Merge nucleic/calm-north-ferret-9vca into dev

This commit is contained in:
2026-07-21 18:42:16 -07:00
parent cd6154288e
commit 02d523161a
3 changed files with 203 additions and 6 deletions
@@ -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
}
}
@@ -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)) + "…"
}
}
@@ -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 {