Merge nucleic/amber-umber-falcon-aa9i into dev

This commit is contained in:
2026-08-07 00:43:34 -07:00
parent 81555a4ea1
commit 68c2875a7b
8 changed files with 530 additions and 168 deletions
@@ -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
@@ -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)
}
}
@@ -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.)
@@ -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<Content: View>: 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<ToolGroup> {
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<ToolGroup> {
showAll ? groups[...] : groups.prefix(Self.collapsedLineLimit)
}
private var hidden: ArraySlice<ToolGroup> {
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 {
@@ -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__<server>__<tool>`), 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
}
}
@@ -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
}
}
@@ -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
@@ -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