Tool Call Semantics Alignment
Nucleic-Session: B5E370C3-4764-47E1-95D3-A36B4AAB8AC6
This commit is contained in:
@@ -1,24 +1,32 @@
|
||||
import SwiftUI
|
||||
import NucleicProtocol
|
||||
|
||||
/// One tool call as a collapsible card — the mobile echo of the Mac's tool group. 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. The old remote rendered `started` and
|
||||
/// `completed` as two separate one-line rows and dropped results/file-changes entirely.
|
||||
struct ToolGroupRow: View {
|
||||
/// 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 {
|
||||
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 {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
let content = VStack(alignment: .leading, spacing: 8) {
|
||||
Button { expanded.toggle() } label: { header }.buttonStyle(.plain)
|
||||
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) {
|
||||
@@ -55,44 +63,355 @@ struct ToolGroupRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// A subagent spawn (`Task`/`Agent`) — the gold Orchestra card, mirroring the Mac's
|
||||
/// OrchestrationCard at phone fidelity so an orchestrated run reads distinctly from a plain tool.
|
||||
struct OrchestrationCard: View {
|
||||
let group: ToolGroup
|
||||
/// 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
|
||||
|
||||
private var subtitle: String {
|
||||
group.input["description"]?.stringValue
|
||||
?? group.input["prompt"]?.stringValue
|
||||
?? group.input.compactSummary
|
||||
/// 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)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Button { expanded.toggle() } label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "sparkles").foregroundStyle(Palette.orchestra)
|
||||
Text(group.finished ? "Orchestrated" : "Orchestrating")
|
||||
.font(.caption.weight(.semibold)).foregroundStyle(Palette.orchestra)
|
||||
Text(subtitle).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||
Spacer(minLength: 4)
|
||||
if !group.finished { ProgressView().controlSize(.mini) }
|
||||
Image(systemName: expanded ? "chevron.down" : "chevron.right")
|
||||
.font(.caption2).foregroundStyle(.tertiary)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
header
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.onTapGesture { withAnimation(.easeInOut(duration: 0.15)) { expanded.toggle() } }
|
||||
if expanded {
|
||||
ToolBlock(label: "Task", text: group.input.approvalDetail, mono: false)
|
||||
if let result = group.result {
|
||||
ToolBlock(label: "Result", text: result.compactSummary, mono: false)
|
||||
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
|
||||
}
|
||||
}
|
||||
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 10))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(Color.primary.opacity(0.05), lineWidth: 1))
|
||||
}
|
||||
|
||||
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.name).font(.caption.weight(.semibold))
|
||||
Text(group.input.compactSummary)
|
||||
.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)
|
||||
.background(Palette.orchestra.opacity(0.08), in: RoundedRectangle(cornerRadius: 10))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(Palette.orchestra.opacity(0.35), lineWidth: 1))
|
||||
}
|
||||
|
||||
private var trailingControls: some View {
|
||||
HStack(spacing: 6) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
private var collapseFooter: some View {
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.15)) { expanded = false }
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "chevron.up").font(.caption2.weight(.semibold))
|
||||
Text("Collapse").font(.caption.weight(.medium))
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.foregroundStyle(Palette.accent).contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.horizontal, 10).padding(.vertical, 6)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Subagent & multi-agent cards
|
||||
//
|
||||
// Claude Code spawns subagents through the `Task` tool. Nucleic surfaces that delegated work in
|
||||
// the orchestra gold so it reads distinctly from the agent's own tool calls: a lone `Task`
|
||||
// becomes a `SubagentCard`, and a parallel wave of them becomes an `OrchestrationCard`. Both
|
||||
// build from the projection's nested `children` (a subagent's inner Read/Bash/Edit/thinking),
|
||||
// mirroring the Mac at phone fidelity.
|
||||
|
||||
/// The lifecycle state of a spawned subagent, read from its `Task` group: not finished → still
|
||||
/// working; an error result → failed; otherwise → done.
|
||||
enum SubagentRunState {
|
||||
case running, done, failed
|
||||
|
||||
init(group: ToolGroup) {
|
||||
if !group.finished { self = .running }
|
||||
else if group.isError { self = .failed }
|
||||
else { self = .done }
|
||||
}
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .running: "Working"
|
||||
case .done: "Done"
|
||||
case .failed: "Failed"
|
||||
}
|
||||
}
|
||||
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .running: Palette.orchestra
|
||||
case .done: Palette.success
|
||||
case .failed: Palette.danger
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The human-facing bits of a `Task` tool call, so the subagent cards agree on what to show.
|
||||
enum Subagent {
|
||||
/// The subagent flavor (`subagent_type`: "Explore", "general-purpose", …), or nil.
|
||||
static func type(_ group: ToolGroup) -> String? {
|
||||
let raw = group.input["subagent_type"]?.stringValue?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return (raw?.isEmpty == false) ? raw : nil
|
||||
}
|
||||
|
||||
/// The one-line task the parent handed the subagent (`description`), falling back to the
|
||||
/// full `prompt`, then a generic label.
|
||||
static func taskLabel(_ group: ToolGroup) -> String {
|
||||
if let description = group.input["description"]?.stringValue?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines), !description.isEmpty {
|
||||
return description
|
||||
}
|
||||
if let prompt = group.input["prompt"]?.stringValue?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines), !prompt.isEmpty {
|
||||
return prompt
|
||||
}
|
||||
return "Subagent task"
|
||||
}
|
||||
|
||||
/// "Explore — find the composer", joining the flavor and task for a compact one-liner.
|
||||
static func headline(_ group: ToolGroup) -> String {
|
||||
let label = taskLabel(group)
|
||||
if let type = type(group) { return "\(type) — \(label)" }
|
||||
return label
|
||||
}
|
||||
|
||||
/// The subagent's returned report, or nil while it's still working / reported nothing.
|
||||
static func report(_ group: ToolGroup) -> String? {
|
||||
guard let result = group.result else { return nil }
|
||||
let text = result.compactSummary
|
||||
return text.isEmpty ? nil : text
|
||||
}
|
||||
}
|
||||
|
||||
/// A small leading status glyph for a subagent — a gold spinner while it works, a green check or
|
||||
/// red cross once it settles.
|
||||
struct SubagentStatusGlyph: View {
|
||||
let state: SubagentRunState
|
||||
var body: some View {
|
||||
Group {
|
||||
switch state {
|
||||
case .running: ProgressView().controlSize(.mini).tint(Palette.orchestra)
|
||||
case .done: Image(systemName: "checkmark.circle.fill").foregroundStyle(Palette.success)
|
||||
case .failed: Image(systemName: "xmark.octagon.fill").foregroundStyle(Palette.danger)
|
||||
}
|
||||
}
|
||||
.font(.caption).frame(width: 16)
|
||||
}
|
||||
}
|
||||
|
||||
/// A status pill (spinner / check / cross + word) for a subagent card header.
|
||||
struct SubagentStatusChip: View {
|
||||
let state: SubagentRunState
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
SubagentStatusGlyph(state: state).frame(width: 14)
|
||||
Text(state.label)
|
||||
}
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(state.color)
|
||||
.padding(.horizontal, 7).padding(.vertical, 3)
|
||||
.background(state.color.opacity(0.14), in: Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
/// One delegated subagent (`Task`), as a gold card: its type and the task it was given, a live
|
||||
/// Working / Done / Failed status, and — on expand — its nested activity stream (or, for a
|
||||
/// replayed transcript with none, its returned report). Mirrors the Mac's `SubagentCard`.
|
||||
struct SubagentCard: View {
|
||||
let group: ToolGroup
|
||||
@State private var expanded = false
|
||||
|
||||
private var state: SubagentRunState { SubagentRunState(group: group) }
|
||||
private var report: String? { Subagent.report(group) }
|
||||
private var hasDetail: Bool { !group.children.isEmpty || report != nil }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
header
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
guard hasDetail else { return }
|
||||
withAnimation(.easeInOut(duration: 0.15)) { expanded.toggle() }
|
||||
}
|
||||
if expanded, hasDetail {
|
||||
Divider().overlay(Palette.orchestra.opacity(0.2))
|
||||
SubagentActivityStream(children: group.children, fallbackReport: report)
|
||||
}
|
||||
}
|
||||
.background(Palette.orchestra.opacity(0.08))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(Palette.orchestra.opacity(0.3), lineWidth: 1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 10) {
|
||||
Image(systemName: "person.2.fill")
|
||||
.font(.callout).foregroundStyle(Palette.orchestra).frame(width: 18)
|
||||
(Text("Subagent")
|
||||
+ (Subagent.type(group).map { Text(" · \($0)").foregroundColor(.secondary) } ?? Text("")))
|
||||
.font(.callout.weight(.semibold))
|
||||
Spacer(minLength: 6)
|
||||
SubagentStatusChip(state: state)
|
||||
if hasDetail {
|
||||
Image(systemName: expanded ? "chevron.up" : "chevron.down")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Text(Subagent.taskLabel(group))
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
.lineLimit(expanded ? nil : 2).truncationMode(.tail)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.horizontal, 12).padding(.vertical, 10)
|
||||
}
|
||||
}
|
||||
|
||||
/// A multi-agent fan-out: a wave of two or more subagents (`Task` calls) spawned together. The
|
||||
/// header sums up the wave (count + overall status); each subagent is its own row that expands
|
||||
/// independently to reveal its activity stream and report. Mirrors the Mac's `OrchestrationCard`.
|
||||
struct OrchestrationCard: View {
|
||||
let groups: [ToolGroup]
|
||||
|
||||
/// Overall wave status: failed if any failed, else running if any is still working, else done.
|
||||
private var overall: SubagentRunState {
|
||||
let states = groups.map { SubagentRunState(group: $0) }
|
||||
if states.contains(where: { if case .failed = $0 { true } else { false } }) { return .failed }
|
||||
if states.contains(where: { if case .running = $0 { true } else { false } }) { return .running }
|
||||
return .done
|
||||
}
|
||||
private var doneCount: Int { groups.filter(\.finished).count }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
header
|
||||
ForEach(groups, id: \.toolCallID) { group in
|
||||
Divider().overlay(Palette.orchestra.opacity(0.18))
|
||||
OrchestrationAgentRow(group: group)
|
||||
}
|
||||
}
|
||||
.background(Palette.orchestra.opacity(0.08))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(Palette.orchestra.opacity(0.3), lineWidth: 1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 10) {
|
||||
Image(systemName: "rectangle.3.group.fill")
|
||||
.font(.callout).foregroundStyle(Palette.orchestra).frame(width: 18)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text("Multi-agent fan-out").font(.callout.weight(.semibold))
|
||||
Text("\(groups.count) subagents · \(doneCount) done")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 6)
|
||||
SubagentStatusChip(state: overall)
|
||||
}
|
||||
.padding(.horizontal, 12).padding(.vertical, 10)
|
||||
}
|
||||
}
|
||||
|
||||
/// One subagent within a fan-out: a one-line status header that expands on tap to reveal that
|
||||
/// agent's own activity stream — and, failing that, its returned report. Each row tracks its own
|
||||
/// expansion so agents open independently.
|
||||
private struct OrchestrationAgentRow: View {
|
||||
let group: ToolGroup
|
||||
@State private var expanded = false
|
||||
|
||||
private var state: SubagentRunState { SubagentRunState(group: group) }
|
||||
private var report: String? { Subagent.report(group) }
|
||||
private var hasDetail: Bool { !group.children.isEmpty || report != nil }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
header
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
guard hasDetail else { return }
|
||||
withAnimation(.easeInOut(duration: 0.15)) { expanded.toggle() }
|
||||
}
|
||||
if expanded, hasDetail {
|
||||
SubagentActivityStream(children: group.children, fallbackReport: report)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 9) {
|
||||
SubagentStatusGlyph(state: state)
|
||||
Text(Subagent.headline(group))
|
||||
.font(.caption).lineLimit(1).truncationMode(.tail)
|
||||
Spacer(minLength: 6)
|
||||
if hasDetail {
|
||||
Image(systemName: expanded ? "chevron.up" : "chevron.down")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12).padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
/// A subagent's nested activity — the calls, thinking, and prose it produced — rendered with the
|
||||
/// same row views the main transcript uses, recursing into nested subagents' own cards. Falls
|
||||
/// back to the returned report when there's no captured inner activity (a replayed transcript).
|
||||
private struct SubagentActivityStream: View {
|
||||
let children: [TranscriptItem]
|
||||
var fallbackReport: String? = nil
|
||||
|
||||
var body: some View {
|
||||
if !children.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(children) { child in
|
||||
TranscriptRow(item: child)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12).padding(.vertical, 8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
} else if let fallbackReport {
|
||||
Text(fallbackReport)
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 12).padding(.vertical, 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,7 +444,7 @@ enum ToolGlyph {
|
||||
case "Edit", "Write", "MultiEdit", "NotebookEdit": return "pencil"
|
||||
case "Grep", "Glob", "Search": return "magnifyingglass"
|
||||
case "WebFetch", "WebSearch": return "globe"
|
||||
case "Task", "Agent": return "sparkles"
|
||||
case "Task", "Agent": return "person.2"
|
||||
case "TodoWrite": return "checklist"
|
||||
case "AskUserQuestion": return "questionmark.bubble"
|
||||
default: return "wrench.and.screwdriver"
|
||||
|
||||
Reference in New Issue
Block a user