diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/HostCommandSummary.swift b/NucleicRemote/NucleicRemote/Views/Transcript/HostCommandSummary.swift index 9d3837c..4c20eb6 100644 --- a/NucleicRemote/NucleicRemote/Views/Transcript/HostCommandSummary.swift +++ b/NucleicRemote/NucleicRemote/Views/Transcript/HostCommandSummary.swift @@ -810,7 +810,18 @@ extension ToolGroup { 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 } + /// The tool name as shown to the user — the host-exec gate reads as a clean "Host" tag, and + /// the VM/container tools as where they run ("macOS VM"), rather than their raw + /// `mcp__nucleic__…` wire names. + var displayName: String { + if isHostExec { return "Host" } + return SandboxToolDisplay.label(for: name) ?? name + } + + /// The one-line body beside the name: the sandbox tools parse their own arguments (a shell + /// line as `$ command`, a computer-use step as "left click (120, 400)"); everything else keeps + /// the generic JSON gist. + var displayDetail: String { + SandboxToolDisplay.detail(for: name, input: input) ?? input.compactSummary + } } diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/SandboxToolDisplay.swift b/NucleicRemote/NucleicRemote/Views/Transcript/SandboxToolDisplay.swift new file mode 100644 index 0000000..d49fd3c --- /dev/null +++ b/NucleicRemote/NucleicRemote/Views/Transcript/SandboxToolDisplay.swift @@ -0,0 +1,184 @@ +import Foundation +import NucleicProtocol + +/// Presentation for Nucleic's own sandbox-escape tools — the macOS/Linux VM pair, their +/// computer-use siblings, and `linux_container` — 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) { + 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 + } + } + + /// 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", "open_browser": + return string(step, "text").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)) + "…" + } +} diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift b/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift index 07753d5..6244354 100644 --- a/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift +++ b/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift @@ -44,7 +44,7 @@ struct ToolCallCard: View { Image(systemName: ToolGlyph.icon(group.name)) .font(.caption).foregroundStyle(group.isError ? Palette.danger : .secondary) Text(group.displayName).font(.caption.weight(.semibold)) - Text(group.input.compactSummary) + Text(group.displayDetail) .font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(1) Spacer(minLength: 4) if !group.finished { ProgressView().controlSize(.mini) } @@ -133,7 +133,7 @@ struct ToolBlockCard: View { .font(.caption).foregroundStyle(group.isError ? Palette.danger : .secondary) .frame(width: 16) Text(group.displayName).font(.caption.weight(.semibold)) - Text(group.input.compactSummary) + Text(group.displayDetail) .font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(1) Spacer(minLength: 4) if index == 0 { trailingControls } @@ -584,7 +584,9 @@ enum ToolGlyph { case "AskUserQuestion": return "questionmark.bubble" case ExitPlanMode.toolName: return "list.bullet.clipboard" case HostCommandSummary.hostExecToolName: return "desktopcomputer" - default: return "wrench.and.screwdriver" + // The VM/container tools each get their own glyph — a screen for computer-use, a box for + // a container — so they don't all collapse into the generic wrench. + default: return SandboxToolDisplay.icon(for: name) ?? "wrench.and.screwdriver" } } static func fileChange(_ kind: FileChange.ChangeKind) -> String {