Files
nucleic-remote-ios/NucleicRemote/NucleicRemote/Views/Transcript/HostExecCard.swift
T

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)
}
}
}