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 <[email protected]>
This commit is contained in:
@@ -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<Void, Never>?
|
||||
|
||||
/// 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)
|
||||
|
||||
@@ -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..<sealedEventCount]` produced `sealedItems`.
|
||||
private var sealedEventCount = 0
|
||||
/// `events[sealedEventCount - 1].seq` at seal time — detects a rewritten/merged prefix
|
||||
/// (a reconnect backfill slotting events in by seq) that shifts history under the watermark.
|
||||
private var sealedLastSeq: UInt64 = 0
|
||||
/// The stream's first seq — detects a session switch / stream reset.
|
||||
private var streamFirstSeq: UInt64?
|
||||
/// Carried constant from the first non-empty `sessionStarted.cwd`. Nothing seals while nil
|
||||
/// (a root arriving later would retroactively change sealed lock folds).
|
||||
private var worktreeRoot: String?
|
||||
/// Every `messageID`/`toolCallID` referenced by a sealed event. A later event referencing one
|
||||
/// would mutate sealed output — detected here, answered with a full reset (self-healing).
|
||||
private var sealedIDs: Set<String> = []
|
||||
/// 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..<n where Self.refs(of: events[start + r]).contains(where: sealedIDs.contains) {
|
||||
return nil
|
||||
}
|
||||
return start
|
||||
}
|
||||
|
||||
var spans: [String: IDSpan] = [:]
|
||||
var creations: [Creation?] = Array(repeating: nil, count: n)
|
||||
var thinkingText: [String: String] = [:]
|
||||
var seenMessageItem = Set<String>()
|
||||
var seenThinkingItem = Set<String>()
|
||||
var seenToolItem = Set<String>()
|
||||
var lastChunkInScope: [String: String] = [:]
|
||||
var lastBoundary = -1 // region index of the latest turnCompleted/runFinished
|
||||
|
||||
for r in 0..<n {
|
||||
let event = events[start + r]
|
||||
for id in Self.refs(of: event) {
|
||||
if sealedIDs.contains(id) { return nil }
|
||||
if var span = spans[id] {
|
||||
span.lastRef = r
|
||||
spans[id] = span
|
||||
} else {
|
||||
spans[id] = IDSpan(firstRef: r, lastRef: r)
|
||||
}
|
||||
}
|
||||
switch event.kind {
|
||||
case .userText(let c), .assistantText(let c):
|
||||
trackChunk(c, in: &spans, lastChunkInScope: &lastChunkInScope)
|
||||
if seenMessageItem.insert(c.messageID).inserted { creations[r] = .separator }
|
||||
case .thinking(let c):
|
||||
trackChunk(c, in: &spans, lastChunkInScope: &lastChunkInScope)
|
||||
let existing = thinkingText[c.messageID] ?? ""
|
||||
thinkingText[c.messageID] = c.isPartial ? existing + c.text : c.text
|
||||
if seenThinkingItem.insert(c.messageID).inserted { creations[r] = .thinking(c.messageID) }
|
||||
case .toolCallStarted(let c), .toolCallCompleted(let c):
|
||||
if seenToolItem.insert(c.toolCallID).inserted { creations[r] = .tool }
|
||||
case .toolResult(let result):
|
||||
spans[result.toolCallID]?.resultSeen = true
|
||||
case .toolCallInputDelta, .fileChange, .approvalResolved:
|
||||
break // refs (if any) tracked above; creates nothing
|
||||
case .turnCompleted, .runFinished:
|
||||
creations[r] = .separator
|
||||
lastBoundary = r
|
||||
case .sessionStarted, .usage, .rateLimit, .approvalRequested, .error:
|
||||
creations[r] = .separator
|
||||
case .note(let note):
|
||||
if note.lockEvent && !showLockEvents { break }
|
||||
if let lock = note.lock, !lock.paths.isEmpty { creations[r] = .transparent }
|
||||
else { creations[r] = .separator }
|
||||
case .raw:
|
||||
if showRaw { creations[r] = .separator }
|
||||
}
|
||||
}
|
||||
|
||||
// Future-proofing (rule 2): an id wholly before the seam must be *closed* — provably done
|
||||
// taking new events. An open id caps the seam at its first reference (it stays whole in
|
||||
// the tail).
|
||||
var cap = n - edgeLag
|
||||
for (_, span) in spans where !isClosed(span, spans: spans, lastBoundary: lastBoundary) {
|
||||
cap = min(cap, span.firstRef)
|
||||
}
|
||||
|
||||
// Interval isolation (rule 1): no seam inside any id's [firstRef, lastRef] span.
|
||||
// maxLastFromBefore[w] = the furthest lastRef among ids first referenced before w; a seam
|
||||
// at w is isolation-safe iff that never reaches w.
|
||||
var maxLastAtFirst = [Int](repeating: -1, count: n)
|
||||
for span in spans.values {
|
||||
maxLastAtFirst[span.firstRef] = max(maxLastAtFirst[span.firstRef], span.lastRef)
|
||||
}
|
||||
|
||||
// Run-split rule (3): replay creations in item order; a seam is placeable after event r
|
||||
// only while the last solid (visible, surviving) item is a hard separator. The replay
|
||||
// resolves each thinking row against its *final* region text, which is exactly what the
|
||||
// sealed fold will contain (open ids were already excluded by `cap`).
|
||||
var separatorOK = [Bool](repeating: false, count: n)
|
||||
var lastSolidIsSeparator = true // sealed prefix is empty or ends with a separator (invariant)
|
||||
for r in 0..<n {
|
||||
switch creations[r] {
|
||||
case .separator: lastSolidIsSeparator = true
|
||||
case .tool: lastSolidIsSeparator = false
|
||||
case .thinking(let id):
|
||||
let text = thinkingText[id] ?? ""
|
||||
if !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
lastSolidIsSeparator = true
|
||||
}
|
||||
case .transparent, nil: break
|
||||
}
|
||||
separatorOK[r] = lastSolidIsSeparator
|
||||
}
|
||||
|
||||
var runningMaxLast = -1
|
||||
var best = 0
|
||||
if cap >= 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..<watermark]
|
||||
let segment = TranscriptProjection.buildSegment(
|
||||
slice, worktreeRoot: worktreeRoot, priorEdits: sealedEdits,
|
||||
showRaw: showRaw, showLockEvents: showLockEvents)
|
||||
|
||||
// Lock notes in this segment that folded onto edits sealed earlier: bake them in — the
|
||||
// note is now sealed too, so the fold is final.
|
||||
for patch in segment.priorLockPatches {
|
||||
Self.applyLock(patch.lock, to: patch.toolCallID, at: sealedEditIndex, in: &sealedItems)
|
||||
}
|
||||
let editIDs = Set(segment.edits.map(\.toolCallID))
|
||||
for item in segment.items {
|
||||
let index = sealedItems.count
|
||||
switch item.kind {
|
||||
case .tool(let group) where editIDs.contains(group.toolCallID):
|
||||
sealedEditIndex[group.toolCallID] = index
|
||||
case .toolBlock(let groups):
|
||||
for group in groups where editIDs.contains(group.toolCallID) {
|
||||
sealedEditIndex[group.toolCallID] = index
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
sealedItems.append(item)
|
||||
}
|
||||
sealedEdits.append(contentsOf: segment.edits)
|
||||
for event in slice {
|
||||
for id in Self.refs(of: event) { sealedIDs.insert(id) }
|
||||
}
|
||||
sealedEventCount = watermark
|
||||
sealedLastSeq = events[watermark - 1].seq
|
||||
}
|
||||
|
||||
// MARK: - Rendering
|
||||
|
||||
private func render(_ events: [AgentEvent]) -> [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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<NSString, ParsedBlocks> = {
|
||||
nonisolated(unsafe) private static let blockCache: NSCache<NSString, ParsedBlocks> = {
|
||||
let cache = NSCache<NSString, ParsedBlocks>()
|
||||
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<NSString, InlineBox> = {
|
||||
nonisolated(unsafe) private static let inlineCache: NSCache<NSString, InlineBox> = {
|
||||
let cache = NSCache<NSString, InlineBox>()
|
||||
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 {
|
||||
|
||||
@@ -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<AgentEvent>, 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<AgentEvent>) -> 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<Int>()
|
||||
@@ -167,14 +221,27 @@ 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 {
|
||||
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)
|
||||
}
|
||||
guard !folded.isEmpty else { return flat }
|
||||
@@ -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<AgentEvent>)
|
||||
-> (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<AgentEvent>) -> [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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user