diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 6a30816..c9aac61 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -1066,8 +1066,12 @@ struct TranscriptList: View { // transcript jitter up and down under a drag. A stable height breaks the loop. // Matches the Mac transcript, for the same reason. VStack(alignment: .leading, spacing: 14) { - ForEach(items) { item in - TranscriptRow(item: item).id(item.id) + // Display rows, not projected items: a supervisor's run of standalone lock + // notes is gathered into one row here rather than taking a full 14pt gap each + // (see `TranscriptDisplay`). Purely presentational — the projection, its + // incremental fold, and the pre-warm above all keep working on `items`. + ForEach(TranscriptDisplay.rows(items)) { row in + TranscriptDisplayRowView(row: row).id(row.id) } // Fixed, zero-content scroll target for "jump to bottom". Scrolling to the last // row's id instead would chase a moving target — the final row's identity diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/HostCommandSummary.swift b/NucleicRemote/NucleicRemote/Views/Transcript/HostCommandSummary.swift index 4c20eb6..db11489 100644 --- a/NucleicRemote/NucleicRemote/Views/Transcript/HostCommandSummary.swift +++ b/NucleicRemote/NucleicRemote/Views/Transcript/HostCommandSummary.swift @@ -812,16 +812,23 @@ extension ToolGroup { var hostCommand: String? { input["command"]?.stringValue } /// The tool name as shown to the user — the host-exec gate reads as a clean "Host" tag, and /// the VM/container tools as where they run ("macOS VM"), rather than their raw - /// `mcp__nucleic__…` wire names. + /// `mcp__nucleic__…` wire names. A tool from some *other* MCP server has no entry in that + /// table, so it falls back to the readable form of its wire name ("Create issue") instead of + /// putting `mcp__github__create_issue` in the transcript. + /// + /// Transcript rows only — the approval gate keeps the exact wire name via + /// `SandboxToolDisplay.gateLabel`, since granting a permission means knowing precisely which + /// tool you granted it to. var displayName: String { if isHostExec { return "Host" } - return SandboxToolDisplay.label(for: name) ?? name + return SandboxToolDisplay.label(for: name) ?? ToolDisplayName.prettify(name) } /// The one-line body beside the name: the sandbox tools parse their own arguments (a shell - /// line as `$ command`, a computer-use step as "left click (120, 400)"); everything else keeps - /// the generic JSON gist. + /// line as `$ command`, a computer-use step as "left click (120, 400)"); everything else takes + /// the generic gist, which names the field that identifies the call rather than listing the + /// input's keys. var displayDetail: String { - SandboxToolDisplay.detail(for: name, input: input) ?? input.compactSummary + SandboxToolDisplay.detail(for: name, input: input) ?? ToolDetail.gist(input) } } diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/HostExecCard.swift b/NucleicRemote/NucleicRemote/Views/Transcript/HostExecCard.swift index 8313dd2..c9afc20 100644 --- a/NucleicRemote/NucleicRemote/Views/Transcript/HostExecCard.swift +++ b/NucleicRemote/NucleicRemote/Views/Transcript/HostExecCard.swift @@ -28,11 +28,14 @@ struct HostExecToolCard: View { private var command: String { group.hostCommand ?? group.input.compactSummary } /// The breakdown is parsed from the command string alone — never from anything the agent said. private var parsed: HostCommandSummary.Summary? { HostCommandSummary.summary(for: command) } - private var output: String? { - let text = group.result?.compactSummary ?? "" - return text.isEmpty ? nil : text + /// `host_exec` returns `{exit_code, stdout, stderr}`, so the card shows the exit status and + /// each stream rather than a one-line gist of the envelope — which, for an object it doesn't + /// recognise, rendered the *key names*: a failed build read "exit_code, stderr, stdout". + private var hasOutput: Bool { + guard let result = group.result else { return false } + if SandboxExecResult(result) != nil { return true } + return !result.resultText.isEmpty } - private var hasOutput: Bool { output != nil } /// Whether the parse carries structure worth revealing (a working dir, more than one step, or /// any flags/args/env) — so a richer command is expandable even before it has output. @@ -62,9 +65,8 @@ struct HostExecToolCard: View { Divider().overlay(Color.primary.opacity(0.06)) HostCommandBreakdown(summary: parsed, showPurpose: false) } - if let output { - ToolHostBlock(label: group.isError ? "Error" : "Output", - text: output, danger: group.isError) + if let result = group.result { + ToolResultSection(result: result, isError: group.isError) } } } @@ -298,22 +300,5 @@ struct HostCommandBreakdown: View { } } -/// A labeled block of monospaced text for a host call's output — mirrors the transcript's other -/// tool blocks, bounded in height and selectable. -private struct ToolHostBlock: View { - let label: String - let text: String - var danger: Bool = false - - var body: some View { - VStack(alignment: .leading, spacing: 2) { - Text(label.uppercased()).font(.caption2.weight(.semibold)).foregroundStyle(.tertiary) - Text(text) - .font(.caption.monospaced()) - .foregroundStyle(danger ? Palette.danger : .secondary) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - .frame(maxHeight: 220) - } - } -} +// (A host call's output renders through the shared `ToolResultSection` / `ToolOutputBlock` in +// ToolGroupRow.swift, so it reads the same as every other tool's.) diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift b/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift index 2203326..54c2320 100644 --- a/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift +++ b/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift @@ -1,60 +1,131 @@ import SwiftUI import NucleicProtocol -/// One tool call as a collapsible card — the mobile echo of the Mac's `ToolCallRow`. Collapsed -/// it shows the tool, a one-line input gist, and a running spinner; expanded it reveals the full -/// input, the result, and any files the call touched. When `inGroup` is set the card renders -/// flush (no background/border) because an enclosing `ToolBlockCard` supplies the single -/// contiguous container — exactly as the Mac groups a run of calls. -struct ToolCallCard: View { +// MARK: - The quiet rail +// +// Tool calls read as an aligned list under one hairline rail rather than a stack of grey boxes. +// A transcript is mostly tool calls, and giving each its own filled, bordered container turned +// the conversation into a wall of chrome — the prose that matters got the same visual weight as +// a `Read`. Here the rail carries the grouping, a fixed icon gutter keeps every tool name on one +// x so the column scans, and the box only appears where it earns its keep: the tinted panel a +// single line opens into when you actually want that call's input and result. + +/// Shared geometry for the rail, so a line, its lock trail, and its expanded panel all align to +/// the same two columns however deeply they're nested. +enum ToolRailMetrics { + /// The hairline itself. + static let rail: CGFloat = 2 + /// Hairline to icon gutter. + static let railGap: CGFloat = 9 + /// The icon column, so every tool name starts at one x. + static let gutter: CGFloat = 17 + /// Icon gutter to name. + static let iconGap: CGFloat = 6 + /// Leading inset for content that should line up with a row's *text*, not its glyph. + static let textInset: CGFloat = gutter + iconGap + /// Between two call lines in a run — tight enough to read as one list, since the transcript's + /// own 14pt row gap already separates the run from the prose around it. + static let lineSpacing: CGFloat = 9 +} + +/// The hairline a run of tool lines hangs off. Draws no fill and no border: nested inside a gold +/// subagent card it adds a rule, not another box. +struct ToolRail: View { + /// Overrides the hairline colour — the danger tint when a call in the run failed. + var tint: Color? = nil + @ViewBuilder var content: Content + + var body: some View { + HStack(alignment: .top, spacing: ToolRailMetrics.railGap) { + Capsule(style: .continuous) + .fill(tint ?? Color.primary.opacity(0.12)) + .frame(width: ToolRailMetrics.rail) + VStack(alignment: .leading, spacing: ToolRailMetrics.lineSpacing) { content } + .frame(maxWidth: .infinity, alignment: .leading) + } + .fixedSize(horizontal: false, vertical: true) + } +} + +/// One tool call as a single line on the rail: glyph, name, and a one-line gist of what it was +/// asked to do. Tapping opens *that* line into a tinted panel with its full input, its result, +/// and the files it touched — so opening one call in a run of ten doesn't unfold the other nine. +struct ToolCallLine: View { let group: ToolGroup - /// Renders flush inside a `ToolBlockCard` (the block draws the one card) when true. - var inGroup: Bool = false @State private var expanded = false var body: some View { - let content = VStack(alignment: .leading, spacing: 8) { - Button { expanded.toggle() } label: { header } + VStack(alignment: .leading, spacing: 6) { + Button { + withAnimation(.easeInOut(duration: 0.15)) { expanded.toggle() } + } label: { summary } .buttonStyle(.plain) .hoverEffect(.highlight) - // Folded lock-lifecycle lines read as part of the edit card (always visible, not gated - // by expand), so the lock brackets its edit rather than sorting after it — as on the Mac. + // Folded lock-lifecycle lines read as part of the call (always visible, not gated by + // expand), so the lock brackets its edit rather than sorting after it — as on the Mac. if !group.lockLines.isEmpty { LockLinesSection(locks: group.lockLines) + .padding(.leading, ToolRailMetrics.textInset) + } + if expanded { + ToolCallPanel(group: group) + .padding(.leading, ToolRailMetrics.textInset) } - if expanded { details } - } - if inGroup { - content.padding(.horizontal, 10).padding(.vertical, 8) - } else { - content - .padding(10) - .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 10)) - .overlay(RoundedRectangle(cornerRadius: 10).strokeBorder( - group.isError ? Palette.danger.opacity(0.5) : Color.primary.opacity(0.05), lineWidth: 1)) } } - private var header: some View { - HStack(spacing: 8) { + private var summary: some View { + HStack(alignment: .firstTextBaseline, spacing: ToolRailMetrics.iconGap) { Image(systemName: ToolGlyph.icon(group.name)) - .font(.caption).foregroundStyle(group.isError ? Palette.danger : .secondary) - Text(group.displayName).font(.caption.weight(.semibold)) + .font(.caption) + .foregroundStyle(group.isError ? Palette.danger : .secondary) + .frame(width: ToolRailMetrics.gutter, alignment: .leading) + Text(group.displayName) + .font(.caption.weight(.semibold)) + .foregroundStyle(group.isError ? Palette.danger : .primary) + .layoutPriority(1) Text(group.displayDetail) - .font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(1) + .font(.caption.monospaced()).foregroundStyle(.secondary) + .lineLimit(1).truncationMode(truncation) Spacer(minLength: 4) if !group.finished { ProgressView().controlSize(.mini) } - Image(systemName: expanded ? "chevron.down" : "chevron.right") - .font(.caption2).foregroundStyle(.tertiary) + Image(systemName: "chevron.right") + .font(.system(size: 9, weight: .semibold)).foregroundStyle(.tertiary) + .rotationEffect(.degrees(expanded ? 90 : 0)) } .contentShape(Rectangle()) } + /// A bare path is identified by its tail, so clip its middle; a command or a sentence reads + /// from the front, so clip its tail. + private var truncation: Text.TruncationMode { + let detail = group.displayDetail + return detail.contains("/") && !detail.contains(" ") ? .middle : .tail + } +} + +/// The panel one tool line opens into: its input, its result, and the files it changed, inset +/// under the line's text column and tinted rather than boxed in grey — accent normally, danger +/// when the call failed. +private struct ToolCallPanel: View { + let group: ToolGroup + + private var tint: Color { group.isError ? Palette.danger : Palette.accent } + + var body: some View { + VStack(alignment: .leading, spacing: 9) { content } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 10).padding(.vertical, 8) + .background(tint.opacity(0.06), in: RoundedRectangle(cornerRadius: 9, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 9, style: .continuous) + .strokeBorder(tint.opacity(0.16), lineWidth: 1)) + } + /// Only shell tools carry a literal command worth structuring (a git commit); everything else /// keeps the plain input block, so a non-shell input can't be misread as a commit. private var isShellTool: Bool { group.name == "Bash" || group.name == "Shell" } - @ViewBuilder private var details: some View { + @ViewBuilder private var content: some View { let input = group.input.approvalDetail // A git commit reads as a structured commit card (subject + Markdown body); a multi-step or // destructive shell pipeline reads as a compact step list (deletes flagged in red) — both @@ -68,121 +139,92 @@ struct ToolCallCard: View { HostCommandBreakdown(summary: summary, showPurpose: false) CommandDisclosure(command: input, accent: Palette.accent) } else if !input.isEmpty { - ToolBlock(label: "Input", text: input, mono: true) + ToolOutputBlock(label: "Input", text: input) } if let result = group.result { - ToolBlock(label: group.isError ? "Error" : "Result", - text: result.compactSummary, mono: true, danger: group.isError) + ToolResultSection(result: result, isError: group.isError) } if !group.fileChanges.isEmpty { VStack(alignment: .leading, spacing: 3) { ForEach(group.fileChanges, id: \.path) { change in Label(change.path, systemImage: ToolGlyph.fileChange(change.change)) - .font(.caption2.monospaced()).foregroundStyle(.secondary).lineLimit(1) + .font(.caption2.monospaced()).foregroundStyle(.secondary) + .lineLimit(1).truncationMode(.middle) } } } } } -/// A run of two or more consecutive tool calls rendered as ONE contiguous card — the mobile echo -/// of the Mac's `ToolGroupRow`. Collapsed it lists up to five one-line summaries (glyph + tool + -/// input gist) with a call count and a "See more"; tapping the block reveals every call in full, -/// each its own flush `ToolCallCard` with result and file changes. -struct ToolBlockCard: View { - let groups: [ToolGroup] - @State private var expanded = false - - /// A collapsed block shows at most this many summary lines before a "See more". - private static let collapsedLineLimit = 5 - - private var allFinished: Bool { groups.allSatisfy(\.finished) } - private var hiddenCount: Int { max(0, groups.count - Self.collapsedLineLimit) } - private var shownGroups: ArraySlice { - expanded ? groups[...] : groups.prefix(Self.collapsedLineLimit) - } +/// A lone tool call — one line on its own short rail, so a single call and a run of them read the +/// same way. +struct ToolCallCard: View { + let group: ToolGroup var body: some View { - VStack(alignment: .leading, spacing: 0) { - header - .contentShape(Rectangle()) - .hoverEffect(.highlight) - .onTapGesture { withAnimation(.easeInOut(duration: 0.15)) { expanded.toggle() } } - if expanded { - ForEach(groups, id: \.toolCallID) { group in - Divider().overlay(Color.primary.opacity(0.06)) - ToolCallCard(group: group, inGroup: true) - } - Divider().overlay(Color.primary.opacity(0.06)) - collapseFooter - } + ToolRail(tint: group.isError ? Palette.danger.opacity(0.45) : nil) { + ToolCallLine(group: group) } - .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 10)) - .overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(Color.primary.opacity(0.05), lineWidth: 1)) + } +} + +/// A run of two or more consecutive tool calls: one aligned list under a single rail — the mobile +/// echo of the Mac's `ToolGroupRow`. A long run shows its first few lines and folds the rest +/// behind a "N more", which carries a spinner and a lock glyph when the calls it hides are still +/// running or still holding a file lock, so collapsing never hides live state. +struct ToolBlockCard: View { + let groups: [ToolGroup] + @State private var showAll = false + + /// A collapsed run shows at most this many lines before the "N more". + private static let collapsedLineLimit = 5 + + private var shown: ArraySlice { + showAll ? groups[...] : groups.prefix(Self.collapsedLineLimit) + } + private var hidden: ArraySlice { + showAll ? [] : groups.dropFirst(Self.collapsedLineLimit) } - private var header: some View { - VStack(alignment: .leading, spacing: 7) { - ForEach(Array(shownGroups.enumerated()), id: \.element.toolCallID) { index, group in - HStack(spacing: 8) { - Image(systemName: ToolGlyph.icon(group.name)) - .font(.caption).foregroundStyle(group.isError ? Palette.danger : .secondary) - .frame(width: 16) - Text(group.displayName).font(.caption.weight(.semibold)) - Text(group.displayDetail) - .font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(1) - Spacer(minLength: 4) - if index == 0 { trailingControls } - } - } - if !expanded, hiddenCount > 0 { - Text("See \(hiddenCount) more…") - .font(.caption.weight(.medium)).foregroundStyle(Palette.accent) - .padding(.leading, 24) - } - } - .padding(10) - } - - /// The lock state of any call in the block that is currently waiting/held (newest wins), so a - /// collapsed block flags at a glance that it holds a file lock. `nil` when nothing is locked or - /// every lock has been released — expand to see which calls and paths. Mirrors the Mac's - /// `blockLockState`. - private var blockLockState: NoteLock.State? { - for group in groups.reversed() { + /// The lock state of the last hidden call still waiting on or holding a lock — nil when every + /// hidden call has released. + private var hiddenLockState: NoteLock.State? { + for group in hidden.reversed() { if let state = group.lockLines.last?.state, state != .released { return state } } return nil } - private var trailingControls: some View { - HStack(spacing: 6) { - // A held/waiting lock anywhere in the block shows as a small glyph, collapsed. - if let lockState = blockLockState { - Image(systemName: LockLineStyle.icon(lockState)) - .font(.caption2).foregroundStyle(.tertiary) + var body: some View { + ToolRail(tint: groups.contains(where: \.isError) ? Palette.danger.opacity(0.45) : nil) { + ForEach(shown, id: \.toolCallID) { group in + ToolCallLine(group: group) } - Text("\(groups.count)") - .font(.caption2.weight(.semibold).monospacedDigit()).foregroundStyle(.secondary) - if !allFinished { ProgressView().controlSize(.mini) } - Image(systemName: expanded ? "chevron.down" : "chevron.right") - .font(.caption2).foregroundStyle(.tertiary) + if !hidden.isEmpty || showAll { moreToggle } } } - private var collapseFooter: some View { + private var moreToggle: some View { Button { - withAnimation(.easeInOut(duration: 0.15)) { expanded = false } + withAnimation(.easeInOut(duration: 0.15)) { showAll.toggle() } } label: { - HStack(spacing: 6) { - Image(systemName: "chevron.up").font(.caption2.weight(.semibold)) - Text("Collapse").font(.caption.weight(.medium)) + HStack(alignment: .firstTextBaseline, spacing: ToolRailMetrics.iconGap) { + Image(systemName: showAll ? "chevron.up" : "ellipsis") + .font(.system(size: 9, weight: .semibold)) + .frame(width: ToolRailMetrics.gutter, alignment: .leading) + Text(showAll ? "Show fewer" : "\(hidden.count) more") + .font(.caption.weight(.medium)) + if let lockState = hiddenLockState { + Image(systemName: LockLineStyle.icon(lockState)) + .font(.caption2).foregroundStyle(.tertiary) + } + if hidden.contains(where: { !$0.finished }) { ProgressView().controlSize(.mini) } Spacer(minLength: 0) } - .foregroundStyle(Palette.accent).contentShape(Rectangle()) + .foregroundStyle(Palette.accent) + .contentShape(Rectangle()) } .buttonStyle(.plain) - .padding(.horizontal, 10).padding(.vertical, 6) } } @@ -265,9 +307,10 @@ enum Subagent { guard let result = group.result else { return nil } // A `nucleic_subagent` worker returns a JSON envelope — surface its actual `output` (or // failure `message`) rather than a one-line gist of the raw `{ok,session_id,…}` object. A - // `Task` subagent returns prose, which falls through to the gist unchanged. + // `Task` subagent returns prose, which falls through unchanged — via `resultText`, so a + // report delivered as an MCP content array reads as the report and not as "[1 items]". if let worker = workerText(result.resultText), !worker.isEmpty { return worker } - let text = result.compactSummary + let text = result.resultText return text.isEmpty ? nil : text } @@ -485,8 +528,10 @@ private struct SubagentActivityStream: View { var body: some View { if !children.isEmpty { VStack(alignment: .leading, spacing: 8) { - ForEach(children) { child in - TranscriptRow(item: child) + // Same lock-run gathering as the main transcript — a worker's own edits produce + // the same trail, and it must not push its actual work off the screen. + ForEach(TranscriptDisplay.rows(children)) { row in + TranscriptDisplayRowView(row: row) } } .padding(.horizontal, 12).padding(.vertical, 8) @@ -501,26 +546,96 @@ private struct SubagentActivityStream: View { } } -/// A labeled, scroll-capped block of monospaced (or prose) text for a tool's input/result. -private struct ToolBlock: View { +/// A labeled block of a tool's text — an input, a command's output, a result. +/// +/// Long text clamps to a readable stub with a Show more/Show less toggle. The blocks this +/// replaces capped their height at 220pt instead, which sliced the text off mid-line with no way +/// to reach the rest — the failing case being exactly the one you open a tool call for: a build +/// whose error is at the bottom of a long log. +struct ToolOutputBlock: View { let label: String let text: String var mono: Bool = true var danger: Bool = false + /// A collapsed block shows at most this many lines. + var collapsedLines: Int = 12 + + @State private var expanded = false + + private var lineCount: Int { text.reduce(1) { $1.isNewline ? $0 + 1 : $0 } } + /// Whether the clamp actually hides something. A long *single* line wraps past the limit too, + /// so estimate that case by length rather than leaving it clipped with no way out. + private var isClamped: Bool { lineCount > collapsedLines || text.count > collapsedLines * 60 } var body: some View { - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: 3) { Text(label.uppercased()).font(.caption2.weight(.semibold)).foregroundStyle(.tertiary) Text(text) .font(mono ? .caption.monospaced() : .caption) .foregroundStyle(danger ? Palette.danger : .secondary) .textSelection(.enabled) + .lineLimit(expanded ? nil : collapsedLines) + .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) - .frame(maxHeight: 220) + if isClamped { + Button { + withAnimation(.easeInOut(duration: 0.15)) { expanded.toggle() } + } label: { + Text(expanded ? "Show less" : "Show more") + .font(.caption2.weight(.medium)).foregroundStyle(Palette.accent) + } + .buttonStyle(.plain) + } } } } +/// A tool's result. The sandbox execution tools (`host_exec`, `mac_vm_exec`, `linux_vm_exec`, +/// `linux_container` exec) return a `{exit_code, stdout, stderr}` envelope, which reads as an +/// exit-status line plus its streams as separate blocks; everything else is one block of the +/// result text. +struct ToolResultSection: View { + let result: JSONValue + var isError: Bool = false + + var body: some View { + if let exec = SandboxExecResult(result) { + VStack(alignment: .leading, spacing: 8) { + ExitStatusLine(exitCode: exec.exitCode) + if !exec.stdout.isEmpty { ToolOutputBlock(label: "Output", text: exec.stdout) } + if !exec.stderr.isEmpty { + // Stderr is where build tools put warnings as well as errors, so tint it by + // the command's actual outcome rather than by which stream it arrived on. + ToolOutputBlock(label: "Stderr", text: exec.stderr, danger: !exec.succeeded) + } + if exec.isEmpty { + Text("No output").font(.caption2).foregroundStyle(.tertiary) + } + } + } else { + let text = result.resultText + if !text.isEmpty { + ToolOutputBlock(label: isError ? "Error" : "Result", text: text, danger: isError) + } + } + } +} + +/// How a command exited, stated plainly — green for 0, red for anything else. +struct ExitStatusLine: View { + let exitCode: Int + private var ok: Bool { exitCode == 0 } + + var body: some View { + HStack(spacing: 4) { + Image(systemName: ok ? "checkmark.circle.fill" : "xmark.octagon.fill") + Text(ok ? "exit 0" : "exit \(exitCode)") + } + .font(.caption2.weight(.medium).monospacedDigit()) + .foregroundStyle(ok ? Palette.success : Palette.danger) + } +} + /// The leading glyph and muted one-liner for a lock line folded onto a tool card — the phone echo /// of the Mac's `LockLineStyle`. enum LockLineStyle { diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/ToolPresentation.swift b/NucleicRemote/NucleicRemote/Views/Transcript/ToolPresentation.swift new file mode 100644 index 0000000..3e6f5d9 --- /dev/null +++ b/NucleicRemote/NucleicRemote/Views/Transcript/ToolPresentation.swift @@ -0,0 +1,143 @@ +import Foundation +import NucleicProtocol + +// The pure half of how a tool call presents itself in the transcript: what it's called, the +// one-line gist of what it was asked to do, and how to read its result. No SwiftUI, so it builds +// into the host-testable `NucleicRemoteProjection` target alongside the projection itself — these +// are the parts with edge cases worth pinning down (wire names, unfamiliar input shapes, the +// sandbox tools' `{exit_code, stdout, stderr}` envelope arriving inside an MCP content array). + +/// The `{exit_code, stdout, stderr}` envelope the sandbox execution tools return — `host_exec`, +/// `mac_vm_exec`, `linux_vm_exec`, and `linux_container` exec. +/// +/// Without this the transcript renders the envelope through the generic one-line gist, which for +/// an object it doesn't recognise lists its *key names* — so a build that failed with a page of +/// compiler errors reads, in full, "exit_code, stderr, stdout". Unwrapping it lets the card show +/// what actually happened: whether the command succeeded, and its output as output. +struct SandboxExecResult: Equatable { + let exitCode: Int + let stdout: String + let stderr: String + + var succeeded: Bool { exitCode == 0 } + var isEmpty: Bool { stdout.isEmpty && stderr.isEmpty } + + /// Nil unless `result` really is the exec envelope — an object carrying `exit_code`, either + /// directly or as JSON text inside the MCP content array the tool bridge wraps it in. + init?(_ result: JSONValue) { + guard let object = Self.envelope(result), let code = Self.exitCode(object["exit_code"]) + else { return nil } + exitCode = code + stdout = Self.text(object["stdout"]) + stderr = Self.text(object["stderr"]) + } + + private static func envelope(_ result: JSONValue) -> [String: JSONValue]? { + if let object = result.objectValue, object["exit_code"] != nil { return object } + // An MCP tool delivers the same envelope as a JSON *string* in a content array, so the + // structured value above is a `[{type: "text", text: "{\"exit_code\": …}"}]` array. + let text = result.resultText.trimmingCharacters(in: .whitespacesAndNewlines) + guard text.hasPrefix("{"), let parsed = try? JSONValue(parsing: text), + let object = parsed.objectValue, object["exit_code"] != nil else { return nil } + return object + } + + /// An exit code arrives as a number, but tolerate the string form some bridges emit rather + /// than falling all the way back to the "exit_code, stderr, stdout" rendering over a quote. + private static func exitCode(_ value: JSONValue?) -> Int? { + guard let value else { return nil } + if let int = value.intValue { return int } + if let string = value.stringValue { return Int(string) } + return nil + } + + private static func text(_ value: JSONValue?) -> String { + (value?.stringValue ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + } +} + +/// How an unfamiliar tool names itself in the transcript. +enum ToolDisplayName { + private static let mcpPrefix = "mcp__" + + /// A readable label for a tool the display table doesn't know: + /// `mcp__github__create_issue` → "Create issue". Native tool names (`Read`, `WebFetch`) and + /// anything that isn't an MCP wire name pass through untouched — they already read fine, and + /// re-casing them would only fight their own conventions. + /// + /// Transcript rows only. The approval gate keeps using the exact wire name + /// (`SandboxToolDisplay.gateLabel`): there, the user is granting a specific tool permission and + /// must see precisely which one. + static func prettify(_ name: String) -> String { + guard let bare = bareName(name) else { return name } + let words = bare.split(separator: "_").map(String.init).filter { !$0.isEmpty } + guard let first = words.first else { return name } + // Sentence case, not title case: "Create issue", not "Create Issue" — the rows around it + // are sentence case, and title-casing turns acronyms into "Vm". + return ([first.prefix(1).uppercased() + first.dropFirst()] + words.dropFirst()) + .joined(separator: " ") + } + + /// The tool half of an MCP wire name (`mcp____`), or nil for a native tool. + static func bareName(_ name: String) -> String? { + guard name.hasPrefix(mcpPrefix) else { return nil } + let rest = name.dropFirst(mcpPrefix.count) + guard let separator = rest.range(of: "__") else { return nil } + let bare = String(rest[separator.upperBound...]) + return bare.isEmpty ? nil : bare + } +} + +/// The one-line gist of a tool's input for a collapsed transcript row. +enum ToolDetail { + /// The fields that actually identify a call, most specific first. A tool nobody wrote a + /// display rule for still tends to take one of these. + private static let priorityKeys = [ + "command", "file_path", "path", "notebook_path", "source", "destination", + "pattern", "query", "url", "task", "description", "prompt", "message", "text", "name", + ] + + /// A gist of at most one line. Unlike `compactSummary` — which the projection bakes into + /// `.raw` bodies and so must keep its exact shape — this falls back to `key: value` pairs + /// rather than a bare list of key names, so an unrecognised input still says something about + /// what the call was asked to do. + static func gist(_ input: JSONValue) -> String { + switch input { + case .string(let string): + return singleLine(string) + case .object(let object): + for key in priorityKeys { + if let text = scalar(object[key]), !text.isEmpty { return singleLine(text) } + } + let pairs = object.keys.sorted().compactMap { key -> String? in + guard let text = scalar(object[key]), !text.isEmpty else { return nil } + return "\(key): \(singleLine(text, limit: 60))" + } + // Every field is a nested object or array: there's no honest one-liner to give, so + // fall back to naming the fields — the row is one tap from the full input either way. + return pairs.isEmpty ? object.keys.sorted().joined(separator: ", ") + : pairs.prefix(3).joined(separator: " · ") + default: + return input.compactSummary + } + } + + /// A field's text if it's a scalar; nil for the nested objects and arrays that have no + /// one-line form. + private static func scalar(_ value: JSONValue?) -> String? { + switch value { + case .string(let string): return string + case .number(let number): return number == number.rounded() ? String(Int(number)) : String(number) + case .bool(let bool): return String(bool) + default: return nil + } + } + + /// Flattened to one line and clipped, so a pasted multi-line prompt can't stretch the row or + /// smuggle a newline into a summary that's laid out as a single line. + private static func singleLine(_ text: String, limit: Int = 160) -> String { + let flat = text.split(whereSeparator: \.isNewline).joined(separator: " ") + .trimmingCharacters(in: .whitespaces) + return flat.count > limit ? String(flat.prefix(limit - 1)) + "…" : flat + } +} diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptDisplayRows.swift b/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptDisplayRows.swift new file mode 100644 index 0000000..955fdaf --- /dev/null +++ b/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptDisplayRows.swift @@ -0,0 +1,82 @@ +import Foundation +import NucleicProtocol + +/// One row of the transcript as *displayed*: a projected item, or a run of adjacent file-lock +/// notes gathered into a single row. +/// +/// This layer exists for the Orchestra supervisor case. `AppStore` emits waiting/acquired/released +/// notes for every worker's edit attributed to the supervisor (the lock *holder*), but a supervisor +/// edits nothing itself, so those notes have no edit card for the projection to fold them onto and +/// each one arrives as its own standalone `.note`. A fan-out over a few dozen files therefore +/// renders as a few dozen near-identical rows — each with the transcript's full 14pt inter-row gap +/// — burying the conversation between them. +/// +/// Gathering them here rather than in `TranscriptProjection` is deliberate: the projection is the +/// canonical, cross-platform fold, bound by the incremental projector's +/// `fold(prefix) ++ fold(tail) == fold(whole)` invariant and by the exhaustive `TranscriptItem.Kind` +/// switch that feeds the Windows renderer through `TranscriptProjectionABI`. Grouping purely for +/// readability belongs on the far side of both. Pure model code (Foundation + NucleicProtocol, no +/// SwiftUI) so it builds into the host-testable `NucleicRemoteProjection` target. +struct TranscriptDisplayRow: Identifiable, Equatable { + enum Kind: Equatable { + /// A projected row, passed through untouched. + case item(TranscriptItem) + /// Adjacent lock-lifecycle notes shown as one row — literal lines while the trail is + /// short, a collapsed `LockDigest` once it isn't. Kept in stream order, so the folded + /// view sees exactly what the individual rows would have said. + case lockRun([NoteLock]) + } + + let id: String + let seq: UInt64 + let kind: Kind +} + +enum TranscriptDisplay { + + /// Gathers each maximal run of adjacent, path-carrying lock notes into one row, passing + /// everything else through unchanged. + /// + /// Runs of one are gathered too: a lone lock moment then renders through the same + /// `LockLineRow` the folded card lines use, so every lock in the transcript reads the same way + /// whether it landed on an edit card or on its own. + /// + /// One deliberate exclusion — a lock note carrying no structured `NoteLock` (the re-ground and + /// update-conflict notes) is prose the user may have to act on. It never joins a run, *and* it + /// breaks the run around it rather than being stepped over to splice the two halves together. + static func rows(_ items: [TranscriptItem]) -> [TranscriptDisplayRow] { + var rows: [TranscriptDisplayRow] = [] + rows.reserveCapacity(items.count) + var run: [TranscriptItem] = [] + + func flushRun() { + defer { run.removeAll(keepingCapacity: true) } + guard let first = run.first else { return } + // Keyed on the run's first row, so a growing run extends the same row instead of + // being replaced by a differently-named one — which would drop the reader's expansion + // and jog the scroll anchor as a live fan-out streams its locks in. + rows.append(.init(id: "lockrun-\(first.id)", seq: first.seq, + kind: .lockRun(run.compactMap(structuredLock)))) + } + + for item in items { + if structuredLock(item) != nil { + run.append(item) + } else { + flushRun() + rows.append(.init(id: item.id, seq: item.seq, kind: .item(item))) + } + } + flushRun() + return rows + } + + /// The structured lock a note carries, or nil for anything that isn't a foldable lock moment. + /// A lock note with no paths yields nothing to gather, so it stays an ordinary row; so does a + /// process-stall alert, which is a live control surface (Kill / Keep waiting), not a log line. + private static func structuredLock(_ item: TranscriptItem) -> NoteLock? { + guard case .note(_, _, let lockEvent, let lock, let processStall) = item.kind, + lockEvent, processStall == nil, let lock, !lock.paths.isEmpty else { return nil } + return lock + } +} diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift b/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift index 8e43105..8be37d6 100644 --- a/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift +++ b/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift @@ -698,9 +698,28 @@ extension JSONValue { } } + /// Human-readable tool-result text: a plain string, the joined text of an MCP content array, + /// or the canonical JSON as a last resort. Mirrors the Mac's `TranscriptRow.resultText`, so an + /// answered-question card and a git-commit card read the result the same way on both platforms. + /// Lives here rather than with the row views so the pure presentation helpers (which unwrap the + /// `{exit_code, stdout, stderr}` envelope out of an MCP content array) can reach it. + var resultText: String { + if let string = stringValue { return string } + if let array = arrayValue { + let texts = array.compactMap { $0["text"]?.stringValue } + if !texts.isEmpty { return texts.joined(separator: "\n") } + } + let canonical = canonicalString() + return canonical == "null" ? "" : canonical + } + /// A one-line gist of a tool input/result for the collapsed row. Lives here (not with the /// row views) because the projection folds it into `.raw` bodies, and this file also builds /// standalone as the host-testable `NucleicRemoteProjection` SwiftPM target. + /// + /// Deliberately unchanged for objects it doesn't recognise (a sorted list of key names): the + /// projection bakes it into `.raw` bodies that the cross-platform ABI carries verbatim. Row + /// views want `ToolDetail.gist` instead, which says what the call actually does. var compactSummary: String { switch self { case .string(let s): return s diff --git a/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift b/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift index 7400e52..03579cb 100644 --- a/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift +++ b/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift @@ -5,6 +5,26 @@ import NucleicProtocol /// Mac transcript. Assistant/user prose as Markdown bubbles, tool calls as collapsible cards, /// Orchestra spawns in gold, plus the informational rows (usage/cost, rate limits, turn /// boundaries, run outcome) the older remote dropped. +/// One *displayed* transcript row: a projected item, or a gathered run of lock notes. +/// +/// The gathering exists for the Orchestra supervisor case, where lock notes have no edit card to +/// fold onto and each arrives as its own row — a fan-out over thirty files buried the conversation +/// under thirty near-identical lines. Gathered, they render through the same `LockLinesSection` +/// that a folded card uses, so a lock reads the same wherever it lands: spelled out while the +/// trail is short, a one-line digest that expands per file once it isn't. +struct TranscriptDisplayRowView: View { + let row: TranscriptDisplayRow + + var body: some View { + switch row.kind { + case .item(let item): + TranscriptRow(item: item) + case .lockRun(let locks): + LockLinesSection(locks: locks) + } + } +} + struct TranscriptRow: View { let item: TranscriptItem @@ -385,21 +405,8 @@ struct RunFinishedRow: View { } extension JSONValue { - /// Human-readable tool-result text: a plain string, the joined text of an MCP content array, - /// or the canonical JSON as a last resort. Mirrors the Mac's `TranscriptRow.resultText`, so an - /// answered-question card and a git-commit card read the result the same way on both platforms. - var resultText: String { - if let string = stringValue { return string } - if let array = arrayValue { - let texts = array.compactMap { $0["text"]?.stringValue } - if !texts.isEmpty { return texts.joined(separator: "\n") } - } - let canonical = canonicalString() - return canonical == "null" ? "" : canonical - } - - // (`compactSummary` — the one-line gist — lives in TranscriptProjection.swift, which builds - // standalone as the host-testable NucleicRemoteProjection SwiftPM target and needs it there.) + // (`resultText` and `compactSummary` live in TranscriptProjection.swift, which builds + // standalone as the host-testable NucleicRemoteProjection SwiftPM target and needs them there.) /// The full, **untruncated** content of a tool input for the approval card — /// the user must see exactly what they are granting before allowing. Unlike