iOS remote: transcript fidelity + mid-session controls & model catalog

Bring NucleicRemote closer to desktop parity in two areas (the core
sync loop was already at parity — shared protocol, control scope).

Transcript fidelity (iOS): a client-side TranscriptProjection coalesces
streaming text by messageID and folds each tool call's lifecycle
(start/deltas/complete/result/fileChange) into one expandable card —
fixing the duplicate started+completed rows. Adds Markdown bubbles, the
gold Orchestra card for Task/Agent spawns, and the previously-dropped
usage/cost, rate-limit, file-change, turn-boundary and session-started
rows, plus a context-window % header badge.

Mid-session controls + model catalog (protocol/host/iOS): project the
host ModelCatalog over the wire as WireModelCatalog (in Welcome); add 5
control-scope setters (setSessionModel/Effort/Auto/AutoShip/ShipBranch)
backed by the existing AppStore.mutateSession + SessionController hooks;
enrich WireSessionSummary with model/effort/auto/autoShip/shipBranch/
contextInputTokens (all forward-compatible). The composer gains a model
picker and a catalog-driven effort menu (per-backend caps: Codex→xhigh,
Grok→auto), and the session header gains a model/effort/auto/autoship
control bar.

Tests: CBOR round-trips for the new messages, Welcome.modelCatalog, the
new summary fields, forward-compat decode of old bytes, and the setters
reaching the host. Verified in the Simulator (NUCLEIC_DEMO=1).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Nucleic
2026-06-21 04:03:53 -07:00
co-authored by Claude Opus 4.8
parent e3b6ccc297
commit e6ef59e830
9 changed files with 763 additions and 63 deletions
@@ -0,0 +1,200 @@
import Foundation
import NucleicProtocol
/// One render-ready row, folded from the raw `[AgentEvent]` stream. The phone subscribes at
/// `.full`, so it receives streaming text deltas and every step of a tool call's lifecycle;
/// this projection coalesces those into the same shapes the Mac transcript shows — one bubble
/// per message, one card per tool call — so the view layer stays dumb. (Mirrors the desktop's
/// `TranscriptProjection` at phone fidelity.)
struct TranscriptItem: Identifiable, Equatable {
let id: String
/// The seq of the first event that created this item — stable scroll anchor + ordering.
let seq: UInt64
var kind: Kind
enum Role: Equatable { case user, assistant }
enum Kind: Equatable {
case message(role: Role, text: String)
case thinking(text: String)
case tool(ToolGroup)
/// A `Task`/`Agent` spawn — rendered as the gold Orchestra card.
case orchestration(ToolGroup)
case sessionStarted(model: String, cwd: String)
case usage(Usage)
case rateLimit(RateLimit)
case turnBoundary
case approval(toolName: String)
case runFinished(outcome: RunFinished.Outcome)
case error(message: String)
case note(text: String, icon: String?, lockEvent: Bool)
case raw(type: String, body: String)
}
}
/// One tool call's coalesced lifecycle: start → input deltas → complete → result → file changes.
struct ToolGroup: Equatable {
var toolCallID: String
var name: String
var input: JSONValue
var result: JSONValue?
var isError: Bool = false
var finished: Bool = false
/// Paths the call touched (from `fileChange` events tagged with this tool call).
var fileChanges: [FilePatch] = []
struct FilePatch: Equatable { let path: String; let change: FileChange.ChangeKind }
/// Subagent orchestration (`Task`/`Agent`) gets the gold card treatment, like the Mac.
var isOrchestration: Bool { name == "Task" || name == "Agent" }
var isAskUserQuestion: Bool { name == "AskUserQuestion" }
}
enum TranscriptProjection {
/// Fold the raw event stream into render rows. `showRaw` surfaces unrecognized passthrough
/// events (debug); `showLockEvents` keeps file-lock lifecycle notes (off = quieter feed).
static func build(_ events: [AgentEvent], showRaw: Bool, showLockEvents: Bool) -> [TranscriptItem] {
var items: [TranscriptItem] = []
var messageIndex: [String: Int] = [:] // messageID → items index (text coalescing)
var thinkingIndex: [String: Int] = [:]
var toolIndex: [String: Int] = [:] // toolCallID → items index
for event in events {
switch event.kind {
case .userText(let chunk):
coalesceText(.user, chunk, seq: event.seq, into: &items, index: &messageIndex)
case .assistantText(let chunk):
coalesceText(.assistant, chunk, seq: event.seq, into: &items, index: &messageIndex)
case .thinking(let chunk):
coalesceThinking(chunk, seq: event.seq, into: &items, index: &thinkingIndex)
case .toolCallStarted(let call), .toolCallCompleted(let call):
let finished: Bool = { if case .toolCallCompleted = event.kind { return true } else { return false } }()
upsertTool(call, finished: finished, seq: event.seq, into: &items, index: &toolIndex)
case .toolCallInputDelta:
break // the final `toolCallCompleted` carries the assembled input
case .toolResult(let result):
attachResult(result, into: &items, index: toolIndex)
case .fileChange(let change):
attachFileChange(change, seq: event.seq, into: &items, index: toolIndex)
case .sessionStarted(let started):
items.append(.init(id: "start-\(event.seq)", seq: event.seq,
kind: .sessionStarted(model: started.model, cwd: started.cwd)))
case .usage(let usage):
items.append(.init(id: "usage-\(event.seq)", seq: event.seq, kind: .usage(usage)))
case .rateLimit(let limit):
items.append(.init(id: "rate-\(event.seq)", seq: event.seq, kind: .rateLimit(limit)))
case .turnCompleted:
items.append(.init(id: "turn-\(event.seq)", seq: event.seq, kind: .turnBoundary))
case .approvalRequested(let req):
items.append(.init(id: "appr-\(event.seq)", seq: event.seq, kind: .approval(toolName: req.toolName)))
case .approvalResolved:
break // dismissal is reflected in the live approval card, not the transcript
case .runFinished(let finished):
items.append(.init(id: "fin-\(event.seq)", seq: event.seq,
kind: .runFinished(outcome: finished.outcome)))
case .error(let err):
items.append(.init(id: "err-\(event.seq)", seq: event.seq, kind: .error(message: err.message)))
case .note(let note):
if note.lockEvent && !showLockEvents { break }
items.append(.init(id: "note-\(event.seq)", seq: event.seq,
kind: .note(text: note.text, icon: note.icon, lockEvent: note.lockEvent)))
case .raw(let raw):
guard showRaw else { break }
items.append(.init(id: "raw-\(event.seq)", seq: event.seq,
kind: .raw(type: event.nativeType ?? "raw", body: raw.native.compactSummary)))
}
}
return items
}
// MARK: - Coalescing helpers
private static func coalesceText(
_ role: TranscriptItem.Role, _ chunk: TextChunk, seq: UInt64,
into items: inout [TranscriptItem], index: inout [String: Int]
) {
if let i = index[chunk.messageID], case .message(let r, let existing) = items[i].kind {
// A non-partial chunk is the authoritative full text; partials accumulate.
let text = chunk.isPartial ? existing + chunk.text : chunk.text
items[i].kind = .message(role: r, text: text)
} else {
index[chunk.messageID] = items.count
items.append(.init(id: "msg-\(chunk.messageID)", seq: seq,
kind: .message(role: role, text: chunk.text)))
}
}
private static func coalesceThinking(
_ chunk: TextChunk, seq: UInt64, into items: inout [TranscriptItem], index: inout [String: Int]
) {
if let i = index[chunk.messageID], case .thinking(let existing) = items[i].kind {
items[i].kind = .thinking(text: chunk.isPartial ? existing + chunk.text : chunk.text)
} else {
index[chunk.messageID] = items.count
items.append(.init(id: "think-\(chunk.messageID)", seq: seq, kind: .thinking(text: chunk.text)))
}
}
private static func upsertTool(
_ call: ToolCall, finished: Bool, seq: UInt64,
into items: inout [TranscriptItem], index: inout [String: Int]
) {
if let i = index[call.toolCallID], var group = currentGroup(items[i]) {
group.name = call.name
if !call.input.isEmptyValue { group.input = call.input }
group.finished = group.finished || finished
items[i].kind = wrap(group)
} else {
index[call.toolCallID] = items.count
let group = ToolGroup(toolCallID: call.toolCallID, name: call.name, input: call.input, finished: finished)
items.append(.init(id: "tool-\(call.toolCallID)", seq: seq, kind: wrap(group)))
}
}
private static func attachResult(
_ result: ToolResult, into items: inout [TranscriptItem], index: [String: Int]
) {
guard let i = index[result.toolCallID], var group = currentGroup(items[i]) else { return }
group.result = result.content
group.isError = result.isError
group.finished = true
items[i].kind = wrap(group)
}
private static func attachFileChange(
_ change: FileChange, seq: UInt64, into items: inout [TranscriptItem], index: [String: Int]
) {
if let id = change.toolCallID, let i = index[id], var group = currentGroup(items[i]) {
group.fileChanges.append(.init(path: change.path, change: change.kind))
items[i].kind = wrap(group)
}
// Untagged file changes are folded into the diff stat, not the transcript.
}
/// Extract a tool group from either the plain or orchestration kind.
private static func currentGroup(_ item: TranscriptItem) -> ToolGroup? {
switch item.kind {
case .tool(let g), .orchestration(let g): return g
default: return nil
}
}
/// Wrap a group in the right kind — orchestration spawns get the gold card.
private static func wrap(_ group: ToolGroup) -> TranscriptItem.Kind {
group.isOrchestration ? .orchestration(group) : .tool(group)
}
}
extension JSONValue {
/// Whether this value carries nothing worth keeping (so a later, fuller input wins).
var isEmptyValue: Bool {
switch self {
case .null: return true
case .object(let o): return o.isEmpty
case .string(let s): return s.isEmpty
default: return false
}
}
}