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 }
|
var isHostExec: Bool { name == HostCommandSummary.hostExecToolName }
|
||||||
/// The host command this call runs, when it's a host-exec call.
|
/// The host command this call runs, when it's a host-exec call.
|
||||||
var hostCommand: String? { input["command"]?.stringValue }
|
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
|
/// The tool name as shown to the user — the host-exec gate reads as a clean "Host" tag, and
|
||||||
/// than the raw `mcp__nucleic__host_exec` wire name.
|
/// the VM/container tools as where they run ("macOS VM"), rather than their raw
|
||||||
var displayName: String { isHostExec ? "Host" : name }
|
/// `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))
|
Image(systemName: ToolGlyph.icon(group.name))
|
||||||
.font(.caption).foregroundStyle(group.isError ? Palette.danger : .secondary)
|
.font(.caption).foregroundStyle(group.isError ? Palette.danger : .secondary)
|
||||||
Text(group.displayName).font(.caption.weight(.semibold))
|
Text(group.displayName).font(.caption.weight(.semibold))
|
||||||
Text(group.input.compactSummary)
|
Text(group.displayDetail)
|
||||||
.font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(1)
|
.font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(1)
|
||||||
Spacer(minLength: 4)
|
Spacer(minLength: 4)
|
||||||
if !group.finished { ProgressView().controlSize(.mini) }
|
if !group.finished { ProgressView().controlSize(.mini) }
|
||||||
@@ -133,7 +133,7 @@ struct ToolBlockCard: View {
|
|||||||
.font(.caption).foregroundStyle(group.isError ? Palette.danger : .secondary)
|
.font(.caption).foregroundStyle(group.isError ? Palette.danger : .secondary)
|
||||||
.frame(width: 16)
|
.frame(width: 16)
|
||||||
Text(group.displayName).font(.caption.weight(.semibold))
|
Text(group.displayName).font(.caption.weight(.semibold))
|
||||||
Text(group.input.compactSummary)
|
Text(group.displayDetail)
|
||||||
.font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(1)
|
.font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(1)
|
||||||
Spacer(minLength: 4)
|
Spacer(minLength: 4)
|
||||||
if index == 0 { trailingControls }
|
if index == 0 { trailingControls }
|
||||||
@@ -584,7 +584,9 @@ enum ToolGlyph {
|
|||||||
case "AskUserQuestion": return "questionmark.bubble"
|
case "AskUserQuestion": return "questionmark.bubble"
|
||||||
case ExitPlanMode.toolName: return "list.bullet.clipboard"
|
case ExitPlanMode.toolName: return "list.bullet.clipboard"
|
||||||
case HostCommandSummary.hostExecToolName: return "desktopcomputer"
|
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 {
|
static func fileChange(_ kind: FileChange.ChangeKind) -> String {
|
||||||
|
|||||||
Reference in New Issue
Block a user