From 8fcce22bb8fc9e86ef3236a54e1300b60461e1ed Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 18:41:03 -0700 Subject: [PATCH] nvrsion: Add, fix, refactor, and introduce Usage Tab in Settings showing usage (mirroring home page) with placeholders for Codex limits support (backend not implemented); adjust Live Activity Icon Color to a purple atom icon; add a warning to the VM tab in Settings about 64GB free disk space requirement; implement and address performance issues in TRANSCRIPT_INCREMENTAL_PROJECTION system. Nucleic-Promote: 1 Co-authored-by: Nucleic --- .../Views/SessionDetailView.swift | 65 ++- .../IncrementalTranscriptProjection.swift | 419 ++++++++++++++++++ .../Views/Transcript/MarkdownText.swift | 27 +- .../Transcript/TranscriptProjection.swift | 102 ++++- .../NucleicRemote/Views/TranscriptRow.swift | 16 +- .../SessionLiveActivity.swift | 7 +- 6 files changed, 565 insertions(+), 71 deletions(-) create mode 100644 NucleicRemote/NucleicRemote/Views/Transcript/IncrementalTranscriptProjection.swift diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index e5f3b16..343ca8a 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -494,43 +494,29 @@ struct TranscriptList: View { /// settling — a `.task(id:)` debounces it to detect when the opening layout has come to rest. @State private var transcriptContentHeight: CGFloat = 0 - /// Memoized transcript projection. `TranscriptProjection.build` folds the entire raw event - /// stream into rows on every read, but `body` re-evaluates far more often than `events` - /// actually changes: while the chat is opening, `onScrollGeometryChange` bumps - /// `transcriptContentHeight` on every settling-layout frame, and each bump re-runs `body` — - /// so the untouched event stream was re-folded dozens of times during the exact window the - /// tab bar is sliding away, starving that animation of the main thread and making the - /// load-in jitter. Reuse the last fold whenever the stream is unchanged. A class held in - /// `@State` so reading/updating it from `body` doesn't itself invalidate the view (SwiftUI - /// stores the reference and never diffs its interior). - private final class ProjectionCache { - /// The event stream is append-only with strictly increasing `seq`, so `(count, lastSeq)` - /// uniquely pins it; `firstSeq` guards the open reset (stream cleared to `[]`, then - /// refilled). The two display toggles are inputs to the fold, so they key it too. - private struct Key: Equatable { - var count: Int; var firstSeq: UInt64; var lastSeq: UInt64 - var showRaw: Bool; var showLockEvents: Bool - } - private var key: Key? - private var value: [TranscriptItem] = [] - - func items(for events: [AgentEvent], showRaw: Bool, showLockEvents: Bool) -> [TranscriptItem] { - let k = Key(count: events.count, firstSeq: events.first?.seq ?? 0, - lastSeq: events.last?.seq ?? 0, showRaw: showRaw, showLockEvents: showLockEvents) - if key == k { return value } - let built = TranscriptProjection.build(events, showRaw: showRaw, showLockEvents: showLockEvents) - key = k - value = built - return built - } - } - @State private var projectionCache = ProjectionCache() + /// Incremental transcript projection (docs/TRANSCRIPT_INCREMENTAL_PROJECTION.md). Two layers: + /// a read memo that collapses the redundant `body` re-evaluations (settling layout bumps + /// `transcriptContentHeight` every frame while opening, and each bump re-runs `body` against + /// an unchanged stream), and — when the stream *has* grown — a stable-prefix fold that seals + /// everything before the live turn once and re-folds only the tail, so a streaming delta + /// costs O(live-tail) instead of re-folding all N events (which made long sessions cost + /// O(N²) over their lifetime). A class held in `@State` so reading/updating it from `body` + /// doesn't itself invalidate the view (SwiftUI stores the reference, never diffs interior). + @State private var projectionCache = IncrementalTranscriptProjection() /// The in-flight background Markdown pre-warm (below), cancelled when a newer one supersedes /// it or the transcript goes away — so a long warm can't outlive the view or stack up behind /// row churn. @State private var prewarmTask: Task? + /// How many projected rows the pre-warm has already covered, so each later trigger snapshots + /// only the *new* rows instead of re-walking (and re-hashing) the whole transcript on every + /// row that lands — which would quietly re-introduce an O(N) main-thread pass per row. Held + /// in a box (not `@State` value) because updating it from `body`-adjacent code must not + /// invalidate the view. + private final class PrewarmProgress { var count = 0 } + @State private var prewarmProgress = PrewarmProgress() + private var items: [TranscriptItem] { projectionCache.items(for: events, showRaw: showRaw, showLockEvents: showLockEvents) } @@ -541,14 +527,23 @@ struct TranscriptList: View { /// away, jittering the load-in. Snapshot the message bodies here on the main actor (a cheap read /// of the memoized projection), then parse them on a background task so that first layout finds /// the caches already warm. Idempotent and self-cancelling; the parse results are the same - /// whichever thread fills the (thread-safe) caches. + /// whichever thread fills the (thread-safe) caches. Incremental: only rows beyond the last + /// covered count are snapshotted (coalescing can shuffle nearby indices, but a missed body just + /// parses on first layout as before — the warm is an optimization, never a correctness gate). private func prewarmMarkdown() { - let bodies: [String] = items.compactMap { + let current = items + if current.count < prewarmProgress.count { prewarmProgress.count = 0 } // stream reset + let bodies: [String] = current[prewarmProgress.count...].compactMap { if case .message(_, let text) = $0.kind { return text } else { return nil } } + prewarmProgress.count = current.count guard !bodies.isEmpty else { return } - prewarmTask?.cancel() - prewarmTask = Task.detached(priority: .utility) { + // Chain batches instead of cancelling the in-flight one: each batch covers *new* rows + // only, so cancelling a predecessor (say, the big open batch, superseded by the first + // streamed row) would permanently drop its coverage. `onDisappear` cancels the head of + // the chain; a predecessor mid-parse just finishes its bounded batch into shared caches. + prewarmTask = Task.detached(priority: .utility) { [previous = prewarmTask] in + await previous?.value for body in bodies { if Task.isCancelled { return } MarkdownText.prewarm(body) diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/IncrementalTranscriptProjection.swift b/NucleicRemote/NucleicRemote/Views/Transcript/IncrementalTranscriptProjection.swift new file mode 100644 index 0000000..7f76c9d --- /dev/null +++ b/NucleicRemote/NucleicRemote/Views/Transcript/IncrementalTranscriptProjection.swift @@ -0,0 +1,419 @@ +import Foundation +import NucleicProtocol + +/// Stable-prefix incremental projector (docs/TRANSCRIPT_INCREMENTAL_PROJECTION.md). +/// +/// `TranscriptProjection.build` folds the whole stream on every read, so a streaming turn of K +/// deltas over an N-event transcript costs O(N·K) ≈ O(N²) per session. But the stream is +/// immutable except at the tail: everything before the live turn is frozen — its `messageID`s and +/// `toolCallID`s never recur. So this projector **seals** the longest provably-stable prefix of +/// the folded item list once, and re-folds only the unstable suffix per delta: O(live-tail) +/// instead of O(N). +/// +/// The seal point (an index into the raw event stream) is chosen so that +/// `fold(prefix) ++ fold(tail)` is byte-identical to `fold(whole)`: +/// +/// 1. **No coalescing key crosses the seam.** Text/thinking/tool items coalesce on +/// `messageID`/`toolCallID` (and a subagent's events name their parent Task). An exact +/// interval check over the current stream forbids any seam inside an id's first→last +/// reference span, so no item can straddle it. +/// 2. **No id can recur after the seam.** Future arrivals are fenced by closure rules read off +/// the stream itself: a tool is closed once its result arrived; a message/thinking block once +/// a different message has started in its scope; everything, once its turn completed. These +/// are the premises of the design doc ("the stream is immutable except at the tail"); if one +/// is ever violated — a tail event referencing a sealed id — it is *detected* and the +/// projector resets and re-folds from scratch, so correctness never rests on them. +/// 3. **No tool run is split.** `coalesceToolRuns` merges adjacent `.tool` items, so the seam +/// only falls where the last folded item is a hard separator — a visible non-tool row that a +/// future tool call can't merge across. (Empty redacted-thinking rows are transparent to runs +/// and therefore to this rule too.) +/// 4. **Lock notes fold exactly, even across the seam.** A lock's `released` note lands when the +/// file lands in the parent — potentially many turns after the edit it brackets — so sealed +/// edit cards stay reachable through a registry (`TranscriptProjection.PriorEdit`): a tail +/// note that path-matches a sealed edit patches that card, exactly where a whole-stream fold +/// would put it. No lag heuristic, no divergence. +/// 5. **`worktreeRoot` is a carried constant.** Lock-path normalization needs the seq-0 +/// `sessionStarted` cwd; nothing seals until it is known, and it never changes once found. +/// +/// The equivalence test (`IncrementalProjectionEquivalenceTests`) replays streams and asserts +/// `incremental(prefix) == build(prefix)` for **every** prefix — the whole correctness argument, +/// checked mechanically. +/// +/// A class held in `@State` so reads/updates from `body` don't invalidate the view; not +/// thread-safe (main-actor use only, like the `ProjectionCache` it replaces). +final class IncrementalTranscriptProjection { + + // MARK: - Sealed state + + /// Folded output of the stable prefix — appended to at each seal, never re-walked. + private var sealedItems: [TranscriptItem] = [] + /// Watermark into the raw stream: `events[0.. = [] + /// Sealed edit-class calls in item order, for cross-seam lock-note folding (rule 4). + private var sealedEdits: [TranscriptProjection.PriorEdit] = [] + /// Where each sealed edit's card sits in `sealedItems` (it may live inside a `.toolBlock`). + private var sealedEditIndex: [String: Int] = [:] + /// Display toggles are fold inputs; flipping either resets. + private var showRaw = false + private var showLockEvents = true + + // MARK: - Read memo + + /// `body` re-evaluates far more often than the stream changes (settling layout, scroll + /// geometry); this collapses those redundant reads to a cached return, as the previous + /// `ProjectionCache` did. The stream is append-only with strictly increasing seq, so + /// `(count, firstSeq, lastSeq)` pins it. + private struct MemoKey: Equatable { + var count: Int + var firstSeq: UInt64 + var lastSeq: UInt64 + var showRaw: Bool + var showLockEvents: Bool + } + private var memoKey: MemoKey? + private var memoValue: [TranscriptItem] = [] + + /// Hold-back from the stream edge: never seal into the newest few events. Decoders emit + /// tightly-coupled events in one batch (a `toolResult` and its inferred `fileChange`; a + /// whole-message's final chunks) that sync may deliver one at a time — holding the edge back + /// keeps a mid-batch read from sealing an entity whose trailing batch-mates are still in + /// flight. Cheap insurance on top of the closure rules; violations would only cost a reset. + private let edgeLag = 4 + + /// Test hook: how far the watermark has advanced (the equivalence suite also asserts sealing + /// actually happens, so a regression to "never seal" can't pass silently). + var sealedEventCountForTesting: Int { sealedEventCount } + + // MARK: - Read + + func items(for events: [AgentEvent], showRaw: Bool, showLockEvents: Bool) -> [TranscriptItem] { + let key = MemoKey(count: events.count, firstSeq: events.first?.seq ?? 0, + lastSeq: events.last?.seq ?? 0, showRaw: showRaw, showLockEvents: showLockEvents) + if memoKey == key { return memoValue } + + if needsReset(events, showRaw: showRaw, showLockEvents: showLockEvents) { reset() } + self.showRaw = showRaw + self.showLockEvents = showLockEvents + streamFirstSeq = events.first?.seq + if worktreeRoot == nil { + // Only the unsealed region needs scanning: sealing requires the root, so a sealed + // region can only exist after it was found. + worktreeRoot = TranscriptProjection.worktreeRoot(in: events[sealedEventCount...]) + } + + var watermark = chooseWatermark(events) + if watermark == nil { + // A tail event referenced a sealed id — a closure premise was violated (late file + // change, resumed message, post-result subagent child). Refold from scratch; with no + // sealed ids the second pass cannot be violated. + reset() + streamFirstSeq = events.first?.seq + worktreeRoot = TranscriptProjection.worktreeRoot(in: events[...]) + watermark = chooseWatermark(events) + } + seal(events, upTo: watermark ?? sealedEventCount) + + let result = render(events) + memoKey = key + memoValue = result + return result + } + + // MARK: - Reset / identity + + private func needsReset(_ events: [AgentEvent], showRaw: Bool, showLockEvents: Bool) -> Bool { + if showRaw != self.showRaw || showLockEvents != self.showLockEvents { return true } + if events.count < sealedEventCount { return true } + if sealedEventCount > 0 { + if events.first?.seq != streamFirstSeq { return true } + if events[sealedEventCount - 1].seq != sealedLastSeq { return true } + } else if streamFirstSeq != nil, events.first?.seq != streamFirstSeq { + return true // nothing sealed, but the carried worktreeRoot belongs to the old stream + } + return false + } + + private func reset() { + sealedItems = [] + sealedEventCount = 0 + sealedLastSeq = 0 + streamFirstSeq = nil + worktreeRoot = nil + sealedIDs = [] + sealedEdits = [] + sealedEditIndex = [:] + memoKey = nil + memoValue = [] + } + + // MARK: - Watermark selection + + /// The coalescing ids an event mentions: its `messageID` or `toolCallID`, plus the parent + /// Task id for subagent-owned events. Two events sharing an id must land on the same side of + /// the seam; the parent link chains a subagent's whole scope (and, transitively, deeper + /// descendants) to its spawn. + private static func refs(of event: AgentEvent) -> [String] { + switch event.kind { + case .userText(let c), .assistantText(let c), .thinking(let c): + if let parent = c.parentToolCallID { return [c.messageID, parent] } + return [c.messageID] + case .toolCallStarted(let c), .toolCallCompleted(let c): + if let parent = c.parentToolCallID { return [c.toolCallID, parent] } + return [c.toolCallID] + case .toolCallInputDelta(let d): return [d.toolCallID] + case .toolResult(let r): return [r.toolCallID] + case .fileChange(let f): return f.toolCallID.map { [$0] } ?? [] + default: return [] + } + } + + /// One id's life within the unsealed region. + private struct IDSpan { + var firstRef: Int + var lastRef: Int + /// Result arrived → the tool (or Task, with its children) is done. + var resultSeen = false + /// A later chunk with a different messageID in the same scope → this message is done + /// (its authoritative non-partial text can only arrive before the next message starts). + var closedByChunk = false + /// For message/thinking ids: the owning subagent scope ("" = top level). The owner + /// Task's result closes everything inside it. + var chunkScope: String? + } + + /// What an event *creates* in the folded item list, for the run-split rule (3). + private enum Creation { + /// A visible, never-dropped, non-tool row — a safe last-item for a seam. + case separator + /// A `.tool` row a future adjacent call could merge with. + case tool + /// A thinking row: a separator iff its final text is non-empty (an empty redacted block + /// is transparent to run coalescing, so it must be transparent to the seam rule too). + case thinking(String) + /// A lock note that may fold away (dropping it can fuse the runs around it), so it + /// counts as nothing — the seam just waits for the next hard separator. + case transparent + } + + /// The furthest event index the stream can be sealed to right now, or nil when a region event + /// references an already-sealed id (premise violation → caller resets). + private func chooseWatermark(_ events: [AgentEvent]) -> Int? { + let start = sealedEventCount + let n = events.count - start + // Everything below needs the worktree root (rule 5); without it, just verify no sealed-id + // violation … but nothing is sealed if no root was ever found, so there is nothing to do. + guard worktreeRoot != nil else { return start } + guard n > edgeLag else { + // Too little unsealed to advance, but tail refs must still be validated against + // sealed ids so a violation triggers the reset path. + for r in 0..() + var seenThinkingItem = Set() + var seenToolItem = Set() + var lastChunkInScope: [String: String] = [:] + var lastBoundary = -1 // region index of the latest turnCompleted/runFinished + + for r in 0..= 1 { + for w in 1...cap { + runningMaxLast = max(runningMaxLast, maxLastAtFirst[w - 1]) + if separatorOK[w - 1] && runningMaxLast < w { best = w } + } + } + return start + best + } + + /// Track a text/thinking chunk for message-closure: a new messageID in a scope closes the + /// previous one (chunks of one message never resume after the next begins — decoder order). + private func trackChunk( + _ chunk: TextChunk, in spans: inout [String: IDSpan], lastChunkInScope: inout [String: String] + ) { + let scope = chunk.parentToolCallID ?? "" + spans[chunk.messageID]?.chunkScope = scope + if let previous = lastChunkInScope[scope], previous != chunk.messageID { + spans[previous]?.closedByChunk = true + } + lastChunkInScope[scope] = chunk.messageID + } + + private func isClosed(_ span: IDSpan, spans: [String: IDSpan], lastBoundary: Int) -> Bool { + if span.lastRef < lastBoundary { return true } // its turn completed; ids don't cross turns + if span.resultSeen { return true } + if span.closedByChunk { return true } + if let scope = span.chunkScope, !scope.isEmpty, spans[scope]?.resultSeen == true { + return true // the owning subagent returned; its inner stream is done + } + return false + } + + // MARK: - Sealing + + private func seal(_ events: [AgentEvent], upTo watermark: Int) { + guard watermark > sealedEventCount else { return } + let slice = events[sealedEventCount.. [TranscriptItem] { + let tailSlice = events[sealedEventCount...] + guard !tailSlice.isEmpty else { return sealedItems } + let tail = TranscriptProjection.buildSegment( + tailSlice, worktreeRoot: worktreeRoot, priorEdits: sealedEdits, + showRaw: showRaw, showLockEvents: showLockEvents) + if tail.priorLockPatches.isEmpty { return sealedItems + tail.items } + // A live (unsealed) lock note folded onto a sealed edit card: patch a copy per read — + // the note may still be re-evaluated until it seals, so the base stays unpatched. + var patched = sealedItems + for patch in tail.priorLockPatches { + Self.applyLock(patch.lock, to: patch.toolCallID, at: sealedEditIndex, in: &patched) + } + return patched + tail.items + } + + /// Append a folded lock line to a sealed edit's card, whether it renders alone or inside a + /// coalesced `.toolBlock`. + private static func applyLock( + _ lock: NoteLock, to toolCallID: String, at index: [String: Int], + in items: inout [TranscriptItem] + ) { + guard let i = index[toolCallID] else { return } + switch items[i].kind { + case .tool(var group) where group.toolCallID == toolCallID: + group.lockLines.append(lock) + items[i].kind = .tool(group) + case .toolBlock(var groups): + guard let k = groups.firstIndex(where: { $0.toolCallID == toolCallID }) else { return } + groups[k].lockLines.append(lock) + items[i].kind = .toolBlock(groups) + default: + break + } + } +} diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/MarkdownText.swift b/NucleicRemote/NucleicRemote/Views/Transcript/MarkdownText.swift index 4ea8584..3f5fb5e 100644 --- a/NucleicRemote/NucleicRemote/Views/Transcript/MarkdownText.swift +++ b/NucleicRemote/NucleicRemote/Views/Transcript/MarkdownText.swift @@ -40,14 +40,19 @@ struct MarkdownText: View { /// (scrolling, a sibling row streaming) and on every chat reopen, yet structural parsing /// is independent of `bodySize` — so the source string is a complete key. Bounded; /// `NSCache` also evicts under memory pressure. + /// + /// The whole parse pipeline (both caches and the statics below) is `nonisolated`: `prewarm` + /// runs it from a detached background task by design, and as `View` statics they'd otherwise + /// be implicitly MainActor (a Swift 6 error for that call). `(unsafe)` on the caches is + /// sound because `NSCache` is thread-safe and the boxed values are immutable. private final class ParsedBlocks { let blocks: [Block]; init(_ b: [Block]) { self.blocks = b } } - private static let blockCache: NSCache = { + nonisolated(unsafe) private static let blockCache: NSCache = { let cache = NSCache() cache.countLimit = 2048 return cache }() - private static func parse(_ markdown: String) -> [Block] { + nonisolated private static func parse(_ markdown: String) -> [Block] { let key = markdown as NSString if let hit = blockCache.object(forKey: key) { return hit.blocks } let blocks = parseUncached(markdown) @@ -55,7 +60,7 @@ struct MarkdownText: View { return blocks } - private static func parseUncached(_ markdown: String) -> [Block] { + nonisolated private static func parseUncached(_ markdown: String) -> [Block] { var blocks: [Block] = [] var textBuffer: [String] = [] func flush() { @@ -100,12 +105,12 @@ struct MarkdownText: View { /// A GitHub-style table: a `|`-bearing header line immediately followed by a /// `|---|:--:|` separator line. - private static func isTableStart(_ lines: [String], _ index: Int) -> Bool { + nonisolated private static func isTableStart(_ lines: [String], _ index: Int) -> Bool { guard lines[index].contains("|"), index + 1 < lines.count else { return false } return isSeparatorRow(lines[index + 1]) } - private static func isSeparatorRow(_ line: String) -> Bool { + nonisolated private static func isSeparatorRow(_ line: String) -> Bool { let cells = tableCells(line) guard !cells.isEmpty else { return false } return cells.allSatisfy { cell in @@ -115,7 +120,7 @@ struct MarkdownText: View { /// Split a table row into trimmed cells, dropping the empties created by the /// leading/trailing pipes. - private static func tableCells(_ line: String) -> [String] { + nonisolated private static func tableCells(_ line: String) -> [String] { var trimmed = line.trimmingCharacters(in: .whitespaces) if trimmed.hasPrefix("|") { trimmed.removeFirst() } if trimmed.hasSuffix("|") { trimmed.removeLast() } @@ -189,7 +194,7 @@ struct MarkdownText: View { /// Classify one raw prose line. Headings are checked before bullets (a heading marker wins), /// and the plain case keeps the *raw* line (not the trimmed one) exactly as the old cascade /// did — the inline parser preserves leading whitespace under `.inlineOnlyPreservingWhitespace`. - private static func classify(_ raw: String) -> LineStyle { + nonisolated private static func classify(_ raw: String) -> LineStyle { let trimmed = raw.trimmingCharacters(in: .whitespaces) if trimmed.isEmpty { return .blank } // Headings scale relative to the base prose size so the hierarchy holds at any base and @@ -219,7 +224,7 @@ struct MarkdownText: View { } /// Returns the content after a `- `, `* `, `+ ` or `N. ` list marker, else nil. - private static func bulletContent(_ trimmed: String) -> String? { + nonisolated private static func bulletContent(_ trimmed: String) -> String? { for marker in ["- ", "* ", "+ "] where trimmed.hasPrefix(marker) { return String(trimmed.dropFirst(marker.count)) } @@ -241,13 +246,13 @@ struct MarkdownText: View { /// recur across re-renders and reopens, so memoize the parsed result. Independent of /// `bodySize` (callers apply the font), so the source string is a complete key. private final class InlineBox { let value: AttributedString; init(_ v: AttributedString) { self.value = v } } - private static let inlineCache: NSCache = { + nonisolated(unsafe) private static let inlineCache: NSCache = { let cache = NSCache() cache.countLimit = 16384 return cache }() - private static func attributedInline(_ string: String) -> AttributedString { + nonisolated private static func attributedInline(_ string: String) -> AttributedString { let key = string as NSString if let hit = inlineCache.object(forKey: key) { return hit.value } let options = AttributedString.MarkdownParsingOptions( @@ -272,7 +277,7 @@ struct MarkdownText: View { /// keyed only by the source string (independent of `bodySize`), so a warm value equals what /// the main thread would compute, and a repeat call is a cheap cache hit. Cooperatively /// cancellable — a huge transcript's warm loop bails the moment its owning task is cancelled. - static func prewarm(_ markdown: String) { + nonisolated static func prewarm(_ markdown: String) { for block in parse(markdown) { // also warms the block cache if Task.isCancelled { return } switch block { diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift b/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift index 38e04fc..6f52da4 100644 --- a/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift +++ b/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift @@ -102,26 +102,71 @@ enum TranscriptProjection { /// owns them, project the main agent's own events at the top level, and recursively project /// each subagent's events into the `children` of its spawn — so a subagent's inner work nests /// under its card instead of leaking (and interleaving) into the main transcript. + /// + /// One-shot form: folds the whole stream in one pass. The live transcript uses + /// `IncrementalTranscriptProjection`, which folds the stream as seam-delimited segments via + /// `buildSegment` — this wrapper is the `priorEdits: []` whole-stream case of that. static func build(_ events: [AgentEvent], showRaw: Bool, showLockEvents: Bool) -> [TranscriptItem] { + buildSegment(events[...], worktreeRoot: worktreeRoot(in: events[...]), priorEdits: [], + showRaw: showRaw, showLockEvents: showLockEvents).items + } + + /// An edit-class tool call folded in an *earlier* segment, carried forward so a later + /// segment's lock notes can still fold onto it across the seam (a lock's `released` note + /// lands when the file *lands* in the parent — potentially many turns after the edit). + struct PriorEdit: Equatable { + let toolCallID: String + /// Repo-relative, normalized paths this call writes (`editedPaths` ∘ `normalizeForLock`). + let paths: [String] + } + + /// The folded output of one contiguous slice of the stream. + struct SegmentFold { + var items: [TranscriptItem] + /// Lock lines whose backward path-match crossed the seam onto a `priorEdits` entry, in + /// note order. The caller owns those already-folded items and must attach these to them — + /// that's what keeps `fold(prefix) ++ fold(tail)` byte-identical to `fold(whole)` even + /// for a lock note released turns after its edit. + var priorLockPatches: [(toolCallID: String, lock: NoteLock)] + /// This segment's own edit-class calls (in item order), for the caller's registry. + var edits: [PriorEdit] + } + + /// Fold one contiguous slice of the stream with an injected worktree root (a mid-stream + /// slice lacks the seq-0 `sessionStarted` that `build` rescans for). The slice must be + /// seam-delimited — no coalescing key, subagent scope, or open tool run straddling either + /// end — which is exactly what `IncrementalTranscriptProjection` guarantees before calling. + static func buildSegment( + _ events: ArraySlice, worktreeRoot root: String?, priorEdits: [PriorEdit], + showRaw: Bool, showLockEvents: Bool + ) -> SegmentFold { let (topLevel, byParent) = partition(events) // Fold lock-lifecycle notes onto the edit cards they bracket, exactly as the Mac's // `items(_:worktreeRoot:)` does — matched against the session's working directory so an // edit's absolute `file_path` compares against the note's repo-relative paths. Folding // happens only at the top level (a subagent's inner edits are literal, unlocked); the // subagent recursion below stays plain, matching the desktop projection. - let root = worktreeRoot(in: topLevel) + var patches: [(toolCallID: String, lock: NoteLock)] = [] let flat = foldLockNotes(flatItems(topLevel, showRaw: showRaw, showLockEvents: showLockEvents), - worktreeRoot: root) - return coalesceToolRuns(flat).map { + worktreeRoot: root, priorEdits: priorEdits, priorLockPatches: &patches) + let items = coalesceToolRuns(flat).map { attachSubagentChildren($0, byParent: byParent, depth: 0, showRaw: showRaw, showLockEvents: showLockEvents) } + let edits = flat.compactMap { item -> PriorEdit? in + guard case .tool(let group) = item.kind else { return nil } + let paths = editedPaths(toolName: group.name, input: group.input) + .map { normalizeForLock($0, worktreeRoot: root) } + .filter { !$0.isEmpty } + return paths.isEmpty ? nil : PriorEdit(toolCallID: group.toolCallID, paths: paths) + } + return SegmentFold(items: items, priorLockPatches: patches, edits: edits) } /// The session's working directory, read from its `sessionStarted` event, so an edit's /// absolute `file_path` can be made repo-relative to compare against a lock note's /// repo-relative paths. `nil` before the start event is seen (nothing to fold against yet). - private static func worktreeRoot(in events: [AgentEvent]) -> String? { + static func worktreeRoot(in events: ArraySlice) -> String? { for event in events { if case .sessionStarted(let started) = event.kind, !started.cwd.isEmpty { return started.cwd @@ -138,7 +183,15 @@ enum TranscriptProjection { /// `file_path` is stripped of `worktreeRoot` and normalized so it compares against the note's /// repo-relative paths. A note overlapping no preceding edit is left in place (renders /// standalone), matching the Mac's `foldLockNotes`. - private static func foldLockNotes(_ flat: [TranscriptItem], worktreeRoot: String?) -> [TranscriptItem] { + /// + /// The backward scan continues past the front of `flat` into `priorEdits` — the edits of + /// already-folded earlier segments, oldest first — so a note whose edit was sealed in a prior + /// segment (a lock released turns later) still folds exactly where a whole-stream fold would + /// put it. Those hits are reported via `priorLockPatches` for the caller to attach. + private static func foldLockNotes( + _ flat: [TranscriptItem], worktreeRoot: String?, priorEdits: [PriorEdit], + priorLockPatches: inout [(toolCallID: String, lock: NoteLock)] + ) -> [TranscriptItem] { // Each tool item's repo-relative edited paths (only edit-class tools have any), by index. var editsByIndex: [Int: (id: String, paths: [String])] = [:] for (i, item) in flat.enumerated() { @@ -148,7 +201,8 @@ enum TranscriptProjection { .filter { !$0.isEmpty } if !paths.isEmpty { editsByIndex[i] = (group.toolCallID, paths) } } - guard !editsByIndex.isEmpty else { return flat } + guard !editsByIndex.isEmpty || !priorEdits.isEmpty else { return flat } + let priorIDs = Set(priorEdits.map(\.toolCallID)) var locksByCall: [String: [NoteLock]] = [:] var folded = Set() @@ -167,13 +221,26 @@ enum TranscriptProjection { guard let edit = editsByIndex[j] else { continue } if edit.paths.contains(where: { pathsOverlap($0, path) }) { hitID = edit.id; break } } + if hitID == nil { + // Nothing in this segment — keep scanning backward across the seam, newest + // prior edit first, exactly where a whole-stream scan would look next. + for prior in priorEdits.reversed() + where prior.paths.contains(where: { pathsOverlap($0, path) }) { + hitID = prior.toolCallID + break + } + } guard let hitID else { matchedAll = false; break } if let k = indexByID[hitID] { perCard[k].paths.append(path) } else { indexByID[hitID] = perCard.count; perCard.append((hitID, [path])) } } guard matchedAll, !perCard.isEmpty else { continue } for card in perCard { - locksByCall[card.id, default: []].append(NoteLock(state: lock.state, paths: card.paths)) + if priorIDs.contains(card.id) { + priorLockPatches.append((card.id, NoteLock(state: lock.state, paths: card.paths))) + } else { + locksByCall[card.id, default: []].append(NoteLock(state: lock.state, paths: card.paths)) + } } folded.insert(i) } @@ -235,7 +302,7 @@ enum TranscriptProjection { /// Split a scope's events into the main agent's own (`topLevel`) and each subagent's, keyed by /// the spawning `Task`'s id. - private static func partition(_ events: [AgentEvent]) + private static func partition(_ events: ArraySlice) -> (topLevel: [AgentEvent], byParent: [String: [AgentEvent]]) { let parentOf = toolParentMap(events) @@ -254,7 +321,7 @@ enum TranscriptProjection { /// Maps each tool-call id to its parent subagent's id, built from the call start/complete /// events. A tool *result* or *file change* names only a tool id, so it inherits its subagent /// scope from the call it belongs to via this map. - private static func toolParentMap(_ events: [AgentEvent]) -> [String: String] { + private static func toolParentMap(_ events: ArraySlice) -> [String: String] { var map: [String: String] = [:] for event in events { switch event.kind { @@ -512,4 +579,21 @@ extension JSONValue { default: return false } } + + /// A one-line gist of a tool input/result for the collapsed row. Lives here (not with the + /// row views) because the projection folds it into `.raw` bodies, and this file also builds + /// standalone as the host-testable `NucleicRemoteProjection` SwiftPM target. + var compactSummary: String { + switch self { + case .string(let s): return s + case .object(let o): + if let cmd = o["command"]?.stringValue { return cmd } + if let path = o["file_path"]?.stringValue ?? o["path"]?.stringValue { return path } + return o.keys.sorted().joined(separator: ", ") + case .array(let a): return "[\(a.count) items]" + case .number(let n): return String(n) + case .bool(let b): return String(b) + case .null: return "null" + } + } } diff --git a/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift b/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift index f492cec..ebe23b2 100644 --- a/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift +++ b/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift @@ -250,20 +250,8 @@ extension JSONValue { return canonical == "null" ? "" : canonical } - /// A one-line gist of a tool input/result for the collapsed row. - var compactSummary: String { - switch self { - case .string(let s): return s - case .object(let o): - if let cmd = o["command"]?.stringValue { return cmd } - if let path = o["file_path"]?.stringValue ?? o["path"]?.stringValue { return path } - return o.keys.sorted().joined(separator: ", ") - case .array(let a): return "[\(a.count) items]" - case .number(let n): return String(n) - case .bool(let b): return String(b) - case .null: return "null" - } - } + // (`compactSummary` — the one-line gist — lives in TranscriptProjection.swift, which builds + // standalone as the host-testable NucleicRemoteProjection SwiftPM target and needs it there.) /// The full, **untruncated** content of a tool input for the approval card — /// the user must see exactly what they are granting before allowing. Unlike diff --git a/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift b/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift index 38632c7..8619626 100644 --- a/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift +++ b/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift @@ -50,10 +50,12 @@ struct SessionLiveActivity: Widget { } } } compactLeading: { + // Calm state shows the Nucleic Control brand mark (the purple atom); when the + // user is needed it switches to the amber approval glyph. Image(systemName: state.needsAttention - ? ActivityPalette.glyph(.approval) : ActivityPalette.glyph(.running)) + ? ActivityPalette.glyph(.approval) : "atom") .foregroundStyle(state.needsAttention - ? ActivityPalette.attention : ActivityPalette.active) + ? ActivityPalette.attention : ActivityPalette.brand) } compactTrailing: { Text("\(state.needsAttention ? max(state.approvalCount, state.needsYouCount) : state.runningCount)") .font(.caption2.bold()) @@ -280,6 +282,7 @@ private enum ActivityPalette { static let success = Color(red: 0.30, green: 0.78, blue: 0.45) // done green static let danger = Color(red: 0.92, green: 0.34, blue: 0.34) // error red static let neutral = Color.secondary + static let brand = Color(red: 0.62, green: 0.51, blue: 0.93) // Nucleic Control purple static let added = Color(red: 0.30, green: 0.78, blue: 0.45) static let removed = Color(red: 0.92, green: 0.34, blue: 0.34)