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:
co-authored by
Claude Opus 4.8
parent
e3b6ccc297
commit
e6ef59e830
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user