From e114c7ed6db2779d2e67925fe6676de952599be8 Mon Sep 17 00:00:00 2001 From: Nucleic Date: Wed, 29 Jul 2026 04:32:16 -0700 Subject: [PATCH] Merge nucleic/lucid-drift-otter-zrlk into dev --- .../Views/SessionDetailView.swift | 83 ++++++++++++++++++- .../Views/Transcript/SandboxToolDisplay.swift | 21 ++++- 2 files changed, 102 insertions(+), 2 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index ceb6dee..df5784d 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -473,7 +473,11 @@ struct SessionDetailView: View { if running { HStack(spacing: 8) { ProgressView().controlSize(.small) - Text("Agent is working…").font(.footnote).foregroundStyle(.primary) + Text(progressText).font(.footnote).foregroundStyle(.primary) + // Cross-fade the label as the agent moves between steps, so a + // fast run of tool calls doesn't snap the row's text. + .contentTransition(.opacity) + .animation(.easeInOut(duration: 0.15), value: progressText) Spacer() } .frame(maxWidth: .infinity, alignment: .leading) @@ -629,6 +633,83 @@ struct SessionDetailView: View { if !message.text.isEmpty { return message.text } return message.attachments.map(\.filename).joined(separator: ", ") } + + /// A live, human label for what the agent is doing right now — "Reading…", "Running on a macOS + /// VM…", "Thinking…" — derived from the newest transcript event, replacing a fixed "Agent is + /// working…" that said nothing about a turn that may run for minutes. + /// + /// Derived on-device from the host's own event stream rather than read off a field in the + /// summary: the phone already holds those events (they're what the transcript above renders), + /// so the label tracks the turn at streaming speed instead of at summary-refresh speed, needs + /// no wire field, and works against a host of any version. Mirrors the Mac's + /// `SessionDetailView.progressText`; keep the two in step. + private var progressText: String { + // Blocked on a human, not the agent. The composer's working row is hidden behind the + // approval card in that state, but a resolved-but-not-yet-cleared gate can flash through + // here — say what's actually being waited on rather than claiming the agent is working. + if let approval = store.openApprovals.first { + return approval.toolName == AskUserQuestion.toolName + ? "Waiting for answers…" + : "Waiting for approval…" + } + for event in store.openEvents.reversed() { + // Extended-thinking progress pings (`system/thinking_tokens`) arrive as raw events + // during the model's reasoning pause — often the long gap after a tool result, before + // any visible output. Without this the walk skips every ping and lands back on the last + // tool call, freezing the row on e.g. "Running…" for the whole pause, which reads as a + // stall even though the model is working. + if Self.isThinkingPing(event) { return "Thinking…" } + switch event.kind { + case .toolCallStarted(let call), .toolCallCompleted(let call): + return Self.gerund(for: call.name) + case .thinking: + return "Thinking…" + case .assistantText(let chunk) where chunk.isPartial: + return "Writing…" + case .toolResult: + // The tool has finished (its result landed); the model is now working on what comes + // next. Stop here rather than walking back to the call behind it and reporting a + // completed tool as still "Running…". + return "Working…" + case .userText: + // Nothing from the agent since the user's message. + return "Working…" + default: + continue + } + } + return "Working…" + } + + /// Whether this is a Claude `system/thinking_tokens` progress ping — the mobile echo of + /// `TranscriptProjection.parseThinkingTokens`, reduced to the "is it one" half (the phone's + /// working row shows no token meter, so the counts themselves go unused). + private static func isThinkingPing(_ event: AgentEvent) -> Bool { + guard case .raw(let raw) = event.kind else { return false } + return event.nativeType == "system/thinking_tokens" + || raw.native["subtype"]?.stringValue == "thinking_tokens" + } + + /// Turns a tool name into a present-progressive status line — "Read" → "Reading…". Unknown + /// tools fall back to "Running …" so we never guess a malformed gerund. Mirrors the Mac's + /// `SessionDetailView.gerund(for:)`. + private static func gerund(for name: String) -> String { + switch name { + case "Read": return "Reading…" + case "Write": return "Writing…" + case "Edit", "MultiEdit", "NotebookEdit": return "Editing…" + case "Bash", "BashOutput": return "Running…" + case "Grep", "Glob": return "Searching…" + case "WebFetch": return "Fetching…" + case "WebSearch": return "Searching the web…" + case "Task": return "Delegating…" + case HostCommandSummary.hostExecToolName: return "Running a command on host…" + // The VM/container tools name where the work is happening ("Running on a macOS VM…") + // rather than echoing their `mcp__nucleic__…` wire name. + default: return SandboxToolDisplay.gerund(for: name) + ?? "Running \(name)…" + } + } } struct TranscriptList: View { diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/SandboxToolDisplay.swift b/NucleicRemote/NucleicRemote/Views/Transcript/SandboxToolDisplay.swift index 89228e3..90520ae 100644 --- a/NucleicRemote/NucleicRemote/Views/Transcript/SandboxToolDisplay.swift +++ b/NucleicRemote/NucleicRemote/Views/Transcript/SandboxToolDisplay.swift @@ -8,7 +8,7 @@ import NucleicProtocol /// `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 +/// label, a glyph, a one-line body, the composer's working line — iOS has no 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. /// @@ -50,6 +50,25 @@ enum SandboxToolDisplay { label(for: toolName) ?? bareName(toolName) } + /// The present-progressive working-row line — "Running on a macOS VM…". Nil for tools this + /// file doesn't speak for, so the caller keeps its own "Running …" fallback. Mirrors + /// `NucleicCore/SandboxToolDisplay.gerund(for:)` so the phone's composer names a call exactly + /// the way the Mac's status line does. + static func gerund(for toolName: String) -> String? { + switch bareName(toolName) { + case "mac_vm_exec": "Running on a macOS VM…" + case "linux_vm_exec": "Running on a Linux VM…" + case "linux_container": "Running in a Linux container…" + case "mac_vm_computer", "mac_vm_computer_batch": "Operating a macOS VM…" + case "linux_vm_computer", "linux_vm_computer_batch": "Operating a Linux VM…" + case "mac_vm_clear_notifications": "Clearing VM notifications…" + case "mac_vm_request_operator": "Waiting for you to drive the VM…" + case "mac_vm_control": "Managing the macOS VM…" + case "linux_vm_control": "Managing the Linux VM…" + 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? {