Merge nucleic/calm-north-ferret-9vca into dev
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user