nvrsion: promote trunk to dev
Nucleic-Promote: 1 Co-authored-by: Nucleic <[email protected]>
This commit is contained in:
@@ -130,7 +130,7 @@ 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)!,
|
||||
return ActivityDay(day: cal.date(byAdding: .day, value: -i, to: today)!,
|
||||
count: count, tokens: count * 8_500 + (i * 137) % 4_000)
|
||||
}
|
||||
dashboard = DashboardSnapshot(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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[..<eq]), value: String(first[first.index(after: eq)...])))
|
||||
rest = rest.dropFirst()
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return (env, elevated, Array(rest))
|
||||
}
|
||||
|
||||
private static func makeInvocation(
|
||||
id: Int, program rawProgram: String, args: [String],
|
||||
env: [EnvAssignment], elevated: Bool
|
||||
) -> 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<String>) -> (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[..<eq]),
|
||||
value: String(token[token.index(after: eq)...])))
|
||||
index += 1
|
||||
} else if valueFlags.contains(token), index + 1 < tokens.count,
|
||||
!tokens[index + 1].hasPrefix("-") {
|
||||
flags.append(Flag(name: token, value: tokens[index + 1]))
|
||||
index += 2
|
||||
} else {
|
||||
flags.append(Flag(name: token, value: nil))
|
||||
index += 1
|
||||
}
|
||||
} else {
|
||||
arguments.append(token)
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
return (flags, arguments)
|
||||
}
|
||||
|
||||
// MARK: - Program specs
|
||||
|
||||
private struct ProgramSpec {
|
||||
var openActions: Bool = false
|
||||
var subcommands: Set<String> = []
|
||||
var maxActions: Int = 1
|
||||
var valueFlags: Set<String> = []
|
||||
}
|
||||
|
||||
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<String> = ["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[..<eq].allSatisfy { $0.isLetter || $0.isNumber || $0 == "_" }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ToolGroup convenience
|
||||
|
||||
extension ToolGroup {
|
||||
/// Whether this is the host-exec gate (a command run on the macOS host, outside the sandbox).
|
||||
var isHostExec: Bool { name == HostCommandSummary.hostExecToolName }
|
||||
/// The host command this call runs, when it's a host-exec call.
|
||||
var hostCommand: String? { input["command"]?.stringValue }
|
||||
/// The tool name as shown to the user — the host-exec gate reads as a clean "Host" tag rather
|
||||
/// than the raw `mcp__nucleic__host_exec` wire name.
|
||||
var displayName: String { isHostExec ? "Host" : name }
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import SwiftUI
|
||||
import NucleicProtocol
|
||||
|
||||
/// 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.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user