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

202 lines
10 KiB
Swift

import Foundation
import NucleicProtocol
/// Presentation for Nucleic's own sandbox-escape tools — the macOS/Linux VM pair, their
/// computer-use siblings, and `linux_container` — plus the shell tool, whose wire name is still
/// Claude Code's `Bash` but which runs nash (the Nucleic agent shell) — so a remote transcript
/// reads the same way the Mac's does: "macOS VM · $ swift build" rather than the raw
/// `mcp__nucleic__mac_vm_exec` wire name followed by a JSON gist.
///
/// The mobile echo of `NucleicCore/SandboxToolDisplay.swift`, trimmed to what this app shows (a
/// label, a glyph, a one-line body — iOS has no per-tool working row or tool-block verb). It
/// carries the tool names as literals because the remote links only `NucleicProtocol`, not
/// `NucleicCore` where they're declared; keep the two in step when a tool is added.
///
/// `host_exec` is deliberately absent — it already has `ToolGroup.displayName` and a structured
/// card of its own.
enum SandboxToolDisplay {
/// The MCP prefix every Nucleic tool is advertised under; transcripts may record either the
/// qualified or the bare name, so every lookup matches on the stripped form.
private static let mcpPrefix = "mcp__nucleic__"
private static func bareName(_ toolName: String) -> String {
toolName.hasPrefix(mcpPrefix) ? String(toolName.dropFirst(mcpPrefix.count)) : toolName
}
/// The human label that replaces the raw tool name in a card headline. Nil for anything this
/// file doesn't speak for, so the caller keeps the tool name unchanged.
static func label(for toolName: String) -> String? {
switch bareName(toolName) {
// The wire name is Claude Code's, but the shell behind it is nash.
case "Bash": "Nash"
case "BashOutput": "Nash output"
case "mac_vm_exec", "mac_vm_control": "macOS VM"
case "mac_vm_computer", "mac_vm_computer_batch": "macOS VM screen"
case "mac_vm_clear_notifications": "macOS VM notifications"
case "mac_vm_request_operator": "macOS VM handoff"
case "linux_vm_exec", "linux_vm_control": "Linux VM"
case "linux_vm_computer", "linux_vm_computer_batch": "Linux VM screen"
case "linux_container": "Linux container"
default: nil
}
}
/// The tool name for an *approval* surface, where identity is what the user is deciding on:
/// the pretty label when we have one, and otherwise the tool name with the `mcp__nucleic__`
/// plumbing prefix dropped. Never nil and never invented — a tool this file doesn't know
/// still reads as its own identifier rather than as something friendlier than it is.
static func gateLabel(for toolName: String) -> String {
label(for: toolName) ?? bareName(toolName)
}
/// SF Symbol for the tool — a screen for computer-use, a box for a container, a power switch
/// for a lifecycle op — so the glyph carries the same distinction the label does.
static func icon(for toolName: String) -> String? {
switch bareName(toolName) {
case "mac_vm_exec": "macwindow"
case "linux_vm_exec": "server.rack"
case "linux_container": "shippingbox"
case "mac_vm_computer", "mac_vm_computer_batch",
"linux_vm_computer", "linux_vm_computer_batch": "cursorarrow.motionlines"
case "mac_vm_clear_notifications": "bell.slash"
case "mac_vm_request_operator": "hand.raised"
case "mac_vm_control", "linux_vm_control": "power"
default: nil
}
}
/// The one-line rendering of a call's arguments, parsed per tool rather than dumped as JSON.
/// Nil when the tool isn't one of ours or the call carries nothing worth showing, in which
/// case the caller falls back to the generic input gist.
static func detail(for toolName: String, input: JSONValue) -> String? {
switch bareName(toolName) {
case "mac_vm_exec", "linux_vm_exec":
return string(input, "command").map { "$ \(singleLine($0))" }
case "mac_vm_computer", "linux_vm_computer":
return computerAction(input)
case "mac_vm_computer_batch", "linux_vm_computer_batch":
return batchSummary(input)
case "linux_container":
return containerOp(input)
case "mac_vm_control", "linux_vm_control":
return string(input, "op").map(controlOpPhrase)
// The instructions are what the user is being asked to do — the point of the hand-off.
case "mac_vm_request_operator":
return string(input, "instructions").map(singleLine)
// Takes no arguments: nothing to print beside the label.
case "mac_vm_clear_notifications":
return nil
default:
return nil
}
}
/// One computer-use step as prose — "left click (120, 400)", "type “hello”", "launch Xcode".
private static func computerAction(_ step: JSONValue) -> String? {
guard let action = string(step, "action") else { return nil }
let point: String? = {
guard let x = step["x"]?.intValue, let y = step["y"]?.intValue else { return nil }
return "(\(x), \(y))"
}()
let verb = actionVerb(action)
switch action {
case "screenshot", "cursor_position", "ax_dump":
return verb
case "left_click", "right_click", "double_click", "mouse_move", "left_click_drag",
"ax_element_at":
return [verb, point].compactMap { $0 }.joined(separator: " ")
case "type":
return string(step, "text").map { "\(verb) “\(clip(singleLine($0)))”" } ?? verb
case "key", "launch_app":
return string(step, "text").map { "\(verb) \(clip($0))" } ?? verb
// The starting page is the informative half of an `open_browser` with a `url` — "open chrome"
// says far less than the address it lands on.
case "open_browser":
let target = string(step, "url") ?? string(step, "text")
return target.map { "\(verb) \(clip($0))" } ?? verb
case "scroll":
let amount = step["scroll_amount"]?.intValue.map { String($0) }
return [verb, string(step, "scroll_direction"), amount].compactMap { $0 }
.joined(separator: " ")
case "wait":
return step["duration_ms"]?.intValue.map { "\(verb) \($0)ms" } ?? verb
case "ax_press", "ax_focus":
return string(step, "ref").map { "\(verb) \(clip($0))" } ?? verb
case "ax_set_value":
let value = string(step, "value").map { "“\(clip(singleLine($0)))”" }
return [verb, string(step, "ref"), value].compactMap { $0 }.joined(separator: " ")
default:
return [verb, point].compactMap { $0 }.joined(separator: " ")
}
}
/// "5 steps — left click, type, key…": the count plus the leading verbs, so the row says what
/// the sequence does without unrolling every step's coordinates.
private static func batchSummary(_ input: JSONValue) -> String? {
guard let steps = input["steps"]?.arrayValue, !steps.isEmpty else { return nil }
let count = "\(steps.count) step\(steps.count == 1 ? "" : "s")"
let verbs = steps.prefix(3).compactMap { $0["action"]?.stringValue }.map(actionVerb)
guard !verbs.isEmpty else { return count }
let tail = steps.count > verbs.count ? "…" : ""
return "\(count) — \(verbs.joined(separator: ", "))\(tail)"
}
/// A `linux_container` call as "op — argument", showing whichever field that op uses.
private static func containerOp(_ input: JSONValue) -> String? {
guard let op = string(input, "op") else { return nil }
let subject: String? = switch op {
case "exec": string(input, "command").map { "$ \(singleLine($0))" }
case "create": string(input, "image") ?? string(input, "name")
default: string(input, "name")
}
guard let subject else { return op }
return "\(op) — \(subject)"
}
/// A VM lifecycle op as a phrase, so "status" doesn't read as a state label.
private static func controlOpPhrase(_ op: String) -> String {
switch op {
case "status": "check status"
case "stop": "power off"
case "suspend": "suspend"
case "resume": "resume"
case "restart": "restart"
case "kill": "delete and reset"
default: op
}
}
/// `left_click` → "left click"; the `ax_` prefix expands, since the abbreviation means
/// nothing to a reader.
private static func actionVerb(_ action: String) -> String {
switch action {
case "ax_dump": return "accessibility dump"
case "ax_element_at": return "accessibility element at"
case "ax_press": return "press"
case "ax_set_value": return "set"
case "ax_focus": return "focus"
default: return action.replacingOccurrences(of: "_", with: " ")
}
}
/// A non-empty, whitespace-trimmed string argument, or nil.
private static func string(_ value: JSONValue, _ key: String) -> String? {
guard let raw = value[key]?.stringValue else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
/// Collapses a multi-line argument onto one line — a heredoc would blow out a single-line row.
private static func singleLine(_ text: String) -> String {
text.split(whereSeparator: \.isNewline)
.map { $0.trimmingCharacters(in: .whitespaces) }
.joined(separator: " ⏎ ")
}
/// Keeps an inline argument short enough that the label beside it stays readable.
private static func clip(_ text: String, limit: Int = 40) -> String {
text.count <= limit ? text : String(text.prefix(limit)) + "…"
}
}