Merge nucleic/lucid-drift-otter-zrlk into dev

This commit is contained in:
2026-07-29 04:32:16 -07:00
parent 04590918c0
commit e114c7ed6d
2 changed files with 102 additions and 2 deletions
@@ -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 <name>…" 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 {
@@ -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 <name>…" 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? {