Merge nucleic/amber-umber-falcon-aa9i into dev
This commit is contained in:
@@ -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 { details }
|
||||
if expanded {
|
||||
ToolCallPanel(group: group)
|
||||
.padding(.leading, ToolRailMetrics.textInset)
|
||||
}
|
||||
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,23 +546,93 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user