diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index c76669e..b05b117 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -130,8 +130,8 @@ final class RemoteStore: ObservableObject { let activity = (0..<40).map { i -> ActivityDay in let count = (i * 7) % 6 // Sample tokens roughly track messages so the preview grid shades by usage. - ActivityDay(day: cal.date(byAdding: .day, value: -i, to: today)!, - count: count, tokens: count * 8_500 + (i * 137) % 4_000) + return ActivityDay(day: cal.date(byAdding: .day, value: -i, to: today)!, + count: count, tokens: count * 8_500 + (i * 137) % 4_000) } dashboard = DashboardSnapshot( counts: DashboardCounts( diff --git a/NucleicRemote/NucleicRemote/Views/ApprovalCardView.swift b/NucleicRemote/NucleicRemote/Views/ApprovalCardView.swift index bc65cae..9d73d91 100644 --- a/NucleicRemote/NucleicRemote/Views/ApprovalCardView.swift +++ b/NucleicRemote/NucleicRemote/Views/ApprovalCardView.swift @@ -33,10 +33,17 @@ struct ApprovalCardView: View { .background(approval.risk.color.opacity(0.2), in: Capsule()) .foregroundStyle(approval.risk.color) } - // Full, untruncated content — the command wraps and the box fits its - // content, only scrolling vertically once it's genuinely tall, so the user - // can read exactly what they are granting without a giant half-empty box. - if !approval.input.approvalDetail.isEmpty { + // A host_exec gate escapes the sandbox onto the macOS host, so lay out what it's + // about to run — the inferred purpose and the program/actions/flags, parsed from the + // command itself (never from the agent) — then the exact command beneath it. Every + // other tool shows its untruncated detail in a single scrollable box. + if approval.toolName == HostCommandSummary.hostExecToolName, + let command = approval.input["command"]?.stringValue, + let parsed = HostCommandSummary.summary(for: command) { + HostCommandBreakdown(summary: parsed) + Text("Exact command").font(.caption2.weight(.semibold)).foregroundStyle(.secondary) + ApprovalDetailBox(text: command) + } else if !approval.input.approvalDetail.isEmpty { ApprovalDetailBox(text: approval.input.approvalDetail) } diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/HostCommandSummary.swift b/NucleicRemote/NucleicRemote/Views/Transcript/HostCommandSummary.swift new file mode 100644 index 0000000..500038d --- /dev/null +++ b/NucleicRemote/NucleicRemote/Views/Transcript/HostCommandSummary.swift @@ -0,0 +1,590 @@ +import Foundation + +/// Parses a `host_exec` shell command into a structured, **deterministic** breakdown — the +/// program, its actions (subcommands/targets), its flags, its operands, and an *inferred +/// purpose* — so the phone lays out, in plain language, what the agent is about to run on the +/// user's machine. The mobile mirror of the desktop's `HostCommandSummary` (NucleicCore): the +/// remote app links only `NucleicProtocol`, so the parser is duplicated here at phone fidelity, +/// exactly as `TranscriptProjection` is. +/// +/// Everything is derived **only from the command string** — never from anything the agent said +/// about its own intent (which may be wrong, or a lie). The inference is computed from the +/// literal tokens, so the user can check "Likely purpose: Build the Swift package" against the +/// command shown right beside it before tapping Allow. +enum HostCommandSummary { + /// The fully-qualified host-exec tool name on the sync wire — the gate that escapes the + /// sandbox onto the macOS host. + static let hostExecToolName = "mcp__nucleic__host_exec" + + /// A `NAME=value` environment assignment prefixed before a program (`NUCLEIC_CHANNEL=dev swift …`). + struct EnvAssignment: Equatable, Identifiable { + let name: String + let value: String + var id: String { name } + var display: String { "\(name)=\(value)" } + } + + /// A parsed flag: its name (leading dashes kept) and its value when it takes one, recovered + /// from `--flag value`, `--flag=value`, or `-f value`. A boolean flag has a nil value. + struct Flag: Equatable, Identifiable { + let name: String + let value: String? + var id: String { name + "\u{1}" + (value ?? "") } + var display: String { value.map { "\(name) \($0)" } ?? name } + } + + /// One program invocation within a command pipeline. + struct Invocation: Equatable, Identifiable { + let id: Int + /// The program name, reduced to its basename (`/usr/bin/swift` → `swift`). + let program: String + /// The subcommands / targets that follow it, in order (`build`, `run`, `test`). + let actions: [String] + /// The parsed flags, in order. + let flags: [Flag] + /// Positional operands that are neither actions nor flag values (a script path, a branch). + let arguments: [String] + /// `NAME=value` assignments prefixed before the program. + let env: [EnvAssignment] + /// True when the invocation is `sudo`-elevated. + let elevated: Bool + /// True for an inherently destructive program (`rm`/`rmdir`). + let destructive: Bool + + /// "swift build", for a compact one-liner. + var headline: String { ([program] + actions).joined(separator: " ") } + } + + /// A whole `host_exec` command, parsed. + struct Summary: Equatable { + let invocations: [Invocation] + let workingDirectory: String? + /// A plain-language guess at what the command does, inferred from the parsed tokens + /// (never from the agent). + let purpose: String + + var isElevated: Bool { invocations.contains { $0.elevated } } + var isDestructive: Bool { invocations.contains { $0.destructive } } + } + + /// Parses `command` into a structured ``Summary``. Returns nil only for a blank command. + static func summary(for command: String) -> Summary? { + let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + let (flattened, _) = Lexer.stripHeredocs(command) + var invocations: [Invocation] = [] + var workingDirectory: String? + var nextID = 0 + for segment in Lexer.splitSegments(flattened) { + let tokens = Lexer.tokenize(segment) + let (env, elevated, rest) = stripPrefixes(tokens) + guard let head = rest.first else { continue } + if basename(head).lowercased() == "cd" { + if workingDirectory == nil, rest.count >= 2 { + workingDirectory = normalizeDirectory(rest[1]) + } + continue + } + invocations.append(makeInvocation( + id: nextID, program: head, args: Array(rest.dropFirst()), + env: env, elevated: elevated)) + nextID += 1 + } + + let purpose = inferPurpose( + invocations: invocations, workingDirectory: workingDirectory) + return Summary(invocations: invocations, workingDirectory: workingDirectory, purpose: purpose) + } + + // MARK: - Segment parsing + + private static func stripPrefixes(_ tokens: [String]) -> (env: [EnvAssignment], elevated: Bool, rest: [String]) { + var env: [EnvAssignment] = [] + var elevated = false + var rest = tokens[...] + while let first = rest.first { + if first == "sudo" { + elevated = true + rest = rest.dropFirst() + } else if Lexer.isEnvAssignment(first), let eq = first.firstIndex(of: "=") { + env.append(EnvAssignment( + name: String(first[.. Invocation { + let program = basename(rawProgram) + let spec = spec(for: program) + let (actions, rest) = splitActions(args, spec: spec) + let (flags, arguments) = splitFlags(rest, valueFlags: spec.valueFlags) + let destructive = program == "rm" || program == "rmdir" + return Invocation( + id: id, program: program, actions: actions, flags: flags, arguments: arguments, + env: env, elevated: elevated, destructive: destructive) + } + + private static func splitActions(_ args: [String], spec: ProgramSpec) -> (actions: [String], rest: [String]) { + var actions: [String] = [] + var index = 0 + while index < args.count, actions.count < spec.maxActions { + let token = args[index] + guard isIdentifierLike(token) else { break } + guard spec.openActions || spec.subcommands.contains(token) else { break } + actions.append(token) + index += 1 + } + return (actions, Array(args[index...])) + } + + private static func splitFlags(_ tokens: [String], valueFlags: Set) -> (flags: [Flag], arguments: [String]) { + var flags: [Flag] = [] + var arguments: [String] = [] + var index = 0 + var endOfOptions = false + while index < tokens.count { + let token = tokens[index] + if endOfOptions { arguments.append(token); index += 1; continue } + if token == "--" { endOfOptions = true; index += 1; continue } + if token.hasPrefix("-"), token.count > 1 { + if let eq = token.firstIndex(of: "=") { + flags.append(Flag(name: String(token[.. = [] + var maxActions: Int = 1 + var valueFlags: Set = [] + } + + private static func spec(for program: String) -> ProgramSpec { + switch program { + case "swift": + return ProgramSpec( + subcommands: ["build", "run", "test", "package", "sdk"], maxActions: 2, + valueFlags: ["--product", "--target", "--build-system", "-c", "--configuration", + "--filter", "--package-path", "--scratch-path", "--jobs", "-j", + "-Xswiftc", "-Xcc", "-Xlinker", "--triple"]) + case "make": + return ProgramSpec(openActions: true, maxActions: 4, valueFlags: ["-f", "-C", "-j"]) + case "npm", "pnpm", "yarn", "bun": + return ProgramSpec( + subcommands: ["install", "i", "ci", "run", "run-script", "test", "build", "start", + "exec", "publish", "add", "remove", "update", "lint", "dev"], + valueFlags: ["--prefix", "-w", "--workspace", "--filter"]) + case "npx": + return ProgramSpec(openActions: true, maxActions: 1) + case "cargo": + return ProgramSpec( + subcommands: ["build", "test", "run", "check", "clippy", "fmt", "bench", "doc", + "install", "update", "publish", "clean"], + valueFlags: ["--package", "-p", "--bin", "--example", "--features", "--target", + "--manifest-path", "--jobs", "-j"]) + case "git": + return ProgramSpec( + subcommands: ["commit", "push", "pull", "fetch", "clone", "merge", "rebase", + "checkout", "switch", "branch", "tag", "stash", "restore", "reset", + "revert", "cherry-pick", "add", "rm", "mv", "status", "log", "diff", + "show", "blame", "rev-parse", "worktree", "init", "clean"], + maxActions: 2, + valueFlags: ["-m", "--message", "-F", "--file", "-b", "-B", "-C", "-c"]) + case "xcodebuild": + return ProgramSpec( + openActions: true, maxActions: 3, + valueFlags: ["-scheme", "-project", "-workspace", "-configuration", "-sdk", + "-destination", "-derivedDataPath", "-arch", "-target"]) + case "python", "python3", "python2": + return ProgramSpec(valueFlags: ["-m", "-c", "-W", "-X"]) + case "pip", "pip3": + return ProgramSpec( + subcommands: ["install", "uninstall", "download", "list", "show", "freeze", + "wheel", "check"], + valueFlags: ["-r", "--requirement", "-c", "--constraint", "-t", "--target"]) + case "node": + return ProgramSpec(valueFlags: ["-e", "--eval", "-r", "--require"]) + case "docker", "podman": + return ProgramSpec( + subcommands: ["build", "run", "exec", "compose", "push", "pull", "up", "down", + "start", "stop", "ps", "images", "logs"], + maxActions: 2, + valueFlags: ["-t", "--tag", "-f", "--file", "-p", "--publish", "-v", "--volume"]) + case "gh": + return ProgramSpec( + subcommands: ["pr", "issue", "repo", "release", "run", "workflow", "auth", "api", + "create", "list", "view", "merge", "checkout", "status"], + maxActions: 3) + case "brew": + return ProgramSpec( + subcommands: ["install", "uninstall", "upgrade", "update", "list", "info", + "search", "tap", "bundle"], + maxActions: 2) + case "sh", "bash", "zsh", "fish": + return ProgramSpec(valueFlags: ["-c"]) + default: + return ProgramSpec(openActions: true, maxActions: 1) + } + } + + // MARK: - Inferred purpose + + private static func inferPurpose(invocations: [Invocation], workingDirectory: String?) -> String { + guard !invocations.isEmpty else { + if let workingDirectory { return "Change directory to \(workingDirectory)" } + return "Run a host command" + } + let interpreted = invocations.map { interpret($0) } + let best = interpreted.max { $0.salience < $1.salience } + return best?.purpose ?? "Run \(invocations[0].headline)" + } + + private static func interpret(_ inv: Invocation) -> (purpose: String, salience: Int) { + if inv.destructive { + let target = inv.arguments.first.map { " \($0)" } ?? " files" + return ("Delete\(target) on the host", 100) + } + let action = inv.actions.first + switch inv.program { + case "swift": + return swiftPurpose(inv, action: action) + case "make": + let targets = inv.actions + if targets.isEmpty { return ("Run the default make target", 60) } + let list = backticked(targets.joined(separator: ", ")) + return ("Run the make target\(targets.count == 1 ? "" : "s") \(list)", 64) + case "npm", "pnpm", "yarn", "bun": + return packageManagerPurpose(inv, action: action) + case "npx": + let tool = inv.arguments.first ?? inv.actions.first + return (tool.map { "Run \(backticked($0)) via npx" } ?? "Run a tool via npx", 55) + case "cargo": + return cargoPurpose(action: action) + case "git": + return gitPurpose(inv) + case "xcodebuild": + let testing = inv.actions.contains("test") || inv.actions.contains("test-without-building") + let scheme = flagValue(inv, ["-scheme"]).map { " (scheme \($0))" } ?? "" + return (testing ? "Run Xcode tests\(scheme)" : "Build the Xcode project\(scheme)", testing ? 70 : 66) + case "python", "python3", "python2": + if let module = flagValue(inv, ["-m"]) { return ("Run the Python module \(backticked(module))", 62) } + if let script = inv.arguments.first { return ("Run the Python script \(backticked(script))", 62) } + if flagValue(inv, ["-c"]) != nil { return ("Run inline Python code", 58) } + return ("Run Python", 50) + case "pip", "pip3": + if action == "install" { + let pkgs = inv.arguments.isEmpty ? "" : " " + backticked(inv.arguments.prefix(3).joined(separator: ", ")) + return ("Install Python packages\(pkgs)", 60) + } + return ("Run pip\(action.map { " \($0)" } ?? "")", 45) + case "node": + if let script = inv.arguments.first { return ("Run the Node script \(backticked(script))", 60) } + if flagValue(inv, ["-e", "--eval"]) != nil { return ("Run inline Node code", 56) } + return ("Run Node", 50) + case "docker", "podman": + let sub = inv.actions.joined(separator: " ") + return (sub.isEmpty ? "Run a container command" : "Run `\(inv.program) \(sub)`", 60) + case "gh": + let sub = inv.actions.joined(separator: " ") + return (sub.isEmpty ? "Run a GitHub CLI command" : "Run `gh \(sub)`", 55) + case "brew": + switch action { + case "install": return ("Install Homebrew packages", 60) + case "uninstall": return ("Uninstall Homebrew packages", 60) + case "upgrade", "update": return ("Update Homebrew packages", 55) + default: return ("Run a Homebrew command", 45) + } + case "sh", "bash", "zsh", "fish": + if let script = inv.arguments.first { return ("Run the shell script \(backticked(script))", 58) } + if flagValue(inv, ["-c"]) != nil { return ("Run an inline shell command", 54) } + return ("Start a \(inv.program) shell", 40) + default: + return defaultPurpose(inv) + } + } + + private static func swiftPurpose(_ inv: Invocation, action: String?) -> (String, Int) { + let channel = flagValue(inv, [], env: "NUCLEIC_CHANNEL") + let channelSuffix = channel.map { " (\($0) channel)" } ?? "" + let release = (flagValue(inv, ["-c", "--configuration"]) == "release") + let releaseSuffix = release ? " in release" : "" + switch action { + case "build": + let what = flagValue(inv, ["--product"]).map { "the Swift product \(backticked($0))" } + ?? "the Swift package" + return ("Build \(what)\(releaseSuffix)\(channelSuffix)", 66) + case "test": + let filter = flagValue(inv, ["--filter"]).map { " (filter: \($0))" } ?? "" + return ("Run the Swift test suite\(filter)", 70) + case "run": + let what = (inv.arguments.first ?? flagValue(inv, ["--product"])).map(backticked) ?? "the Swift package" + return ("Build and run \(what)\(channelSuffix)", 68) + case "package": + let sub = inv.actions.count > 1 ? inv.actions[1] : (inv.arguments.first ?? "") + return (sub.isEmpty ? "Run a SwiftPM package command" : "Run SwiftPM `package \(sub)`", 55) + default: + return defaultPurpose(inv) + } + } + + private static func packageManagerPurpose(_ inv: Invocation, action: String?) -> (String, Int) { + let pm = inv.program + switch action { + case "install", "i", "ci", "add": + return ("Install \(pm) dependencies", 60) + case "run", "run-script", "exec", "dev": + let script = inv.arguments.first ?? (inv.actions.count > 1 ? inv.actions[1] : nil) + return (script.map { "Run the \(backticked($0)) \(pm) script" } ?? "Run an \(pm) script", 60) + case "test": + return ("Run \(pm) tests", 68) + case "build": + return ("Run the \(pm) build", 66) + case "start": + return ("Start the \(pm) app", 60) + case "publish": + return ("Publish the \(pm) package", 80) + case "lint": + return ("Lint with \(pm)", 50) + default: + return defaultPurpose(inv) + } + } + + private static func cargoPurpose(action: String?) -> (String, Int) { + switch action { + case "build": return ("Build the Rust crate", 66) + case "test": return ("Run the Rust tests", 70) + case "run": return ("Build and run the Rust crate", 68) + case "check": return ("Type-check the Rust crate", 60) + case "clippy": return ("Lint the Rust crate", 55) + case "fmt": return ("Format the Rust code", 50) + case "publish": return ("Publish the Rust crate", 80) + default: return ("Run cargo\(action.map { " \($0)" } ?? "")", 50) + } + } + + /// Inline git phrasing — a phone-sized echo of the desktop's `GitCommandSummary` (which the + /// remote can't link), covering the common host-run git ops. + private static func gitPurpose(_ inv: Invocation) -> (String, Int) { + let action = inv.actions.first ?? "" + let ops = inv.arguments + func remote(_ verb: String, _ preposition: String) -> String { + guard let first = ops.first else { return "\(verb) changes" } + return ops.count >= 2 ? "\(verb) \(preposition) \(first)/\(ops[1])" : "\(verb) \(preposition) \(first)" + } + switch action { + case "commit": return ("Commit changes", 78) + case "push": return (remote("Push", "to"), 80) + case "pull": return (remote("Pull", "from"), 75) + case "fetch": return (remote("Fetch", "from"), 70) + case "merge": return (ops.first.map { "Merge \($0)" } ?? "Merge branches", 76) + case "rebase": return (ops.first.map { "Rebase onto \($0)" } ?? "Rebase commits", 76) + case "checkout": return (ops.first.map { "Check out \($0)" } ?? "Check out a branch", 60) + case "switch": return (ops.first.map { "Switch to \($0)" } ?? "Switch branches", 60) + case "reset": return ("Reset the working tree", 72) + case "add": return ("Stage changes", 40) + case "status": return ("Check git status", 30) + case "log": return ("Show recent commits", 25) + case "diff": return ("Show changes", 25) + case "rev-parse": return ("Resolve a git revision", 20) + case "worktree": return ("Manage worktrees", 55) + case "clone": return ("Clone a repository", 70) + default: return (action.isEmpty ? "Run a git command" : "Run `git \(action)`", 55) + } + } + + private static func defaultPurpose(_ inv: Invocation) -> (String, Int) { + let readOnly: Set = ["ls", "cat", "pwd", "echo", "which", "head", "tail", "grep", + "find", "file", "stat", "env", "printenv", "whoami", "date"] + if readOnly.contains(inv.program) { + return ("Inspect the host (\(backticked(inv.program)))", 15) + } + return ("Run \(backticked(inv.headline))", 30) + } + + // MARK: - Helpers + + private static func flagValue(_ inv: Invocation, _ names: [String], env: String? = nil) -> String? { + if let env, let assignment = inv.env.first(where: { $0.name == env }) { return assignment.value } + for name in names { + if let flag = inv.flags.first(where: { $0.name == name }), let value = flag.value { + return value + } + } + return nil + } + + private static func backticked(_ text: String) -> String { "`\(text)`" } + + private static func basename(_ token: String) -> String { + (token as NSString).lastPathComponent + } + + private static func isIdentifierLike(_ token: String) -> Bool { + guard let first = token.first, first.isLetter else { return false } + return token.allSatisfy { $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" } + } + + private static func normalizeDirectory(_ raw: String) -> String { + if raw.contains("git rev-parse --show-toplevel") { return "the repository root" } + return raw + } +} + +// MARK: - Shell lexing + +/// The just-enough shell-awareness the parser needs — a phone-local copy of the desktop's +/// `ShellLexer` (which lives in NucleicCore, unavailable to the remote): split a command into +/// segments, tokenize one segment honoring quotes, and strip heredoc bodies so a here-doc payload +/// isn't parsed as commands. +private enum Lexer { + static func stripHeredocs(_ command: String) -> (flattened: String, bodies: [String]) { + let lines = command.components(separatedBy: "\n") + var kept: [String] = [] + var bodies: [String] = [] + var index = 0 + while index < lines.count { + let line = lines[index] + guard let here = heredocStart(in: line) else { + kept.append(line) + index += 1 + continue + } + kept.append(here.lineWithoutOperator) + var body: [String] = [] + index += 1 + while index < lines.count { + let candidate = here.dashIndented + ? lines[index].trimmingCharacters(in: CharacterSet(charactersIn: " \t")) + : lines[index] + if candidate == here.delimiter { index += 1; break } + body.append(lines[index]) + index += 1 + } + bodies.append(body.joined(separator: "\n")) + } + return (kept.joined(separator: "\n"), bodies) + } + + private struct Heredoc { + let delimiter: String + let dashIndented: Bool + let lineWithoutOperator: String + } + + private static func heredocStart(in line: String) -> Heredoc? { + guard let opRange = line.range(of: #"<<-?\s*(['"]?)[A-Za-z_][A-Za-z0-9_]*\1"#, + options: .regularExpression) else { return nil } + let op = String(line[opRange]) + let dashIndented = op.hasPrefix("<<-") + let delimiter = op + .replacingOccurrences(of: "<<-", with: "") + .replacingOccurrences(of: "<<", with: "") + .trimmingCharacters(in: CharacterSet(charactersIn: " \t'\"")) + let without = line.replacingCharacters(in: opRange, with: " ") + return Heredoc(delimiter: delimiter, dashIndented: dashIndented, lineWithoutOperator: without) + } + + static func splitSegments(_ command: String) -> [String] { + var segments: [String] = [] + var current = "" + var quote: Character? = nil + let chars = Array(command) + var i = 0 + func flush() { + let trimmed = current.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { segments.append(trimmed) } + current = "" + } + while i < chars.count { + let c = chars[i] + if let q = quote { + current.append(c) + if c == q { quote = nil } + i += 1 + continue + } + switch c { + case "'", "\"": + quote = c; current.append(c); i += 1 + case "\n", ";": + flush(); i += 1 + case "&" where i + 1 < chars.count && chars[i + 1] == "&": + flush(); i += 2 + case "|" where i + 1 < chars.count && chars[i + 1] == "|": + flush(); i += 2 + case "|": + flush(); i += 1 + default: + current.append(c); i += 1 + } + } + flush() + return segments + } + + static func tokenize(_ segment: String) -> [String] { + var tokens: [String] = [] + var current = "" + var quote: Character? = nil + var sawChar = false + for c in segment { + if let q = quote { + if c == q { quote = nil } else { current.append(c) } + continue + } + switch c { + case "'", "\"": + quote = c; sawChar = true + case " ", "\t": + if sawChar { tokens.append(current); current = ""; sawChar = false } + default: + current.append(c); sawChar = true + } + } + if sawChar { tokens.append(current) } + return tokens + } + + static func isEnvAssignment(_ token: String) -> Bool { + guard let eq = token.firstIndex(of: "="), eq != token.startIndex else { return false } + return token[.. 1 { return true } + return parsed.invocations.contains { + !$0.flags.isEmpty || !$0.arguments.isEmpty || !$0.env.isEmpty + } + } + private var canExpand: Bool { hasOutput || hasBreakdown } + + 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 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(.caption.weight(.semibold)) + if let parsed { + Text(MessageBubble.markdown(parsed.purpose)) + .font(.caption).lineLimit(1).truncationMode(.tail) + } + } + HStack(alignment: .firstTextBaseline, spacing: 5) { + Text("$").font(.caption.monospaced()).foregroundStyle(Palette.accent.opacity(0.7)) + Text(command) + .font(.caption.monospaced()).foregroundStyle(.secondary) + .lineLimit(expanded ? nil : 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 + + 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 \(dir)"]) + } + ForEach(Array(summary.invocations.enumerated()), id: \.element.id) { index, inv in + if index > 0 { Divider().overlay(Color.primary.opacity(0.08)) } + invocationView(inv) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + /// 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: 9) { + Image(systemName: "sparkles").font(.callout).foregroundStyle(accent).frame(width: 16) + VStack(alignment: .leading, spacing: 2) { + Text("Likely purpose · inferred from the command") + .font(.caption2.weight(.semibold)).foregroundStyle(.secondary) + Text(MessageBubble.markdown(summary.purpose)) + .font(.callout.weight(.semibold)) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + } + Spacer(minLength: 0) + } + } + + /// 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) + } + } +} diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift b/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift index 40ac069..b96c8e7 100644 --- a/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift +++ b/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift @@ -32,7 +32,7 @@ struct ToolCallCard: View { HStack(spacing: 8) { Image(systemName: ToolGlyph.icon(group.name)) .font(.caption).foregroundStyle(group.isError ? Palette.danger : .secondary) - Text(group.name).font(.caption.weight(.semibold)) + Text(group.displayName).font(.caption.weight(.semibold)) Text(group.input.compactSummary) .font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(1) Spacer(minLength: 4) @@ -105,7 +105,7 @@ struct ToolBlockCard: View { 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.displayName).font(.caption.weight(.semibold)) Text(group.input.compactSummary) .font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(1) Spacer(minLength: 4) @@ -447,6 +447,7 @@ enum ToolGlyph { case "Task", "Agent": return "person.2" case "TodoWrite": return "checklist" case "AskUserQuestion": return "questionmark.bubble" + case HostCommandSummary.hostExecToolName: return "desktopcomputer" default: return "wrench.and.screwdriver" } } diff --git a/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift b/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift index 511e9a4..d2474f1 100644 --- a/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift +++ b/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift @@ -16,9 +16,12 @@ struct TranscriptRow: View { ThinkingRow(text: text) case .tool(let group): // A lone subagent spawn reads as its own gold subagent card (type, task, status, and - // its nested activity on expand); every other tool is a plain collapsible card. + // its nested activity on expand); a host_exec call reads as a structured host card + // (purpose + command + breakdown); every other tool is a plain collapsible card. if group.isOrchestration { SubagentCard(group: group) + } else if group.isHostExec { + HostExecToolCard(group: group) } else { ToolCallCard(group: group) }