320 lines
16 KiB
Swift
320 lines
16 KiB
Swift
import SwiftUI
|
|
import NucleicProtocol
|
|
|
|
private struct TranscriptWorktreeRootKey: EnvironmentKey {
|
|
static let defaultValue: String? = nil
|
|
}
|
|
|
|
extension EnvironmentValues {
|
|
/// The session's worktree root (its `sessionStarted` cwd), injected by `SessionDetailView` so a
|
|
/// host card's working-directory line can abbreviate an absolute "cd" path to a short
|
|
/// "<worktree name>/…" anchor. Nil (the default) leaves the directory verbatim.
|
|
var transcriptWorktreeRoot: String? {
|
|
get { self[TranscriptWorktreeRootKey.self] }
|
|
set { self[TranscriptWorktreeRootKey.self] = newValue }
|
|
}
|
|
}
|
|
|
|
/// A `mcp__nucleic__host_exec` tool call in the transcript, rendered as a structured card — the
|
|
/// mobile echo of the Mac's `HostExecToolCard`. It leads with the deterministic, command-derived
|
|
/// purpose (the easy-to-read headline), shows the literal `$ command` beneath it, and reveals the
|
|
/// parsed breakdown + output on tap — instead of the generic `mcp__nucleic__host_exec {…}` row the
|
|
/// catch-all tool card would show. The desktop glyph and "Host" tag flag it as running on the
|
|
/// macOS host, outside the sandbox.
|
|
struct HostExecToolCard: View {
|
|
let group: ToolGroup
|
|
@State private var expanded = false
|
|
|
|
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
|
|
}
|
|
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.
|
|
private var hasBreakdown: Bool {
|
|
guard let parsed else { return false }
|
|
if parsed.workingDirectory != nil || parsed.invocations.count > 1 { return true }
|
|
return parsed.invocations.contains {
|
|
$0.control != nil || !$0.flags.isEmpty || !$0.arguments.isEmpty || !$0.env.isEmpty
|
|
}
|
|
}
|
|
/// A host-run `git commit` — surfaced as a structured commit card (subject + Markdown body),
|
|
/// like the Mac, instead of only the generic "Commit changes" purpose.
|
|
private var commit: GitCommitSummary.Commit? { GitCommitSummary.parse(command) }
|
|
private var canExpand: Bool { hasOutput || hasBreakdown || commit != nil }
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Button { if canExpand { withAnimation(.easeInOut(duration: 0.15)) { expanded.toggle() } } }
|
|
label: { header }
|
|
.buttonStyle(.plain)
|
|
if expanded {
|
|
if let commit {
|
|
// The `$ command` is already shown in the header, so omit the disclosure here.
|
|
Divider().overlay(Color.primary.opacity(0.06))
|
|
GitCommitCard(commit: commit)
|
|
} else if hasBreakdown, let parsed {
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
.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(alignment: .top, spacing: 8) {
|
|
Image(systemName: "desktopcomputer")
|
|
.font(.caption).foregroundStyle(Palette.accent).frame(width: 16)
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
|
Text("HOST").font(.caption2.weight(.bold)).foregroundStyle(Palette.accent)
|
|
if let parsed {
|
|
// The command-derived purpose leads as the bold headline; "HOST" is just a
|
|
// quiet tag (the desktop glyph already marks it as a host call).
|
|
Text(MessageBubble.markdown(parsed.purpose))
|
|
.font(.callout.weight(.semibold)).lineLimit(1).truncationMode(.tail)
|
|
} else {
|
|
Text("Host").font(.callout.weight(.semibold))
|
|
}
|
|
}
|
|
HStack(alignment: .firstTextBaseline, spacing: 5) {
|
|
Text("$").font(.caption.monospaced()).foregroundStyle(Palette.accent.opacity(0.7))
|
|
Text(command)
|
|
.font(.caption.monospaced()).foregroundStyle(.secondary)
|
|
// Expanded, the literal stays bounded — a long chain's command is itself
|
|
// long, and the compact step list above carries the structure.
|
|
.lineLimit(expanded ? 6 : 1).truncationMode(.middle)
|
|
.textSelection(.enabled)
|
|
}
|
|
}
|
|
Spacer(minLength: 4)
|
|
if !group.finished { ProgressView().controlSize(.mini) }
|
|
if canExpand {
|
|
Image(systemName: expanded ? "chevron.down" : "chevron.right")
|
|
.font(.caption2).foregroundStyle(.tertiary).padding(.top, 2)
|
|
}
|
|
}
|
|
.contentShape(Rectangle())
|
|
}
|
|
}
|
|
|
|
/// The shared, deterministic breakdown of a host command — driven by ``HostCommandSummary``
|
|
/// (parsed from the command string, never from the agent). The mobile echo of the Mac's
|
|
/// `HostCommandBreakdown`: the inferred purpose (optionally), the working directory, and each
|
|
/// program invocation as program + actions + flags + operands, so the user can verify *exactly*
|
|
/// what's about to run. Used by ``HostExecToolCard`` (in chat) and the approval card.
|
|
struct HostCommandBreakdown: View {
|
|
let summary: HostCommandSummary.Summary
|
|
/// Whether to render the prominent "Likely purpose" row and any `sudo`/destructive banner.
|
|
/// The chat card heads its row with the purpose already, so it passes `false`.
|
|
var showPurpose: Bool = true
|
|
|
|
/// The session's worktree root, so the working-directory line ("cd" target) abbreviates to a
|
|
/// short "<worktree name>/…" anchor instead of a full absolute path. Injected by
|
|
/// `SessionDetailView`; nil leaves the directory verbatim.
|
|
@Environment(\.transcriptWorktreeRoot) private var worktreeRoot
|
|
|
|
private let accent = Palette.accent
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 9) {
|
|
if showPurpose {
|
|
purposeRow
|
|
if summary.isElevated || summary.isDestructive { riskBanner }
|
|
}
|
|
if let dir = summary.workingDirectory {
|
|
detailRow(icon: "folder", lines: ["in \(HostCommandSummary.abbreviatePath(dir, worktree: worktreeRoot, project: HostCommandSummary.repoRoot(forWorktree: worktreeRoot)))"])
|
|
}
|
|
steps
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
/// At most this many steps render in the compact chain list before the rest fold into a
|
|
/// "+N more" tail — so a long `cmd1 && cmd2 && …` pipeline can't grow the card unbounded
|
|
/// (the exact command shown alongside is always the complete source of truth).
|
|
private static let maxVisibleSteps = 7
|
|
|
|
/// The invocations, rendered to fit the chain length: a lone command gets the full block
|
|
/// (program + actions + every env/flag/operand spelled out); a *chain* collapses to one
|
|
/// compact line per command so a long pipeline stays short, not a tall stack of blocks.
|
|
@ViewBuilder private var steps: some View {
|
|
// A lone *program* gets the full block; a lone control-flow header (or any chain) uses the
|
|
// compact step list, which renders the loop/conditional header as one glyphed line.
|
|
if summary.invocations.count == 1, let only = summary.invocations.first, only.control == nil {
|
|
invocationView(only)
|
|
} else {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
ForEach(Array(summary.invocations.prefix(Self.maxVisibleSteps))) { inv in
|
|
stepRow(inv)
|
|
}
|
|
let hidden = summary.invocations.count - Self.maxVisibleSteps
|
|
if hidden > 0 {
|
|
Text("+\(hidden) more step\(hidden == 1 ? "" : "s")")
|
|
.font(.caption2).foregroundStyle(.tertiary)
|
|
.padding(.leading, 22)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One command in a chain, compacted to a single line: a step glyph, the program + actions
|
|
/// emphasized, then a truncated tail of its env/flags/operands. A destructive/elevated step is
|
|
/// tinted and glyphed in the danger color so it still stands out.
|
|
private func stepRow(_ inv: HostCommandSummary.Invocation) -> some View {
|
|
let risky = inv.destructive || inv.elevated
|
|
let tail = stepTail(inv)
|
|
let headline = Text(inv.headline)
|
|
.font(.caption.monospaced().weight(.semibold))
|
|
.foregroundColor(.primary)
|
|
let tailText = Text(tail.isEmpty ? "" : " " + tail)
|
|
.font(.caption.monospaced())
|
|
.foregroundColor(.secondary)
|
|
return HStack(alignment: .firstTextBaseline, spacing: 8) {
|
|
Image(systemName: stepGlyph(inv))
|
|
.font(.caption2).foregroundStyle(risky ? Palette.danger : accent).frame(width: 14)
|
|
Text("\(headline)\(tailText)")
|
|
.lineLimit(1).truncationMode(.middle)
|
|
.textSelection(.enabled)
|
|
Spacer(minLength: 0)
|
|
}
|
|
}
|
|
|
|
/// The trailing detail of a compacted step — its env, flags, and operands joined on one line,
|
|
/// truncated by the row. The literal command carries the untruncated form.
|
|
private func stepTail(_ inv: HostCommandSummary.Invocation) -> String {
|
|
(inv.env.map(\.display) + inv.flags.map(\.display) + inv.arguments).joined(separator: " ")
|
|
}
|
|
|
|
/// The leading glyph for a compacted step: a loop/conditional header gets a control-flow glyph;
|
|
/// a delete/elevation marker takes priority so the risky step is unmistakable; otherwise a
|
|
/// plain terminal glyph.
|
|
private func stepGlyph(_ inv: HostCommandSummary.Invocation) -> String {
|
|
if let control = inv.control { return control.kind == .loop ? "repeat" : "arrow.triangle.branch" }
|
|
if inv.destructive { return "trash" }
|
|
if inv.elevated { return "lock.shield" }
|
|
return "terminal"
|
|
}
|
|
|
|
/// The deterministic, command-derived purpose — labeled so the user knows it's *Nucleic's*
|
|
/// reading of the command, not the agent's claim about it.
|
|
private var purposeRow: some View {
|
|
HStack(alignment: .top, spacing: 10) {
|
|
Image(systemName: "sparkles").font(.title3).foregroundStyle(accent).frame(width: 22)
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(MessageBubble.markdown(summary.purpose))
|
|
.font(.title3.weight(.semibold))
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.textSelection(.enabled)
|
|
Text("Likely purpose · inferred from the command")
|
|
.font(.caption2.weight(.medium)).foregroundStyle(.secondary)
|
|
}
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(.horizontal, 11).padding(.vertical, 9)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(accent.opacity(0.12), in: RoundedRectangle(cornerRadius: 8))
|
|
.overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(accent.opacity(0.22), lineWidth: 1))
|
|
}
|
|
|
|
/// A red banner for the two things a host command most needs flagged before "Allow": running
|
|
/// as root (`sudo`) and deleting files (`rm`).
|
|
private var riskBanner: some View {
|
|
let icon = summary.isElevated ? "lock.shield" : "trash"
|
|
let text = summary.isElevated
|
|
? "Runs with elevated privileges (sudo)."
|
|
: "Deletes files on the host."
|
|
return HStack(alignment: .firstTextBaseline, spacing: 7) {
|
|
Image(systemName: icon).font(.caption).foregroundStyle(Palette.danger).frame(width: 16)
|
|
Text(text).font(.caption.weight(.medium)).foregroundStyle(Palette.danger)
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(.horizontal, 8).padding(.vertical, 5)
|
|
.background(Palette.danger.opacity(0.1), in: RoundedRectangle(cornerRadius: 6))
|
|
}
|
|
|
|
/// One program invocation: the program name as a filled badge, its actions as outlined chips,
|
|
/// and — beneath — its env, flags, and operands each as a labeled, monospaced list.
|
|
private func invocationView(_ inv: HostCommandSummary.Invocation) -> some View {
|
|
VStack(alignment: .leading, spacing: 5) {
|
|
HStack(spacing: 6) {
|
|
if inv.elevated { chip("sudo", color: Palette.danger, filled: true) }
|
|
chip(inv.program, color: accent, filled: true)
|
|
ForEach(inv.actions, id: \.self) { chip($0, color: accent, filled: false) }
|
|
if inv.destructive { chip("deletes", color: Palette.danger, filled: false) }
|
|
Spacer(minLength: 0)
|
|
}
|
|
if !inv.env.isEmpty {
|
|
detailRow(icon: "leaf", lines: inv.env.map(\.display))
|
|
}
|
|
if !inv.flags.isEmpty {
|
|
detailRow(icon: "slider.horizontal.3", lines: inv.flags.map(\.display))
|
|
}
|
|
if !inv.arguments.isEmpty {
|
|
detailRow(icon: "chevron.right", lines: [inv.arguments.joined(separator: " ")])
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A small chip — a filled program badge or an outlined action/marker pill.
|
|
private func chip(_ text: String, color: Color, filled: Bool) -> some View {
|
|
Text(text)
|
|
.font(.caption2.monospaced().weight(.medium))
|
|
.foregroundStyle(color)
|
|
.padding(.horizontal, 6).padding(.vertical, 2)
|
|
.background(color.opacity(filled ? 0.16 : 0), in: Capsule())
|
|
.overlay(Capsule().strokeBorder(color.opacity(filled ? 0 : 0.4), lineWidth: 0.75))
|
|
}
|
|
|
|
/// A labeled detail block: a small tertiary glyph and a monospaced list (env vars, one flag
|
|
/// per line, or the operands) — the granular pieces the user scans to verify the command.
|
|
private func detailRow(icon: String, lines: [String]) -> some View {
|
|
HStack(alignment: .top, spacing: 7) {
|
|
Image(systemName: icon).font(.caption2).foregroundStyle(.tertiary)
|
|
.frame(width: 14).padding(.top, 1)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
|
|
Text(line)
|
|
.font(.caption.monospaced()).foregroundStyle(.secondary)
|
|
.textSelection(.enabled)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
}
|
|
}
|