202 lines
10 KiB
Swift
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)) + "…"
|
|
}
|
|
}
|