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.
- package/CHANGELOG.md +84 -0
- package/LICENSE +21 -0
- package/README.md +122 -0
- package/dist/server.js +19792 -0
- package/dist/tui.js +22742 -0
- package/docs/USAGE.md +155 -0
- package/package.json +93 -0
- package/src/core/actions.ts +53 -0
- package/src/core/adopt.ts +93 -0
- package/src/core/consumers.ts +39 -0
- package/src/core/crop.ts +176 -0
- package/src/core/cropplan.ts +201 -0
- package/src/core/ctree-args.ts +89 -0
- package/src/core/decision.ts +97 -0
- package/src/core/journal.ts +343 -0
- package/src/core/lanes.ts +149 -0
- package/src/core/navigation.ts +95 -0
- package/src/core/tokens.ts +105 -0
- package/src/core/transcript.ts +124 -0
- package/src/core/tree.ts +738 -0
- package/src/core/undo.ts +52 -0
- package/src/server/index.ts +278 -0
- package/src/shared/adopt.ts +82 -0
- package/src/shared/debug.ts +9 -0
- package/src/shared/store.ts +286 -0
- package/src/tui/actions.ts +447 -0
- package/src/tui/editor.ts +45 -0
- package/src/tui/index.tsx +421 -0
- package/src/tui/route.tsx +1058 -0
- package/src/tui/transcripts.ts +55 -0
package/src/core/tree.ts
ADDED
|
@@ -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
|
+
}
|