From ff72a577f395d86d936d40df04e4f220e7ea943a Mon Sep 17 00:00:00 2001 From: Nucleic Date: Thu, 30 Jul 2026 19:36:16 -0700 Subject: [PATCH] Merge nucleic/fuzzy-spruce-dingo-vpaa into dev --- .../Transcript/TranscriptProjectionABI.swift | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjectionABI.swift diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjectionABI.swift b/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjectionABI.swift new file mode 100644 index 0000000..6526e6d --- /dev/null +++ b/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjectionABI.swift @@ -0,0 +1,187 @@ +import Foundation +import NucleicProtocol + +/// Stateful, platform-neutral transcript projection for non-Swift renderers. +/// +/// The Windows DLL feeds this the same `HostMsg` JSON it sends to the C# state store. Messages +/// which change a transcript produce a single minimal splice: preserve the common prefix and +/// suffix, delete the changed middle, and insert its replacement. That shape maps directly onto +/// WinUI's `ItemsRepeater` without exposing Swift implementation types across the ABI. +public final class TranscriptProjectionABI { + private final class SessionState { + var events: [AgentEvent] = [] + var items: [TranscriptItem] = [] + let projector = IncrementalTranscriptProjection() + } + + private var sessions: [SessionID: SessionState] = [:] + + public init() {} + + /// Apply one host message and return canonical, versioned render-diff JSON. + /// + /// Messages unrelated to transcripts return `{"splices":[],"version":1}`. Snapshot, live + /// event, full-history chunk, and revert messages update only their named session. + public func apply(_ message: HostMsg) throws -> String { + let update: (SessionID, [AgentEvent])? + switch message { + case .snapshot(let snapshot): + update = (snapshot.summary.sessionID, snapshot.recentEvents) + case .events(let batch): + update = (batch.sessionID, batch.events) + case .transcriptChunk(let chunk): + update = (chunk.sessionID, chunk.events) + case .transcriptReverted(let reverted): + let state = sessions[reverted.sessionID] ?? SessionState() + sessions[reverted.sessionID] = state + if reverted.throughSeq == 0 { + state.events.removeAll() + } else { + state.events.removeAll { $0.seq > reverted.throughSeq } + } + return try renderDiff(sessionID: reverted.sessionID, state: state) + default: + return JSONValue.object([ + "version": .number(1), + "splices": .array([]), + ]).canonicalString() + } + + guard let (sessionID, incoming) = update else { + preconditionFailure("transcript update switch must assign an update") + } + let state = sessions[sessionID] ?? SessionState() + sessions[sessionID] = state + state.events = Self.merged(state.events, incoming) + return try renderDiff(sessionID: sessionID, state: state) + } + + private func renderDiff(sessionID: SessionID, state: SessionState) throws -> String { + let previous = state.items + let next = state.projector.items( + for: state.events, showRaw: false, showLockEvents: true) + state.items = next + + var prefix = 0 + while prefix < previous.count, prefix < next.count, previous[prefix] == next[prefix] { + prefix += 1 + } + + var suffix = 0 + while suffix < previous.count - prefix, suffix < next.count - prefix, + previous[previous.count - suffix - 1] == next[next.count - suffix - 1] { + suffix += 1 + } + + let splices: [JSONValue] + if prefix == previous.count, prefix == next.count { + splices = [] + } else { + let inserted = next[prefix..<(next.count - suffix)].map(Self.json) + splices = [.object([ + "start": .number(Double(prefix)), + "deleteCount": .number(Double(previous.count - prefix - suffix)), + "items": .array(inserted), + ])] + } + + return JSONValue.object([ + "version": .number(1), + "sessionID": .string(sessionID.rawValue), + "splices": .array(splices), + ]).canonicalString() + } + + private static func merged(_ old: [AgentEvent], _ incoming: [AgentEvent]) -> [AgentEvent] { + var bySeq = Dictionary(uniqueKeysWithValues: old.map { ($0.seq, $0) }) + for event in incoming { bySeq[event.seq] = event } + return bySeq.values.sorted { $0.seq < $1.seq } + } + + // MARK: Render DTO + + private static func json(_ item: TranscriptItem) -> JSONValue { + var value: [String: JSONValue] = [ + "id": .string(item.id), + // JSON has no UInt64 type; keep the stable scroll/order anchor exact even above + // JavaScript's 53-bit integer ceiling. C# parses this decimal string as UInt64. + "seq": .string(String(item.seq)), + ] + switch item.kind { + case .message(let role, let text): + value["type"] = .string("message") + value["role"] = .string(role == .user ? "user" : "assistant") + value["text"] = .string(text) + case .thinking(let text): + value["type"] = .string("thinking") + value["text"] = .string(text) + case .tool(let group): + value["type"] = .string("tool") + value["tool"] = json(group) + case .toolBlock(let groups): + value["type"] = .string("toolBlock") + value["tools"] = .array(groups.map(json)) + case .sessionStarted(let model, let cwd): + value["type"] = .string("sessionStarted") + value["model"] = .string(model) + value["cwd"] = .string(cwd) + case .usage(let usage): + value["type"] = .string("usage") + value["usage"] = protocolJSON(usage) + case .rateLimit(let limit): + value["type"] = .string("rateLimit") + value["rateLimit"] = protocolJSON(limit) + case .turnBoundary: + value["type"] = .string("turnBoundary") + case .approval(let toolName): + value["type"] = .string("approval") + value["toolName"] = .string(toolName) + case .runFinished(let outcome): + value["type"] = .string("runFinished") + value["outcome"] = .string(outcome.rawValue) + case .error(let message): + value["type"] = .string("error") + value["message"] = .string(message) + case .note(let text, let icon, let lockEvent, let lock, let processStall): + value["type"] = .string("note") + value["text"] = .string(text) + value["lockEvent"] = .bool(lockEvent) + if let icon { value["icon"] = .string(icon) } + if let lock { value["lock"] = protocolJSON(lock) } + if let processStall { value["processStall"] = protocolJSON(processStall) } + case .raw(let type, let body): + value["type"] = .string("raw") + value["nativeType"] = .string(type) + value["body"] = .string(body) + } + return .object(value) + } + + private static func json(_ group: ToolGroup) -> JSONValue { + var value: [String: JSONValue] = [ + "toolCallID": .string(group.toolCallID), + "name": .string(group.name), + "input": group.input, + "isError": .bool(group.isError), + "finished": .bool(group.finished), + "isOrchestration": .bool(group.isOrchestration), + "fileChanges": .array(group.fileChanges.map { + .object(["path": .string($0.path), "kind": .string($0.change.rawValue)]) + }), + "lockLines": .array(group.lockLines.map(protocolJSON)), + "children": .array(group.children.map(json)), + ] + if let result = group.result { value["result"] = result } + if let command = group.bashCommand { value["bashCommand"] = .string(command) } + return .object(value) + } + + private static func protocolJSON(_ value: T) -> JSONValue { + // These are existing NucleicProtocol Codable values. Encoding cannot fail for their + // concrete cases; retaining a null fallback keeps this renderer-only adapter total if a + // future optional telemetry field gains an unsupported value. + guard let data = try? ProtocolJSON.encode(value), + let json = try? JSONValue(parsing: data) else { return .null } + return json + } +}