diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/HostExecCard.swift b/NucleicRemote/NucleicRemote/Views/Transcript/HostExecCard.swift index 3699bf0..99acd3e 100644 --- a/NucleicRemote/NucleicRemote/Views/Transcript/HostExecCard.swift +++ b/NucleicRemote/NucleicRemote/Views/Transcript/HostExecCard.swift @@ -106,14 +106,72 @@ struct HostCommandBreakdown: View { if let dir = summary.workingDirectory { detailRow(icon: "folder", lines: ["in \(dir)"]) } - ForEach(Array(summary.invocations.enumerated()), id: \.element.id) { index, inv in - if index > 0 { Divider().overlay(Color.primary.opacity(0.08)) } - invocationView(inv) - } + steps } .frame(maxWidth: .infinity, alignment: .leading) } + /// At most this many steps render in the compact chain list before the rest fold into a + /// "+N more" tail — so a long `cmd1 && cmd2 && …` pipeline can't grow the card unbounded + /// (the exact command shown alongside is always the complete source of truth). + private static let maxVisibleSteps = 7 + + /// The invocations, rendered to fit the chain length: a lone command gets the full block + /// (program + actions + every env/flag/operand spelled out); a *chain* collapses to one + /// compact line per command so a long pipeline stays short, not a tall stack of blocks. + @ViewBuilder private var steps: some View { + if summary.invocations.count <= 1 { + if let only = summary.invocations.first { invocationView(only) } + } else { + VStack(alignment: .leading, spacing: 6) { + ForEach(Array(summary.invocations.prefix(Self.maxVisibleSteps))) { inv in + stepRow(inv) + } + let hidden = summary.invocations.count - Self.maxVisibleSteps + if hidden > 0 { + Text("+\(hidden) more step\(hidden == 1 ? "" : "s")") + .font(.caption2).foregroundStyle(.tertiary) + .padding(.leading, 22) + } + } + } + } + + /// One command in a chain, compacted to a single line: a step glyph, the program + actions + /// emphasized, then a truncated tail of its env/flags/operands. A destructive/elevated step is + /// tinted and glyphed in the danger color so it still stands out. + private func stepRow(_ inv: HostCommandSummary.Invocation) -> some View { + let risky = inv.destructive || inv.elevated + let tail = stepTail(inv) + return HStack(alignment: .firstTextBaseline, spacing: 8) { + Image(systemName: stepGlyph(inv)) + .font(.caption2).foregroundStyle(risky ? Palette.danger : accent).frame(width: 14) + (Text(inv.headline) + .font(.caption.monospaced().weight(.semibold)) + .foregroundColor(.primary) + + Text(tail.isEmpty ? "" : " " + tail) + .font(.caption.monospaced()) + .foregroundColor(.secondary)) + .lineLimit(1).truncationMode(.middle) + .textSelection(.enabled) + Spacer(minLength: 0) + } + } + + /// The trailing detail of a compacted step — its env, flags, and operands joined on one line, + /// truncated by the row. The literal command carries the untruncated form. + private func stepTail(_ inv: HostCommandSummary.Invocation) -> String { + (inv.env.map(\.display) + inv.flags.map(\.display) + inv.arguments).joined(separator: " ") + } + + /// The leading glyph for a compacted step: a delete/elevation marker takes priority so the + /// risky step is unmistakable; otherwise a plain terminal glyph. + private func stepGlyph(_ inv: HostCommandSummary.Invocation) -> String { + if inv.destructive { return "trash" } + if inv.elevated { return "lock.shield" } + return "terminal" + } + /// The deterministic, command-derived purpose — labeled so the user knows it's *Nucleic's* /// reading of the command, not the agent's claim about it. private var purposeRow: some View {