599 lines
26 KiB
Swift
599 lines
26 KiB
Swift
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 {
|
|
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 }
|
|
.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.
|
|
if !group.lockLines.isEmpty {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
ForEach(Array(group.lockLines.enumerated()), id: \.offset) { _, lock in
|
|
LockLineRow(lock: lock)
|
|
}
|
|
}
|
|
}
|
|
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) {
|
|
Image(systemName: ToolGlyph.icon(group.name))
|
|
.font(.caption).foregroundStyle(group.isError ? Palette.danger : .secondary)
|
|
Text(group.displayName).font(.caption.weight(.semibold))
|
|
Text(group.input.compactSummary)
|
|
.font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(1)
|
|
Spacer(minLength: 4)
|
|
if !group.finished { ProgressView().controlSize(.mini) }
|
|
Image(systemName: expanded ? "chevron.down" : "chevron.right")
|
|
.font(.caption2).foregroundStyle(.tertiary)
|
|
}
|
|
.contentShape(Rectangle())
|
|
}
|
|
|
|
/// 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 {
|
|
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
|
|
// like the Mac, with the literal command one tap away under "Show command". Everything else
|
|
// keeps the plain input block.
|
|
if isShellTool, let commit = GitCommitSummary.parse(input) {
|
|
GitCommitCard(commit: commit, rawCommand: input)
|
|
} else if isShellTool, let summary = HostCommandSummary.summary(for: input),
|
|
summary.invocations.count > 1 || summary.isDestructive
|
|
|| summary.invocations.contains(where: { $0.control != nil }) {
|
|
HostCommandBreakdown(summary: summary, showPurpose: false)
|
|
CommandDisclosure(command: input, accent: Palette.accent)
|
|
} else if !input.isEmpty {
|
|
ToolBlock(label: "Input", text: input, mono: true)
|
|
}
|
|
if let result = group.result {
|
|
ToolBlock(label: group.isError ? "Error" : "Result",
|
|
text: result.compactSummary, mono: true, danger: 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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
.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.displayName).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)
|
|
}
|
|
|
|
/// 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() {
|
|
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)
|
|
}
|
|
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 }
|
|
// A `Task` subagent surfaces failure as an error result. A `nucleic_subagent` worker
|
|
// instead returns a *non-error* result whose JSON envelope carries `{"ok": false}` (or
|
|
// `{"denied": true}`) in-band, so read that too — otherwise a failed worker reads "Done".
|
|
else if group.isError || Subagent.workerFailed(group) { 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: a `nucleic_subagent` worker's `task`
|
|
/// label, or a `Task` subagent's `description`, falling back to the full `prompt`, then a
|
|
/// generic label.
|
|
static func taskLabel(_ group: ToolGroup) -> String {
|
|
if let task = group.input["task"]?.stringValue?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines), !task.isEmpty {
|
|
return task
|
|
}
|
|
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 }
|
|
// 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.
|
|
if let worker = workerText(result.resultText), !worker.isEmpty { return worker }
|
|
let text = result.compactSummary
|
|
return text.isEmpty ? nil : text
|
|
}
|
|
|
|
/// Whether a `nucleic_subagent` worker result reports failure — a `{"ok": false}` completion
|
|
/// or a `{"denied": true}` refusal, both delivered as non-error tool results. False for any
|
|
/// other tool (a `Task` subagent, whose failure is an error result handled separately).
|
|
static func workerFailed(_ group: ToolGroup) -> Bool {
|
|
guard let result = group.result,
|
|
let envelope = workerEnvelope(result.resultText) else { return false }
|
|
if let denied = envelope["denied"] as? Bool, denied { return true }
|
|
if let ok = envelope["ok"] as? Bool { return !ok }
|
|
return false
|
|
}
|
|
|
|
/// The human-facing text of a `nucleic_subagent` worker envelope — the worker's `output` on
|
|
/// success, or its `message` on failure/denial — or nil when `text` isn't such an envelope
|
|
/// (so a `Task` subagent's prose report falls through unchanged).
|
|
private static func workerText(_ text: String) -> String? {
|
|
guard let envelope = workerEnvelope(text) else { return nil }
|
|
if let denied = envelope["denied"] as? Bool, denied {
|
|
return envelope["message"] as? String ?? ""
|
|
}
|
|
guard let ok = envelope["ok"] as? Bool else { return nil }
|
|
return ok ? (envelope["output"] as? String ?? "")
|
|
: (envelope["message"] as? String ?? "")
|
|
}
|
|
|
|
/// Parses a `nucleic_subagent` result string into its JSON envelope, gated on the envelope's
|
|
/// signature keys (`ok` / `denied`) so an arbitrary JSON-shaped tool result isn't mistaken for
|
|
/// one. Nil for anything that isn't a worker envelope.
|
|
private static func workerEnvelope(_ text: String) -> [String: Any]? {
|
|
guard let data = text.data(using: .utf8),
|
|
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
object["ok"] != nil || object["denied"] != nil
|
|
else { return nil }
|
|
return object
|
|
}
|
|
}
|
|
|
|
/// 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("Multiple Subagents").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)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A labeled, scroll-capped block of monospaced (or prose) text for a tool's input/result.
|
|
private struct ToolBlock: View {
|
|
let label: String
|
|
let text: String
|
|
var mono: Bool = true
|
|
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(mono ? .caption.monospaced() : .caption)
|
|
.foregroundStyle(danger ? Palette.danger : .secondary)
|
|
.textSelection(.enabled)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.frame(maxHeight: 220)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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 {
|
|
static func icon(_ state: NoteLock.State) -> String {
|
|
switch state {
|
|
case .waiting: return "hourglass"
|
|
case .acquired: return "lock.fill"
|
|
case .released: return "lock.open"
|
|
}
|
|
}
|
|
|
|
static func text(_ lock: NoteLock) -> String {
|
|
let paths = pathList(lock.paths)
|
|
switch lock.state {
|
|
case .waiting: return "Waiting for lock · \(paths)"
|
|
case .acquired: return "Locked · \(paths)"
|
|
case .released: return "Lock released · \(paths)"
|
|
}
|
|
}
|
|
|
|
/// Up to two repo-relative paths, then a "+N more" tail.
|
|
private static func pathList(_ paths: [String]) -> String {
|
|
guard !paths.isEmpty else { return "no files" }
|
|
let shown = paths.prefix(2).joined(separator: ", ")
|
|
let extra = paths.count - min(paths.count, 2)
|
|
return extra > 0 ? "\(shown) +\(extra) more" : shown
|
|
}
|
|
}
|
|
|
|
/// One folded lock line on a tool card: a small muted glyph + status, matching the card's
|
|
/// secondary detail styling. The phone echo of the Mac's `LockLineRow`.
|
|
struct LockLineRow: View {
|
|
let lock: NoteLock
|
|
var body: some View {
|
|
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
|
Image(systemName: LockLineStyle.icon(lock.state))
|
|
.font(.caption2).foregroundStyle(.tertiary).frame(width: 15)
|
|
Text(LockLineStyle.text(lock))
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
.lineLimit(1).truncationMode(.middle)
|
|
Spacer(minLength: 0)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// SF Symbols for the common tools + file-change kinds, so a glance reads the action.
|
|
enum ToolGlyph {
|
|
static func icon(_ name: String) -> String {
|
|
switch name {
|
|
case "Bash", "Shell": return "terminal"
|
|
case "Read": return "doc.text"
|
|
case "Edit", "Write", "MultiEdit", "NotebookEdit": return "pencil"
|
|
case "Grep", "Glob", "Search": return "magnifyingglass"
|
|
case "WebFetch", "WebSearch": return "globe"
|
|
case "Task", "Agent", ToolGroup.orchestraSubagentToolName: return "person.2"
|
|
case "TodoWrite": return "checklist"
|
|
case "AskUserQuestion": return "questionmark.bubble"
|
|
case ExitPlanMode.toolName: return "list.bullet.clipboard"
|
|
case HostCommandSummary.hostExecToolName: return "desktopcomputer"
|
|
default: return "wrench.and.screwdriver"
|
|
}
|
|
}
|
|
static func fileChange(_ kind: FileChange.ChangeKind) -> String {
|
|
switch kind {
|
|
case .add: return "plus.circle"
|
|
case .update: return "pencil.circle"
|
|
case .delete: return "minus.circle"
|
|
case .rename: return "arrow.triangle.branch"
|
|
}
|
|
}
|
|
}
|