opencode-context-tree 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,738 @@
1
+ /**
2
+ * The combined tree + trajectory view model (DESIGN.md §7) — a Pi-style whole-tree outline.
3
+ *
4
+ * `buildTreeView` does a depth-first walk of the *whole* tree of sessions, starting at the
5
+ * root (`state.root`, or the current session's furthest loaded ancestor). Each session
6
+ * contributes its own messages (a branch's tail after the copied prefix; `session.fork`
7
+ * copies that prefix with **fresh message IDs**, so the tail is found by *position* — the
8
+ * anchor's index in the parent applied to the branch). A branch hangs off the message it was
9
+ * forked from: right after that message we draw a `⎇ name` header and, when the branch is
10
+ * open, recurse into its tail one level deeper. Branches on the current path (its chain from
11
+ * the root) are open by default so you always see the trunk, where you are, and every sibling
12
+ * from anywhere; off-path branches fold to a single header (`▸`), expandable with `→`/`e`.
13
+ *
14
+ * A `git log --graph` gutter draws the tree axis: `│ ` for each open ancestor level and a
15
+ * `├⎇`/`╰⎇` join at each branch header; row order is the time axis. Rows carry their *owning*
16
+ * session's message IDs (jumping into the prefix forks the ancestor, not the copy).
17
+ *
18
+ * Pure, no OpenCode/opentui/solid-js imports — see test/core-purity.test.ts.
19
+ */
20
+ import type { BranchState, TreeState } from "./journal.js"
21
+ import { estimateTokens } from "./tokens.js"
22
+ import { messagePreview, partPreview, stepKind, type StepPart, type Transcript, type TranscriptMessage } from "./transcript.js"
23
+
24
+ export type Filter = "default" | "no-tools" | "user-only" | "labeled" | "all"
25
+
26
+ export type TurnRow = {
27
+ kind: "turn"
28
+ id: string
29
+ sessionID: string
30
+ messageID: string
31
+ turn: number
32
+ depth: number
33
+ gutter: string
34
+ glyph: "●"
35
+ preview: string
36
+ tokens: number
37
+ estimated: boolean
38
+ label?: string
39
+ isCurrent: boolean
40
+ isTip: boolean
41
+ isDecision: boolean
42
+ isSummary: boolean
43
+ }
44
+
45
+ export type StepRow = {
46
+ kind: "step"
47
+ id: string
48
+ sessionID: string
49
+ messageID: string
50
+ partID: string
51
+ depth: number
52
+ gutter: string
53
+ glyph: "○" | "⚙" | "◇"
54
+ preview: string
55
+ tokens: number
56
+ estimated: boolean
57
+ durationMs?: number
58
+ isError: boolean
59
+ isCropped: boolean
60
+ warn: boolean
61
+ /** Label of the owning message, shown on its first step row only. */
62
+ label?: string
63
+ }
64
+
65
+ export type BranchRow = {
66
+ kind: "branch"
67
+ id: string
68
+ sessionID: string
69
+ parentSessionID: string
70
+ anchorMessageID: string
71
+ depth: number
72
+ gutter: string
73
+ name: string
74
+ status: "open" | "squashed" | "rejected" | "discarded" | "abandoned" | "deleted"
75
+ /** The reason recorded when the branch was closed (discard "Why?", tournament epitaph). */
76
+ note?: string
77
+ turns: number
78
+ tokens: number
79
+ model?: string
80
+ expanded: boolean
81
+ isCurrent: boolean
82
+ last: boolean
83
+ }
84
+
85
+ export type Row = TurnRow | StepRow | BranchRow
86
+
87
+ export type TreeView = {
88
+ rows: Row[]
89
+ indexById: Record<string, number>
90
+ currentRowId?: string
91
+ totalTokens: number
92
+ /** true when any part of `totalTokens` is a chars/4 guess — render it as `~`.
93
+ * Optional so callers can build a placeholder view without it. */
94
+ totalEstimated?: boolean
95
+ }
96
+
97
+ export type CropRef = { messageID: string; partID?: string }
98
+
99
+ export type BuildOptions = {
100
+ state: TreeState
101
+ transcripts: Record<string, Transcript>
102
+ currentSessionID: string
103
+ /** sessionIDs whose branch row is expanded to show its own rows inline. */
104
+ expanded: Set<string>
105
+ filter: Filter
106
+ search?: string
107
+ /** Bookmarks, keyed by messageID (DESIGN.md §4.1 `label.set`). */
108
+ labels?: Record<string, string>
109
+ crops?: CropRef[]
110
+ }
111
+
112
+ const WARN_TOKENS = 10_000
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Small text/number helpers.
116
+ // ---------------------------------------------------------------------------
117
+
118
+ function userText(message: TranscriptMessage): string {
119
+ return message.parts
120
+ .filter((p) => p.type === "text" && p.text)
121
+ .map((p) => p.text)
122
+ .join("\n")
123
+ }
124
+
125
+ function partText(part: StepPart): string {
126
+ return part.type === "tool" ? (part.state?.output ?? "") : (part.text ?? "")
127
+ }
128
+
129
+ function durationOfPart(part: StepPart): number | undefined {
130
+ const t = part.state?.time ?? part.time
131
+ if (t?.start !== undefined && t?.end !== undefined) return t.end - t.start
132
+ return undefined
133
+ }
134
+
135
+ function durationOfParts(parts: StepPart[]): number | undefined {
136
+ let start: number | undefined
137
+ let end: number | undefined
138
+ for (const p of parts) {
139
+ const t = p.state?.time ?? p.time
140
+ if (t?.start !== undefined) start = start === undefined ? t.start : Math.min(start, t.start)
141
+ if (t?.end !== undefined) end = end === undefined ? t.end : Math.max(end, t.end)
142
+ }
143
+ return start !== undefined && end !== undefined ? end - start : undefined
144
+ }
145
+
146
+ /** `metadata.ctree.kind` of a message, read off whichever part carries it. */
147
+ function ctreeKindOf(message: TranscriptMessage): string | undefined {
148
+ for (const part of message.parts) {
149
+ const ctree = part.metadata?.["ctree"] as { kind?: string } | undefined
150
+ if (ctree?.kind) return ctree.kind
151
+ }
152
+ return undefined
153
+ }
154
+
155
+ /** Marker `say()` puts on every headless `/ctree …` reply (src/server/index.ts). */
156
+ const PLUGIN_COMMAND_PREFIX = "[context tree]"
157
+
158
+ /** A headless `/ctree …` runs as an OpenCode command, so it leaves a user turn carrying
159
+ * the marker plus a one-line acknowledgement: plugin plumbing, not conversation. */
160
+ function isPluginCommand(message: TranscriptMessage): boolean {
161
+ const text = message.parts.find((p) => p.type === "text" && p.text)?.text
162
+ return text !== undefined && text.startsWith(PLUGIN_COMMAND_PREFIX)
163
+ }
164
+
165
+ /** Plugin command turns are hidden everywhere but `all`, and while hidden they are not
166
+ * turns at all — `T<n>` numbers what you can see, so it never skips (DESIGN.md §7.2). */
167
+ function hiddenPluginTurn(filter: Filter, message: TranscriptMessage): boolean {
168
+ return filter !== "all" && message.role === "user" && isPluginCommand(message)
169
+ }
170
+
171
+ function countTurns(filter: Filter, messages: TranscriptMessage[]): number {
172
+ return messages.reduce((n, m) => (m.role === "user" && !hiddenPluginTurn(filter, m) ? n + 1 : n), 0)
173
+ }
174
+
175
+ function findLastUserIndex(messages: TranscriptMessage[], filter: Filter): number {
176
+ for (let i = messages.length - 1; i >= 0; i--) if (messages[i]!.role === "user" && !hiddenPluginTurn(filter, messages[i]!)) return i
177
+ return -1
178
+ }
179
+
180
+ /** Tokens for one part of an assistant message: a share of `message.tokens.output`
181
+ * proportional to this part's text length when the message has been costed by the
182
+ * model, else a chars/4 estimate (DESIGN.md §7's row `tokens` column). */
183
+ function stepTokensFor(part: StepPart, message: TranscriptMessage): { tokens: number; estimated: boolean } {
184
+ const text = partText(part)
185
+ // a tool result is not model output: `tokens.output` never covers it, and splitting the
186
+ // model's output across it would price an 80k-char result at a few dozen tokens
187
+ if (part.type !== "tool") {
188
+ const output = message.tokens?.output
189
+ if (typeof output === "number" && output > 0) {
190
+ const generatedLen = message.parts.reduce((sum, p) => (p.type === "tool" ? sum : sum + partText(p).length), 0)
191
+ if (generatedLen > 0) {
192
+ return { tokens: Math.round(output * (text.length / generatedLen)), estimated: false }
193
+ }
194
+ }
195
+ }
196
+ return { tokens: estimateTokens(text), estimated: true }
197
+ }
198
+
199
+ function aggregateTokens(messages: TranscriptMessage[]): number {
200
+ let total = 0
201
+ for (const m of messages) {
202
+ if (m.role === "user") total += estimateTokens(userText(m))
203
+ else for (const p of m.parts) total += estimateTokens(partText(p))
204
+ }
205
+ return total
206
+ }
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // Ancestry.
210
+ // ---------------------------------------------------------------------------
211
+
212
+ /** Session IDs from the root down to (but excluding) `sessionID`, by walking
213
+ * `parentSessionID` through `state.sessions`. Empty if `sessionID` was never itself
214
+ * forked (e.g. the tree's root session). */
215
+ function ancestorChainOf(state: TreeState, sessionID: string): string[] {
216
+ const chain: string[] = []
217
+ const seen = new Set<string>([sessionID])
218
+ let cur = sessionID
219
+ for (;;) {
220
+ const branch = state.sessions[cur]
221
+ if (!branch) break
222
+ if (seen.has(branch.parentSessionID)) break // guard against a corrupt cycle
223
+ chain.unshift(branch.parentSessionID)
224
+ seen.add(branch.parentSessionID)
225
+ cur = branch.parentSessionID
226
+ }
227
+ return chain
228
+ }
229
+
230
+ function buildAnchorMap(state: TreeState): Map<string, BranchState[]> {
231
+ const map = new Map<string, BranchState[]>()
232
+ for (const sessionID of Object.keys(state.sessions)) {
233
+ const branch = state.sessions[sessionID]!
234
+ const list = map.get(branch.anchorMessageID)
235
+ if (list) list.push(branch)
236
+ else map.set(branch.anchorMessageID, [branch])
237
+ }
238
+ return map
239
+ }
240
+
241
+ // ---------------------------------------------------------------------------
242
+ // Filters.
243
+ // ---------------------------------------------------------------------------
244
+
245
+ function stepAllowed(filter: Filter, kind: "text" | "tool" | "reasoning" | "other", labelled = false): boolean {
246
+ switch (filter) {
247
+ case "user-only":
248
+ return false
249
+ case "labeled":
250
+ return labelled
251
+ case "default":
252
+ return kind !== "other"
253
+ case "no-tools":
254
+ return kind !== "other" && kind !== "tool"
255
+ case "all":
256
+ return true
257
+ }
258
+ }
259
+
260
+ function turnAllowed(filter: Filter, label: string | undefined): boolean {
261
+ return filter === "labeled" ? Boolean(label) : true
262
+ }
263
+
264
+ function branchAllowed(filter: Filter): boolean {
265
+ return filter !== "labeled" && filter !== "user-only"
266
+ }
267
+
268
+ // ---------------------------------------------------------------------------
269
+ // Row emission.
270
+ // ---------------------------------------------------------------------------
271
+
272
+ type Ctx = {
273
+ state: TreeState
274
+ transcripts: Record<string, Transcript>
275
+ currentSessionID: string
276
+ expanded: Set<string>
277
+ filter: Filter
278
+ labels: Record<string, string>
279
+ crops: CropRef[]
280
+ /** anchorMessageID → the branches forked there, in journal order. */
281
+ anchorMap: Map<string, BranchState[]>
282
+ /** The current session and every ancestor up to the root; these are open by default. */
283
+ onPath: Set<string>
284
+ /** Each on-path session → the next session down the current path, so the DFS can keep
285
+ * descending toward the current session even when an ancestor's transcript never loaded. */
286
+ onPathChild: Map<string, string>
287
+ /** Index in the current session's transcript where its own messages begin (its copied
288
+ * prefix length), used to render its rows when its parent's transcript is missing. */
289
+ currentTailStart: number
290
+ }
291
+
292
+ function isCropped(ctx: Ctx, messageID: string, partID: string): boolean {
293
+ return ctx.crops.some((c) => c.messageID === messageID && (c.partID === undefined || c.partID === partID))
294
+ }
295
+
296
+ function emitAssistantRows(ctx: Ctx, sessionID: string, message: TranscriptMessage, depth: number, gutter: string, out: Row[]): void {
297
+ if (message.summary) {
298
+ // OpenCode-native compaction summary: one row for the whole message (DESIGN.md §7).
299
+ if (!stepAllowed(ctx.filter, "text")) return
300
+ const text = message.parts.map((p) => partText(p)).join("\n")
301
+ const output = message.tokens?.output
302
+ const tokens = typeof output === "number" ? output : estimateTokens(text)
303
+ const estimated = typeof output !== "number"
304
+ const firstPart = message.parts[0]
305
+ const partID = firstPart?.id ?? `${message.id}-summary`
306
+ out.push({
307
+ kind: "step",
308
+ id: `${sessionID}:${message.id}:${partID}`,
309
+ sessionID,
310
+ messageID: message.id,
311
+ partID,
312
+ depth,
313
+ gutter,
314
+ glyph: "◇",
315
+ preview: "compaction summary",
316
+ tokens,
317
+ estimated,
318
+ durationMs: durationOfParts(message.parts),
319
+ isError: false,
320
+ isCropped: false,
321
+ warn: tokens >= WARN_TOKENS,
322
+ })
323
+ return
324
+ }
325
+
326
+ let first = true
327
+ for (const part of message.parts) {
328
+ const kind = stepKind(part)
329
+ const label = first ? ctx.labels[message.id] : undefined
330
+ if (!stepAllowed(ctx.filter, kind, Boolean(label))) continue
331
+ const { tokens, estimated } = stepTokensFor(part, message)
332
+ first = false
333
+ out.push({
334
+ kind: "step",
335
+ id: `${sessionID}:${message.id}:${part.id}`,
336
+ sessionID,
337
+ messageID: message.id,
338
+ partID: part.id,
339
+ depth,
340
+ gutter,
341
+ glyph: kind === "tool" ? "⚙" : "○",
342
+ preview: partPreview(part),
343
+ tokens,
344
+ estimated,
345
+ durationMs: durationOfPart(part),
346
+ isError: part.state?.status === "error",
347
+ isCropped: isCropped(ctx, message.id, part.id),
348
+ warn: tokens >= WARN_TOKENS,
349
+ label,
350
+ })
351
+ }
352
+ }
353
+
354
+ /** A branch is open in the outline when the user has toggled it: on-path branches start open
355
+ * (so the root→you path is always visible), so `expanded` membership *collapses* them;
356
+ * off-path branches start folded, so membership *opens* them. The BranchRow's `expanded`
357
+ * field carries this resolved state, so the route can fold/unfold with one toggle either way. */
358
+ function shownExpanded(ctx: Ctx, sessionID: string): boolean {
359
+ const flagged = ctx.expanded.has(sessionID)
360
+ return ctx.onPath.has(sessionID) ? !flagged : flagged
361
+ }
362
+
363
+ /** Emit a branch header for `branch` (unless a filter hides branch rows) and, when it is open,
364
+ * recurse into the branch's own messages — its tail after the copied prefix, found by the
365
+ * anchor's *position* in the parent (fork copies the prefix with fresh IDs). */
366
+ function pushBranch(ctx: Ctx, branch: BranchState, depth: number, gutter: string, last: boolean, showHeader: boolean, out: Row[]): void {
367
+ const branchTranscript = ctx.transcripts[branch.sessionID]
368
+ const parentTranscript = ctx.transcripts[branch.parentSessionID]
369
+ const anchorIndex = parentTranscript?.messages.findIndex((m) => m.id === branch.anchorMessageID) ?? -1
370
+ const isCurrent = branch.sessionID === ctx.currentSessionID
371
+ // Offset where the branch's own tail begins in its transcript — the anchor's position in the
372
+ // parent, since fork copies the prefix by position. When the anchor can't be resolved we never
373
+ // slice from 0, which would replay the whole copied prefix as fresh rows (duplicate turns):
374
+ // · anchor "" — forked before the parent's first message, so it shares nothing: whole tail.
375
+ // · the current session — its live transcript is here; slice at the prefix the loaded
376
+ // ancestors account for, so its rows still render when an on-path ancestor never loaded.
377
+ // · otherwise — a real anchor we cannot place: tail unknown, so empty.
378
+ const tailStart = anchorIndex >= 0 ? anchorIndex + 1 : branch.anchorMessageID === "" ? 0 : isCurrent ? ctx.currentTailStart : -1
379
+ const tail = branchTranscript && tailStart >= 0 ? branchTranscript.messages.slice(tailStart) : []
380
+ const expanded = shownExpanded(ctx, branch.sessionID)
381
+
382
+ if (showHeader) {
383
+ out.push({
384
+ kind: "branch",
385
+ id: `branch:${branch.sessionID}`,
386
+ sessionID: branch.sessionID,
387
+ parentSessionID: branch.parentSessionID,
388
+ anchorMessageID: branch.anchorMessageID,
389
+ depth,
390
+ gutter: `${gutter}${last ? "╰⎇" : "├⎇"}`,
391
+ // adopted native forks carry no name: fall back to the session's live title
392
+ name: branch.name ?? branchTranscript?.title ?? branch.sessionID,
393
+ status: branch.forgotten ? "deleted" : (branch.status ?? "open"),
394
+ note: branch.note,
395
+ turns: countTurns(ctx.filter, tail),
396
+ tokens: aggregateTokens(tail),
397
+ model: branch.model,
398
+ expanded,
399
+ isCurrent,
400
+ last,
401
+ })
402
+ }
403
+
404
+ if (!expanded) return
405
+
406
+ // hidden headers (user-only/labeled) keep the current path flat, one level per open branch
407
+ const childDepth = showHeader ? depth + 1 : depth
408
+ const childGutter = showHeader ? `${gutter}│ ` : gutter
409
+ if (branchTranscript && tailStart >= 0) {
410
+ // continue the parent's turn numbering: the branch's first turn follows the anchor's turn
411
+ const turnAtAnchor =
412
+ anchorIndex >= 0 && parentTranscript
413
+ ? countTurns(ctx.filter, parentTranscript.messages.slice(0, anchorIndex + 1))
414
+ : countTurns(ctx.filter, branchTranscript.messages.slice(0, tailStart))
415
+ walkSession(ctx, branch.sessionID, tail, childDepth, childGutter, turnAtAnchor, out)
416
+ }
417
+ // With no rows of its own an on-path ancestor still must hand off to its on-path child, so the
418
+ // DFS reaches the current session when an intermediate ancestor's transcript never loaded.
419
+ descendOnPath(ctx, branch.sessionID, tail, childDepth, childGutter, out)
420
+ }
421
+
422
+ /** Keep the DFS descending the on-path chain even when a session's transcript is missing or
423
+ * empty: draw `sessionID`'s on-path child (known from the journal, no transcript needed) unless
424
+ * the walk over `walked` already drew it. Off-path sessions have no on-path child — a no-op. */
425
+ function descendOnPath(ctx: Ctx, sessionID: string, walked: TranscriptMessage[], depth: number, gutter: string, out: Row[]): void {
426
+ const childID = ctx.onPathChild.get(sessionID)
427
+ const child = childID ? ctx.state.sessions[childID] : undefined
428
+ if (!child || walked.some((m) => m.id === child.anchorMessageID)) return
429
+ pushBranch(ctx, child, depth, gutter, true, branchAllowed(ctx.filter), out)
430
+ }
431
+
432
+ /** The branches forked at `anchorMessageID` of `sessionID`, drawn just below that message.
433
+ * When a filter hides branch rows, only the current path recurses (off-path branches drop). */
434
+ function emitChildBranches(ctx: Ctx, sessionID: string, anchorMessageID: string, depth: number, gutter: string, out: Row[]): void {
435
+ const children = (ctx.anchorMap.get(anchorMessageID) ?? []).filter((b) => b.parentSessionID === sessionID)
436
+ if (children.length === 0) return
437
+ const showHeaders = branchAllowed(ctx.filter)
438
+ const visible = showHeaders ? children : children.filter((b) => ctx.onPath.has(b.sessionID))
439
+ visible.forEach((branch, i) => {
440
+ pushBranch(ctx, branch, depth, gutter, i === visible.length - 1, showHeaders, out)
441
+ })
442
+ }
443
+
444
+ /**
445
+ * Depth-first over one session: emit its own `messages` (the tail after any copied prefix)
446
+ * as rows at `depth`/`gutter`, and after each message recurse into the branches anchored on
447
+ * it. `turnStart` seeds the turn counter (a branch continues its parent's numbering).
448
+ */
449
+ function walkSession(ctx: Ctx, sessionID: string, messages: TranscriptMessage[], depth: number, gutter: string, turnStart: number, out: Row[]): void {
450
+ const lastUserIndex = findLastUserIndex(messages, ctx.filter)
451
+ const counter = { turn: turnStart }
452
+ // set by a hidden plugin command turn, so the acknowledgement that follows it goes too
453
+ let inPluginCommand = false
454
+ messages.forEach((message, i) => {
455
+ if (message.role === "user") {
456
+ inPluginCommand = hiddenPluginTurn(ctx.filter, message)
457
+ if (!inPluginCommand) {
458
+ counter.turn++
459
+ const turn = counter.turn
460
+ const label = ctx.labels[message.id]
461
+ const isDecision = ctx.state.decisions[message.id] !== undefined || ctreeKindOf(message) === "decision"
462
+ const isSummary = ctreeKindOf(message) === "summary"
463
+ if (turnAllowed(ctx.filter, label)) {
464
+ out.push({
465
+ kind: "turn",
466
+ id: `${sessionID}:${message.id}`,
467
+ sessionID,
468
+ messageID: message.id,
469
+ turn,
470
+ depth,
471
+ gutter,
472
+ glyph: "●",
473
+ preview: messagePreview(message),
474
+ tokens: estimateTokens(userText(message)),
475
+ estimated: true,
476
+ label,
477
+ isCurrent: sessionID === ctx.currentSessionID,
478
+ isTip: i === lastUserIndex,
479
+ isDecision,
480
+ isSummary,
481
+ })
482
+ }
483
+ }
484
+ } else if (!inPluginCommand) {
485
+ emitAssistantRows(ctx, sessionID, message, depth, gutter, out)
486
+ }
487
+ // Branches attach right after their anchor — the last message they share with
488
+ // this session — whichever role that message has.
489
+ emitChildBranches(ctx, sessionID, message.id, depth, gutter, out)
490
+ })
491
+ }
492
+
493
+ // ---------------------------------------------------------------------------
494
+ // Search.
495
+ // ---------------------------------------------------------------------------
496
+
497
+ function rowSearchFields(row: Row): string[] {
498
+ switch (row.kind) {
499
+ case "turn":
500
+ return row.label ? [row.preview, row.label] : [row.preview]
501
+ case "step":
502
+ return row.label ? [row.preview, row.label] : [row.preview]
503
+ case "branch":
504
+ return row.model ? [row.name, row.model, row.status] : [row.name, row.status]
505
+ }
506
+ }
507
+
508
+ function rowMatches(row: Row, needle: string): boolean {
509
+ return rowSearchFields(row).some((f) => f.toLowerCase().includes(needle))
510
+ }
511
+
512
+ /** Case-insensitive substring search over preview/name/tool/label (DESIGN.md §7.5).
513
+ * A turn row is kept if it — or one of the step rows it owns (same depth,
514
+ * immediately following) — matches; a branch row is kept if it, or any row nested
515
+ * under it (depth greater than its own), matches. */
516
+ function applySearch(rows: Row[], search: string): Row[] {
517
+ const needle = search.trim().toLowerCase()
518
+ if (!needle) return rows
519
+
520
+ const direct = rows.map((r) => rowMatches(r, needle))
521
+ const keep = direct.slice()
522
+
523
+ for (let i = 0; i < rows.length; i++) {
524
+ const row = rows[i]!
525
+ if (row.kind === "turn") {
526
+ let j = i + 1
527
+ while (j < rows.length && rows[j]!.depth === row.depth && rows[j]!.kind === "step") {
528
+ if (direct[j]) keep[i] = true
529
+ j++
530
+ }
531
+ } else if (row.kind === "branch") {
532
+ let j = i + 1
533
+ while (j < rows.length && rows[j]!.depth > row.depth) {
534
+ if (direct[j]) keep[i] = true
535
+ j++
536
+ }
537
+ }
538
+ }
539
+
540
+ return rows.filter((_, i) => keep[i])
541
+ }
542
+
543
+ // ---------------------------------------------------------------------------
544
+ // Total tokens.
545
+ // ---------------------------------------------------------------------------
546
+
547
+ /** Last assistant `tokens.input` in the current session, plus what the next request adds
548
+ * on top of it — that turn's own output (exact when the provider counted it) and its tool
549
+ * results (always chars/4) — following `tokens.ts`'s `contextSizeOf` (DESIGN.md §3.3 /
550
+ * §6.7). `estimated` is true only when a guess really is part of the figure. */
551
+ function computeTotalTokens(transcript: Transcript): { tokens: number; estimated: boolean } {
552
+ let lastIndex = -1
553
+ let lastInput = 0
554
+ transcript.messages.forEach((m, idx) => {
555
+ if (m.role === "assistant" && typeof m.tokens?.input === "number") {
556
+ lastIndex = idx
557
+ lastInput = m.tokens.input
558
+ }
559
+ })
560
+
561
+ // starts AT the last assistant: its `tokens.input` is the context it was *given*
562
+ let counted = 0
563
+ let guessed = 0
564
+ for (let i = Math.max(lastIndex, 0); i < transcript.messages.length; i++) {
565
+ const m = transcript.messages[i]!
566
+ if (m.role === "user") {
567
+ guessed += estimateTokens(userText(m))
568
+ continue
569
+ }
570
+ // `tokens.output` covers what the model generated, never the tool results it read back
571
+ const output = m.tokens?.output
572
+ if (typeof output === "number") counted += output
573
+ for (const p of m.parts) if (p.type === "tool" || typeof output !== "number") guessed += estimateTokens(partText(p))
574
+ }
575
+
576
+ return { tokens: lastInput + counted + guessed, estimated: lastIndex === -1 || guessed > 0 }
577
+ }
578
+
579
+ // ---------------------------------------------------------------------------
580
+ // Entry point.
581
+ // ---------------------------------------------------------------------------
582
+
583
+ /** The current session and every ancestor from the root down to it — the path drawn open by
584
+ * default. Exported so the route can seed its fold state / know what is "on the path". */
585
+ export function currentChainOf(state: TreeState, currentSessionID: string): string[] {
586
+ return [...ancestorChainOf(state, currentSessionID), currentSessionID]
587
+ }
588
+
589
+ /** How many messages of the current session's transcript are copied prefix: the summed length
590
+ * every *loaded* on-path ancestor contributes (an unloaded ancestor adds nothing, so its share
591
+ * falls to the current session). Mirrors buildSpineMap's `from`, so the render and the
592
+ * crop/spine translation slice the current transcript at the same point. */
593
+ function prefixLengthOf(state: TreeState, transcripts: Record<string, Transcript>, chain: string[]): number {
594
+ let from = 0
595
+ for (let s = 0; s < chain.length - 1; s++) {
596
+ const own = transcripts[chain[s]!]
597
+ const child = state.sessions[chain[s + 1]!]
598
+ const anchorIndex = own && child ? own.messages.findIndex((m) => m.id === child.anchorMessageID) : -1
599
+ if (anchorIndex >= 0) from = anchorIndex + 1
600
+ }
601
+ return from
602
+ }
603
+
604
+ export function buildTreeView(o: BuildOptions): TreeView {
605
+ const currentTranscript = o.transcripts[o.currentSessionID]
606
+ const chain = currentChainOf(o.state, o.currentSessionID)
607
+
608
+ // Render from the tree's declared root when its transcript is loaded, else the furthest
609
+ // loaded ancestor of the current session, else the current session itself. The DFS below
610
+ // then reaches every branch and sibling from there, so nothing is "elsewhere".
611
+ let root = o.currentSessionID
612
+ if (o.state.root && o.transcripts[o.state.root]) root = o.state.root
613
+ else for (const s of chain) if (o.transcripts[s]) { root = s; break }
614
+
615
+ const rootTranscript = o.transcripts[root]
616
+ if (!rootTranscript) return { rows: [], indexById: {}, currentRowId: undefined, totalTokens: 0, totalEstimated: false }
617
+
618
+ const ctx: Ctx = {
619
+ state: o.state,
620
+ transcripts: o.transcripts,
621
+ currentSessionID: o.currentSessionID,
622
+ expanded: o.expanded,
623
+ filter: o.filter,
624
+ labels: o.labels ?? {},
625
+ crops: o.crops ?? [],
626
+ anchorMap: buildAnchorMap(o.state),
627
+ onPath: new Set(chain),
628
+ onPathChild: new Map(chain.slice(0, -1).map((s, i) => [s, chain[i + 1]!])),
629
+ currentTailStart: prefixLengthOf(o.state, o.transcripts, chain),
630
+ }
631
+
632
+ const allRows: Row[] = []
633
+ // branches forked before the first message (anchor "") sit above the root's messages
634
+ emitChildBranches(ctx, root, "", 0, "", allRows)
635
+ walkSession(ctx, root, rootTranscript.messages, 0, "", 0, allRows)
636
+ // if the render root's on-path child wasn't drawn among its messages (its anchor isn't in the
637
+ // loaded root transcript), descend anyway so the path to the current session is never cut
638
+ descendOnPath(ctx, root, rootTranscript.messages, 0, "", allRows)
639
+
640
+ const rows = o.search ? applySearch(allRows, o.search) : allRows
641
+
642
+ // the current session's tip: its last message row, or — for a fork with no messages of its
643
+ // own yet — its own branch header
644
+ let currentRowId: string | undefined
645
+ for (let i = rows.length - 1; i >= 0; i--) {
646
+ const r = rows[i]!
647
+ if (r.kind !== "branch" && r.sessionID === o.currentSessionID) {
648
+ currentRowId = r.id
649
+ break
650
+ }
651
+ }
652
+ if (!currentRowId) currentRowId = rows.find((r) => r.kind === "branch" && r.isCurrent)?.id
653
+
654
+ const indexById: Record<string, number> = {}
655
+ rows.forEach((r, i) => {
656
+ indexById[r.id] = i
657
+ })
658
+
659
+ const total = currentTranscript ? computeTotalTokens(currentTranscript) : { tokens: 0, estimated: false }
660
+ return { rows, indexById, currentRowId, totalTokens: total.tokens, totalEstimated: total.estimated }
661
+ }
662
+
663
+
664
+ // ---------------------------------------------------------------------------
665
+ // Positional map between spine rows and the current session's copied prefix.
666
+ // ---------------------------------------------------------------------------
667
+
668
+ export type SpineMap = {
669
+ /** `${sessionID}:${messageID}` of any spine message → index into the current transcript */
670
+ index: Map<string, number>
671
+ /** current-session messageID for a spine message (same for own messages) */
672
+ toCurrent: (sessionID: string, messageID: string) => string | undefined
673
+ /** current-session partID for a spine part, by position within the message */
674
+ partToCurrent: (sessionID: string, messageID: string, partID: string) => string | undefined
675
+ /** spine owner of a current-session message (itself when past the fork point) */
676
+ fromCurrent: (currentMessageID: string) => { sessionID: string; messageID: string } | undefined
677
+ /** spine partID for a current-session part */
678
+ partFromCurrent: (currentMessageID: string, currentPartID: string) => { sessionID: string; messageID: string; partID: string } | undefined
679
+ }
680
+
681
+ /** Built from the *unfiltered* transcripts, so hidden rows never shift positions
682
+ * (unlike a map derived from the rendered rows). */
683
+ export function buildSpineMap(o: Pick<BuildOptions, "state" | "transcripts" | "currentSessionID">): SpineMap {
684
+ const current = o.transcripts[o.currentSessionID]
685
+ const index = new Map<string, number>()
686
+ const owner: { sessionID: string; messageID: string }[] = []
687
+ if (current) {
688
+ const spine = [...ancestorChainOf(o.state, o.currentSessionID), o.currentSessionID]
689
+ let from = 0
690
+ for (let s = 0; s < spine.length; s++) {
691
+ const sessionID = spine[s]!
692
+ const own = o.transcripts[sessionID]
693
+ const child = spine[s + 1]
694
+ const childBranch = child ? o.state.sessions[child] : undefined
695
+ if (s < spine.length - 1) {
696
+ const anchorIndex = own ? own.messages.findIndex((m) => m.id === childBranch?.anchorMessageID) : -1
697
+ if (own && anchorIndex !== -1) {
698
+ own.messages.slice(from, anchorIndex + 1).forEach((m, i) => {
699
+ index.set(`${sessionID}:${m.id}`, from + i)
700
+ owner[from + i] = { sessionID, messageID: m.id }
701
+ })
702
+ from = anchorIndex + 1
703
+ }
704
+ continue
705
+ }
706
+ current.messages.slice(from).forEach((m, i) => {
707
+ index.set(`${sessionID}:${m.id}`, from + i)
708
+ owner[from + i] = { sessionID, messageID: m.id }
709
+ })
710
+ }
711
+ }
712
+ const messageAt = (i: number) => current?.messages[i]
713
+ const toCurrent = (sessionID: string, messageID: string) => {
714
+ const i = index.get(`${sessionID}:${messageID}`)
715
+ return i === undefined ? undefined : messageAt(i)?.id
716
+ }
717
+ const partToCurrent = (sessionID: string, messageID: string, partID: string) => {
718
+ const i = index.get(`${sessionID}:${messageID}`)
719
+ if (i === undefined) return undefined
720
+ const src = o.transcripts[sessionID]?.messages.find((m) => m.id === messageID)
721
+ const k = src?.parts.findIndex((p) => p.id === partID) ?? -1
722
+ return k === -1 ? undefined : messageAt(i)?.parts[k]?.id
723
+ }
724
+ const fromCurrent = (currentMessageID: string) => {
725
+ const i = current?.messages.findIndex((m) => m.id === currentMessageID) ?? -1
726
+ return i === -1 ? undefined : owner[i]
727
+ }
728
+ const partFromCurrent = (currentMessageID: string, currentPartID: string) => {
729
+ const o1 = fromCurrent(currentMessageID)
730
+ if (!o1) return undefined
731
+ const cur = current?.messages.find((m) => m.id === currentMessageID)
732
+ const k = cur?.parts.findIndex((p) => p.id === currentPartID) ?? -1
733
+ const src = o.transcripts[o1.sessionID]?.messages.find((m) => m.id === o1.messageID)
734
+ const partID = k === -1 ? undefined : src?.parts[k]?.id
735
+ return partID ? { ...o1, partID } : undefined
736
+ }
737
+ return { index, toCurrent, partToCurrent, fromCurrent, partFromCurrent }
738
+ }