Merge nucleic/vivid-spruce-robin-bacs into dev

This commit is contained in:
2026-08-04 00:09:36 -07:00
parent f80b1952c3
commit 1a9330b541
3 changed files with 21 additions and 34 deletions
@@ -144,9 +144,9 @@ struct SettingsView: View {
} header: { } header: {
Text("Transcript") Text("Transcript")
} footer: { } footer: {
Text("Advanced detail surfaces per-turn token counts, rate-limit notices, turn " Text("Advanced detail surfaces model thought traces, per-turn token counts, "
+ "markers, and raw events. Hidden by default so consecutive tool calls " + "rate-limit notices, turn markers, and raw events. Hidden by default "
+ "read as one group.") + "so conversations stay focused and consecutive tool calls read as one group.")
} }
Section { Section {
@@ -25,8 +25,8 @@ import NucleicProtocol
/// projector resets and re-folds from scratch, so correctness never rests on them. /// 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 /// 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 /// 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 /// future tool call can't merge across. (Thought traces and other advanced-detail rows are
/// and therefore to this rule too.) /// 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 /// 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 /// 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 /// edit cards stay reachable through a registry (`TranscriptProjection.PriorEdit`): a tail
@@ -205,11 +205,8 @@ final class IncrementalTranscriptProjection {
case separator case separator
/// A `.tool` row a future adjacent call could merge with. /// A `.tool` row a future adjacent call could merge with.
case tool case tool
/// A thinking row: a separator iff its final text is non-empty (an empty redacted block /// A thought trace or other row held aside from the conversation, or a lock note that
/// is transparent to run coalescing, so it must be transparent to the seam rule too). /// may fold away. It counts as nothing so the seam waits for the next hard separator.
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 case transparent
} }
@@ -232,9 +229,7 @@ final class IncrementalTranscriptProjection {
var spans: [String: IDSpan] = [:] var spans: [String: IDSpan] = [:]
var creations: [Creation?] = Array(repeating: nil, count: n) var creations: [Creation?] = Array(repeating: nil, count: n)
var thinkingText: [String: String] = [:]
var seenMessageItem = Set<String>() var seenMessageItem = Set<String>()
var seenThinkingItem = Set<String>()
var seenToolItem = Set<String>() var seenToolItem = Set<String>()
var lastChunkInScope: [String: String] = [:] var lastChunkInScope: [String: String] = [:]
var lastBoundary = -1 // region index of the latest turnCompleted/runFinished var lastBoundary = -1 // region index of the latest turnCompleted/runFinished
@@ -256,9 +251,7 @@ final class IncrementalTranscriptProjection {
if seenMessageItem.insert(c.messageID).inserted { creations[r] = .separator } if seenMessageItem.insert(c.messageID).inserted { creations[r] = .separator }
case .thinking(let c): case .thinking(let c):
trackChunk(c, in: &spans, lastChunkInScope: &lastChunkInScope) trackChunk(c, in: &spans, lastChunkInScope: &lastChunkInScope)
let existing = thinkingText[c.messageID] ?? "" creations[r] = .transparent
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): case .toolCallStarted(let c), .toolCallCompleted(let c):
if seenToolItem.insert(c.toolCallID).inserted { creations[r] = .tool } if seenToolItem.insert(c.toolCallID).inserted { creations[r] = .tool }
case .toolResult(let result): case .toolResult(let result):
@@ -310,20 +303,13 @@ final class IncrementalTranscriptProjection {
} }
// Run-split rule (3): replay creations in item order; a seam is placeable after event r // 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 // only while the last solid (visible, surviving) item is a hard separator.
// 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 separatorOK = [Bool](repeating: false, count: n)
var lastSolidIsSeparator = true // sealed prefix is empty or ends with a separator (invariant) var lastSolidIsSeparator = true // sealed prefix is empty or ends with a separator (invariant)
for r in 0..<n { for r in 0..<n {
switch creations[r] { switch creations[r] {
case .separator: lastSolidIsSeparator = true case .separator: lastSolidIsSeparator = true
case .tool: lastSolidIsSeparator = false case .tool: lastSolidIsSeparator = false
case .thinking(let id):
let text = thinkingText[id] ?? ""
if !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
lastSolidIsSeparator = true
}
case .transparent, nil: break case .transparent, nil: break
} }
separatorOK[r] = lastSolidIsSeparator separatorOK[r] = lastSolidIsSeparator
@@ -64,15 +64,14 @@ struct TranscriptItem: Identifiable, Equatable {
return false return false
} }
/// Diagnostic, debug-level rows: per-turn token tallies, rate-limit notices, turn markers, and /// Advanced-detail rows: model thought traces, per-turn token tallies, rate-limit notices,
/// unrecognized raw passthrough events. Hidden by default to keep the transcript clean — they're /// turn markers, and unrecognized raw passthrough events. Hidden by default to keep the
/// the "token counts and other lines between tool calls" that fragmented a run of cards — and /// transcript focused on the conversation, and surfaced only when "advanced detail"
/// surfaced only when "advanced detail" (`showRaw`) is on. Prose, thinking, tool calls, /// (`showRaw`) is on. Prose, tool calls, session-start, approvals, run outcome, and errors
/// session-start, approvals, run outcome, and errors are never debug. Mirrors the Mac's /// are never debug. Mirrors the Mac's `TranscriptItem.isDebug`.
/// `TranscriptItem.isDebug`.
var isDebug: Bool { var isDebug: Bool {
switch kind { switch kind {
case .usage, .rateLimit, .turnBoundary, .raw: return true case .thinking, .usage, .rateLimit, .turnBoundary, .raw: return true
default: return false default: return false
} }
} }
@@ -416,8 +415,9 @@ enum TranscriptProjection {
showRaw: Bool, showLockEvents: Bool showRaw: Bool, showLockEvents: Bool
) -> [TranscriptItem] { ) -> [TranscriptItem] {
let items = coalesceToolRuns(flatItems(events, showRaw: showRaw, showLockEvents: showLockEvents)) let items = coalesceToolRuns(flatItems(events, showRaw: showRaw, showLockEvents: showLockEvents))
guard depth < maxSubagentDepth else { return items } let visible = showRaw ? items : items.filter { !$0.isDebug }
return items.map { guard depth < maxSubagentDepth else { return visible }
return visible.map {
attachSubagentChildren($0, byParent: byParent, depth: depth, attachSubagentChildren($0, byParent: byParent, depth: depth,
showRaw: showRaw, showLockEvents: showLockEvents) showRaw: showRaw, showLockEvents: showLockEvents)
} }
@@ -462,8 +462,9 @@ enum TranscriptProjection {
/// followed by the agent's own `Bash` calls yields a fan-out card *and* a tool block, not one /// followed by the agent's own `Bash` calls yields a fan-out card *and* a tool block, not one
/// generic block titled by the first `Task` that would swallow the fan-out. /// generic block titled by the first `Task` that would swallow the fan-out.
/// ///
/// `flatItems` already drops the only kind the phone has (empty redacted thinking), so the /// Advanced-detail rows, including non-empty thought traces, take the hold path in the live
/// hold path is a guard for a caller passing unfiltered items — not a live case. /// projection. They are re-emitted after the block when detail is enabled and filtered out
/// otherwise.
static func coalesceToolRuns(_ flat: [TranscriptItem]) -> [TranscriptItem] { static func coalesceToolRuns(_ flat: [TranscriptItem]) -> [TranscriptItem] {
var out: [TranscriptItem] = [] var out: [TranscriptItem] = []
var run: [TranscriptItem] = [] // consecutive `.tool` items var run: [TranscriptItem] = [] // consecutive `.tool` items