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,343 @@
1
+ /**
2
+ * Journal entry types and pure fold logic (DESIGN.md §4.1).
3
+ *
4
+ * This module must never import from `@opencode-ai/*`, `@opentui/*`, or
5
+ * `solid-js` — it is plain, deterministic TypeScript, unit-tested with
6
+ * fixtures, and run in both the server and TUI plugin halves.
7
+ */
8
+ import { z } from "zod"
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Journal line payloads, one schema per `type` in DESIGN.md §4.1's table.
12
+ // ---------------------------------------------------------------------------
13
+
14
+ export const TreeCreatedData = z.object({
15
+ rootSessionID: z.string(),
16
+ })
17
+ export type TreeCreatedData = z.infer<typeof TreeCreatedData>
18
+
19
+ export const BranchKind = z.enum(["explicit", "jump", "redo", "native"])
20
+ export type BranchKind = z.infer<typeof BranchKind>
21
+
22
+ export const BranchOpenedData = z.object({
23
+ sessionID: z.string(),
24
+ parentSessionID: z.string(),
25
+ anchorMessageID: z.string(),
26
+ name: z.string().optional(),
27
+ trunkModel: z.string().optional(),
28
+ branchModel: z.string().optional(),
29
+ kind: BranchKind,
30
+ })
31
+ export type BranchOpenedData = z.infer<typeof BranchOpenedData>
32
+
33
+ export const BranchStatus = z.enum(["squashed", "rejected", "discarded", "abandoned"])
34
+ export type BranchStatus = z.infer<typeof BranchStatus>
35
+
36
+ export const BranchClosedData = z.object({
37
+ sessionID: z.string(),
38
+ status: BranchStatus,
39
+ decisionMessageID: z.string().optional(),
40
+ note: z.string().optional(),
41
+ })
42
+ export type BranchClosedData = z.infer<typeof BranchClosedData>
43
+
44
+ export const SummaryRecordedData = z.object({
45
+ sessionID: z.string(),
46
+ messageID: z.string(),
47
+ fromSessionID: z.string(),
48
+ fromMessageID: z.string(),
49
+ })
50
+ export type SummaryRecordedData = z.infer<typeof SummaryRecordedData>
51
+
52
+ export const DecisionRecordedData = z.object({
53
+ /** The record text as landed in the trunk (kept here so compaction re-injection and the
54
+ * decisions view need no OpenCode round-trip). */
55
+ text: z.string().optional(),
56
+ sessionID: z.string(),
57
+ messageID: z.string(),
58
+ forkSessionID: z.string(),
59
+ branchName: z.string(),
60
+ siblings: z.array(z.object({ name: z.string(), reason: z.string().optional() })),
61
+ })
62
+ export type DecisionRecordedData = z.infer<typeof DecisionRecordedData>
63
+
64
+ export const CropMode = z.enum(["result", "turn"])
65
+ export type CropMode = z.infer<typeof CropMode>
66
+
67
+ export const CropTarget = z.object({
68
+ messageID: z.string(),
69
+ partID: z.string().optional(),
70
+ callID: z.string().optional(),
71
+ tool: z.string().optional(),
72
+ estTokens: z.number(),
73
+ sha8: z.string(),
74
+ })
75
+ export type CropTarget = z.infer<typeof CropTarget>
76
+
77
+ export const CropAppliedData = z.object({
78
+ sessionID: z.string(),
79
+ mode: CropMode,
80
+ targets: z.array(CropTarget),
81
+ anchorMessageID: z.string(),
82
+ })
83
+ export type CropAppliedData = z.infer<typeof CropAppliedData>
84
+
85
+ export const CropRestoredData = z.object({
86
+ cropID: z.string(),
87
+ })
88
+ export type CropRestoredData = z.infer<typeof CropRestoredData>
89
+
90
+ export const LabelSetData = z.object({
91
+ sessionID: z.string(),
92
+ messageID: z.string(),
93
+ label: z.string().nullable(),
94
+ })
95
+ export type LabelSetData = z.infer<typeof LabelSetData>
96
+
97
+ export const SessionForgottenData = z.object({
98
+ sessionID: z.string(),
99
+ })
100
+ export type SessionForgottenData = z.infer<typeof SessionForgottenData>
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // The envelope and its discriminated-union variants.
104
+ // ---------------------------------------------------------------------------
105
+
106
+ export const JournalActor = z.enum(["tui", "server", "cli"])
107
+ export type JournalActor = z.infer<typeof JournalActor>
108
+
109
+ const envelope = <Type extends string, Data extends z.ZodTypeAny>(type: Type, data: Data) =>
110
+ z.object({
111
+ v: z.literal(1),
112
+ id: z.string(),
113
+ ts: z.number(),
114
+ type: z.literal(type),
115
+ actor: JournalActor,
116
+ data,
117
+ })
118
+
119
+ export const JournalEntrySchema = z.discriminatedUnion("type", [
120
+ envelope("tree.created", TreeCreatedData),
121
+ envelope("branch.opened", BranchOpenedData),
122
+ envelope("branch.closed", BranchClosedData),
123
+ envelope("summary.recorded", SummaryRecordedData),
124
+ envelope("decision.recorded", DecisionRecordedData),
125
+ envelope("crop.applied", CropAppliedData),
126
+ envelope("crop.restored", CropRestoredData),
127
+ envelope("label.set", LabelSetData),
128
+ envelope("session.forgotten", SessionForgottenData),
129
+ ])
130
+ export type JournalEntry = z.infer<typeof JournalEntrySchema>
131
+ export type JournalEntryType = JournalEntry["type"]
132
+
133
+ /** Parse one JSONL line into a validated journal entry, or `undefined` if it is malformed. */
134
+ export function parseJournalLine(line: string): JournalEntry | undefined {
135
+ const trimmed = line.trim()
136
+ if (!trimmed) return undefined
137
+ let json: unknown
138
+ try {
139
+ json = JSON.parse(trimmed)
140
+ } catch {
141
+ return undefined
142
+ }
143
+ const result = JournalEntrySchema.safeParse(json)
144
+ return result.success ? result.data : undefined
145
+ }
146
+
147
+ /** Parse a whole journal file's contents (one JSON object per line), skipping malformed lines. */
148
+ export function parseJournal(contents: string): JournalEntry[] {
149
+ const entries: JournalEntry[] = []
150
+ for (const line of contents.split("\n")) {
151
+ const entry = parseJournalLine(line)
152
+ if (entry) entries.push(entry)
153
+ }
154
+ return entries
155
+ }
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // Folded state.
159
+ // ---------------------------------------------------------------------------
160
+
161
+ export type BranchState = {
162
+ sessionID: string
163
+ parentSessionID: string
164
+ anchorMessageID: string
165
+ name?: string
166
+ kind: BranchKind
167
+ trunkModel?: string
168
+ branchModel?: string
169
+ /** Alias of `branchModel`, the model this branch should run on (DESIGN.md §5's `/branch <name> [model]`). */
170
+ model?: string
171
+ /** "open" until a matching branch.closed; "forgotten" sessions stay in this map, greyed out. */
172
+ status: "open" | BranchStatus
173
+ decisionMessageID?: string
174
+ note?: string
175
+ forgotten: boolean
176
+ }
177
+
178
+ export type CropState = {
179
+ cropID: string
180
+ sessionID: string
181
+ mode: CropMode
182
+ targets: CropTarget[]
183
+ anchorMessageID: string
184
+ restored: boolean
185
+ }
186
+
187
+ export type DecisionState = {
188
+ sessionID: string
189
+ messageID: string
190
+ forkSessionID: string
191
+ branchName: string
192
+ siblings: { name: string; reason?: string }[]
193
+ text?: string
194
+ /** True once the branch it closed was re-opened by /undo: the message stays on screen
195
+ * but the server hides it from the model (DESIGN.md §12 decision 4). */
196
+ hidden: boolean
197
+ recordedAt: number
198
+ }
199
+
200
+ export type TreeState = {
201
+ treeId: string
202
+ root?: string
203
+ /** All sessions the plugin knows about, keyed by sessionID (root included, as a degenerate branch-less entry only if it was itself forked from). */
204
+ sessions: Record<string, BranchState>
205
+ /** All crops ever applied, keyed by a synthesized cropID (index-based, stable within one fold). */
206
+ crops: Record<string, CropState>
207
+ /** Bookmarks, keyed by messageID. */
208
+ labels: Record<string, { sessionID: string; messageID: string; label: string }>
209
+ /** Decision records, keyed by messageID. */
210
+ decisions: Record<string, DecisionState>
211
+ /** Active (non-restored) crops for a session, in application order. Bound to this state. */
212
+ activeCrops: (sessionID: string) => CropState[]
213
+ }
214
+
215
+ function emptyTree(treeId: string): Omit<TreeState, "activeCrops"> {
216
+ return { treeId, root: undefined, sessions: {}, crops: {}, labels: {}, decisions: {} }
217
+ }
218
+
219
+ /** cropID is not carried on `crop.applied` itself — DESIGN.md's `crop.restored` refers to it by
220
+ * the id of the applying journal entry, since a crop is uniquely identified by the event that
221
+ * created it. */
222
+ function cropIdFor(entry: Extract<JournalEntry, { type: "crop.applied" }>): string {
223
+ return entry.id
224
+ }
225
+
226
+ /**
227
+ * Fold an ordered list of journal entries into the current tree state. Pure and
228
+ * deterministic: folding the same entries in the same order always yields the same
229
+ * result, and folding is idempotent when an entry set is re-folded (e.g. after
230
+ * appending more lines, refold from scratch — this function does not do incremental
231
+ * folding itself, callers may cache on top of it).
232
+ */
233
+ export function foldJournal(entries: JournalEntry[], treeId = "default"): TreeState {
234
+ const state = emptyTree(treeId)
235
+
236
+ for (const entry of entries) {
237
+ switch (entry.type) {
238
+ case "tree.created": {
239
+ state.root = entry.data.rootSessionID
240
+ break
241
+ }
242
+ case "branch.opened": {
243
+ const d = entry.data
244
+ const previous = state.sessions[d.sessionID]
245
+ // both halves adopt native forks independently, so the same branch can be opened
246
+ // twice; the first entry wins (re-opening a *closed* branch still folds)
247
+ if (previous?.status === "open") break
248
+ if (previous?.decisionMessageID) {
249
+ const decision = state.decisions[previous.decisionMessageID]
250
+ if (decision) decision.hidden = true
251
+ }
252
+ state.sessions[d.sessionID] = {
253
+ sessionID: d.sessionID,
254
+ parentSessionID: d.parentSessionID,
255
+ anchorMessageID: d.anchorMessageID,
256
+ name: d.name,
257
+ kind: d.kind,
258
+ trunkModel: d.trunkModel,
259
+ branchModel: d.branchModel,
260
+ model: d.branchModel,
261
+ status: "open",
262
+ forgotten: false,
263
+ }
264
+ break
265
+ }
266
+ case "branch.closed": {
267
+ const branch = state.sessions[entry.data.sessionID]
268
+ if (branch) {
269
+ branch.status = entry.data.status
270
+ branch.decisionMessageID = entry.data.decisionMessageID
271
+ branch.note = entry.data.note
272
+ if (entry.data.decisionMessageID) {
273
+ const decision = state.decisions[entry.data.decisionMessageID]
274
+ if (decision) decision.hidden = false
275
+ }
276
+ }
277
+ break
278
+ }
279
+ case "summary.recorded": {
280
+ // Summaries do not change tree shape; they are surfaced by the caller via the
281
+ // raw entry list if needed (e.g. to render a ◇ marker). Nothing to fold here.
282
+ break
283
+ }
284
+ case "decision.recorded": {
285
+ const d = entry.data
286
+ state.decisions[d.messageID] = {
287
+ sessionID: d.sessionID,
288
+ messageID: d.messageID,
289
+ forkSessionID: d.forkSessionID,
290
+ branchName: d.branchName,
291
+ siblings: d.siblings,
292
+ text: d.text,
293
+ hidden: false,
294
+ recordedAt: entry.ts,
295
+ }
296
+ break
297
+ }
298
+ case "crop.applied": {
299
+ const cropID = cropIdFor(entry)
300
+ state.crops[cropID] = {
301
+ cropID,
302
+ sessionID: entry.data.sessionID,
303
+ mode: entry.data.mode,
304
+ targets: entry.data.targets,
305
+ anchorMessageID: entry.data.anchorMessageID,
306
+ restored: false,
307
+ }
308
+ break
309
+ }
310
+ case "crop.restored": {
311
+ const crop = state.crops[entry.data.cropID]
312
+ if (crop) crop.restored = true
313
+ break
314
+ }
315
+ case "label.set": {
316
+ const d = entry.data
317
+ if (d.label === null) {
318
+ delete state.labels[d.messageID]
319
+ } else {
320
+ state.labels[d.messageID] = { sessionID: d.sessionID, messageID: d.messageID, label: d.label }
321
+ }
322
+ break
323
+ }
324
+ case "session.forgotten": {
325
+ const branch = state.sessions[entry.data.sessionID]
326
+ if (branch) branch.forgotten = true
327
+ break
328
+ }
329
+ }
330
+ }
331
+
332
+ return {
333
+ ...state,
334
+ activeCrops: (sessionID: string) => activeCrops(state as TreeState, sessionID),
335
+ }
336
+ }
337
+
338
+ /** Active (non-restored) crops for a given session, in application order. */
339
+ export function activeCrops(state: TreeState, sessionID: string): CropState[] {
340
+ return Object.values(state.crops)
341
+ .filter((c) => c.sessionID === sessionID && !c.restored)
342
+ .sort((a, b) => (a.cropID < b.cropID ? -1 : a.cropID > b.cropID ? 1 : 0))
343
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * DSH-style timeline lanes (DESIGN.md §7.1, §7.3): one column per unit of the
3
+ * chosen mode, three series — Input (context size at each assistant turn), Model
4
+ * (output tokens per assistant step), Tools (result size per tool call, errors
5
+ * flagged). Pure.
6
+ */
7
+ import { estimateTokens } from "./tokens.js"
8
+ import type { Transcript, TranscriptMessage } from "./transcript.js"
9
+
10
+ export type LaneMode = "turns" | "calls" | "duration"
11
+
12
+ export type LaneColumn = {
13
+ /** identifies what the column represents, for cursor mirroring */
14
+ messageID: string
15
+ /** the column's user message, so the cursor also mirrors ● rows (turns/duration mode) */
16
+ userMessageID?: string
17
+ partID?: string
18
+ turn: number
19
+ input: number
20
+ output: number
21
+ tool: number
22
+ toolError: boolean
23
+ /** wall-clock span of the column, ms (duration mode) */
24
+ ms: number
25
+ }
26
+
27
+ export type Lanes = { mode: LaneMode; columns: LaneColumn[] }
28
+
29
+ type Turn = { user?: TranscriptMessage; assistants: TranscriptMessage[]; index: number }
30
+
31
+ function turnsOf(messages: TranscriptMessage[]): Turn[] {
32
+ const out: Turn[] = []
33
+ let index = 0
34
+ for (const m of messages) {
35
+ if (m.role === "user") out.push({ user: m, assistants: [], index: ++index })
36
+ else {
37
+ // after a compaction the transcript opens with an assistant summary: it gets turn 0
38
+ if (out.length === 0) out.push({ assistants: [], index: 0 })
39
+ out[out.length - 1]!.assistants.push(m)
40
+ }
41
+ }
42
+ return out
43
+ }
44
+
45
+ function spanOf(m: TranscriptMessage): number {
46
+ const end = m.time.completed ?? m.parts.reduce((e, p) => Math.max(e, p.state?.time?.end ?? p.time?.end ?? 0), 0)
47
+ return end > m.time.created ? end - m.time.created : 0
48
+ }
49
+
50
+ export function buildLanes(transcript: Transcript, mode: LaneMode): Lanes {
51
+ const columns: LaneColumn[] = []
52
+ for (const turn of turnsOf(transcript.messages)) {
53
+ if (mode === "calls") {
54
+ let any = false
55
+ for (const m of turn.assistants) {
56
+ for (const p of m.parts) {
57
+ if (p.type !== "tool") continue
58
+ any = true
59
+ const out = p.state?.output ?? ""
60
+ const t = p.state?.time
61
+ columns.push({ messageID: m.id, userMessageID: turn.user?.id, partID: p.id, turn: turn.index, input: m.tokens?.input ?? 0, output: 0, tool: estimateTokens(out), toolError: p.state?.status === "error", ms: t?.start !== undefined && t?.end !== undefined ? t.end - t.start : 0 })
62
+ }
63
+ }
64
+ if (!any) {
65
+ const last = turn.assistants.at(-1)
66
+ columns.push({ messageID: last?.id ?? turn.user?.id ?? "", userMessageID: turn.user?.id, turn: turn.index, input: last?.tokens?.input ?? 0, output: last?.tokens?.output ?? 0, tool: 0, toolError: false, ms: last ? spanOf(last) : 0 })
67
+ }
68
+ continue
69
+ }
70
+ // turns / duration: one column per user turn
71
+ const last = turn.assistants.at(-1)
72
+ let tool = 0
73
+ let toolError = false
74
+ let ms = 0
75
+ let output = 0
76
+ for (const m of turn.assistants) {
77
+ output += m.tokens?.output ?? 0
78
+ ms += spanOf(m)
79
+ for (const p of m.parts) {
80
+ if (p.type !== "tool") continue
81
+ tool += estimateTokens(p.state?.output ?? "")
82
+ if (p.state?.status === "error") toolError = true
83
+ }
84
+ }
85
+ columns.push({ messageID: last?.id ?? turn.user?.id ?? "", userMessageID: turn.user?.id, turn: turn.index, input: last?.tokens?.input ?? 0, output, tool, toolError, ms })
86
+ }
87
+ return { mode, columns }
88
+ }
89
+
90
+ const BLOCKS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"]
91
+
92
+ /** `scale` (the model's context limit for the Input lane) fixes the reference height, so a
93
+ * two-message session no longer draws a full bar next to `ctx 100 · low`. */
94
+ export function sparkline(values: number[], width: number, scale?: number): string {
95
+ if (values.length === 0 || width <= 0) return ""
96
+ const cells = fitColumns(values, width)
97
+ const max = Math.max(1, scale ?? 0, ...cells)
98
+ return cells.map((v) => (v <= 0 ? " " : BLOCKS[Math.min(7, Math.floor((v / max) * 7.999))]!)).join("")
99
+ }
100
+
101
+ /** Resample `values` to exactly `width` cells (max-pooling when shrinking, repeating when growing). */
102
+ export function fitColumns(values: number[], width: number): number[] {
103
+ if (values.length === 0) return []
104
+ if (values.length === width) return values.slice()
105
+ const out: number[] = []
106
+ if (values.length > width) {
107
+ const per = values.length / width
108
+ for (let i = 0; i < width; i++) {
109
+ const a = Math.floor(i * per)
110
+ const b = Math.max(a + 1, Math.floor((i + 1) * per))
111
+ out.push(Math.max(...values.slice(a, b)))
112
+ }
113
+ return out
114
+ }
115
+ const rep = width / values.length
116
+ for (let i = 0; i < width; i++) out.push(values[Math.min(values.length - 1, Math.floor(i / rep))]!)
117
+ return out
118
+ }
119
+
120
+ /** Duration mode: repeat each column proportionally to its wall-clock share. */
121
+ export function durationWeighted(lanes: Lanes, width: number): { input: number[]; output: number[]; tool: number[]; toolError: boolean[]; columnAt: (cell: number) => number } {
122
+ const total = lanes.columns.reduce((s, c) => s + Math.max(1, c.ms), 0) || 1
123
+ const input: number[] = []
124
+ const output: number[] = []
125
+ const tool: number[] = []
126
+ const toolError: boolean[] = []
127
+ const owner: number[] = []
128
+ lanes.columns.forEach((c, i) => {
129
+ const cells = Math.max(1, Math.round((Math.max(1, c.ms) / total) * width))
130
+ for (let k = 0; k < cells; k++) {
131
+ input.push(c.input)
132
+ output.push(c.output)
133
+ tool.push(c.tool)
134
+ toolError.push(c.toolError)
135
+ owner.push(i)
136
+ }
137
+ })
138
+ return { input, output, tool, toolError, columnAt: (cell) => owner[Math.min(owner.length - 1, Math.max(0, cell))] ?? 0 }
139
+ }
140
+
141
+ /** Index of the column that contains a given message/part (for the cursor marker). */
142
+ export function columnFor(lanes: Lanes, messageID: string, partID?: string): number {
143
+ if (partID) {
144
+ const i = lanes.columns.findIndex((c) => c.partID === partID)
145
+ if (i >= 0) return i
146
+ }
147
+ const i = lanes.columns.findIndex((c) => c.messageID === messageID || c.userMessageID === messageID)
148
+ return i >= 0 ? i : -1
149
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Pure row-selection and expand/filter helpers for the tree + trajectory route
3
+ * (DESIGN.md §7). Operates on a `Row[]`/`TreeView` built by `core/tree.ts`.
4
+ *
5
+ * Pure, no OpenCode/opentui/solid-js imports — see test/core-purity.test.ts.
6
+ */
7
+ import type { Filter, Row, TreeView } from "./tree.js"
8
+
9
+ /** Move the selection by `delta` rows, clamped to the row list's bounds.
10
+ * Returns -1 for an empty row list. */
11
+ export function moveSelection(rows: Row[], index: number, delta: number): number {
12
+ if (rows.length === 0) return -1
13
+ const next = index + delta
14
+ if (next < 0) return 0
15
+ if (next > rows.length - 1) return rows.length - 1
16
+ return next
17
+ }
18
+
19
+ /** The next/previous `branch` row from `index` in direction `dir` (DESIGN.md §7.2's
20
+ * gutter jump). Stays put if there is no branch row in that direction. */
21
+ export function nextBranchIndex(rows: Row[], index: number, dir: 1 | -1): number {
22
+ if (rows.length === 0) return index
23
+ let i = index
24
+ for (let step = 0; step < rows.length; step++) {
25
+ i += dir
26
+ if (i < 0 || i >= rows.length) return index
27
+ if (rows[i]!.kind === "branch") return i
28
+ }
29
+ return index
30
+ }
31
+
32
+ /** Toggle one sessionID's membership in the `expanded` set, returning a new Set
33
+ * (DESIGN.md §7's `e`/`→` expand, `←` fold). */
34
+ export function toggleExpanded(expanded: Set<string>, sessionID: string): Set<string> {
35
+ const next = new Set(expanded)
36
+ if (next.has(sessionID)) next.delete(sessionID)
37
+ else next.add(sessionID)
38
+ return next
39
+ }
40
+
41
+ const FILTER_ORDER: Filter[] = ["default", "no-tools", "user-only", "labeled", "all"]
42
+
43
+ /** `f` cycles `default → no-tools → user-only → labeled → all → default …`
44
+ * (DESIGN.md §7.5). */
45
+ export function cycleFilter(filter: Filter): Filter {
46
+ const idx = FILTER_ORDER.indexOf(filter)
47
+ return FILTER_ORDER[(idx + 1) % FILTER_ORDER.length]!
48
+ }
49
+
50
+ /**
51
+ * Resolve which row index should carry the selection after the view was rebuilt
52
+ * (a filter/search/expand change, a journal refold, …):
53
+ * 1. `preferredId` (the previously-selected row's id) if it is still present.
54
+ * 2. Failing that, the nearest surviving row for the same message — a step row's
55
+ * id is `sessionID:messageID:partID`, so its owning turn (`sessionID:messageID`)
56
+ * is a reasonable "nearest" landing spot when the step itself was filtered out.
57
+ * 2b. `previousIndex`, clamped, when given — keeps the cursor where it was.
58
+ * 3. `currentRowId` (or, failing that, `view.currentRowId`) — the "you are here" row.
59
+ * 4. The first row, or -1 if the view is empty.
60
+ */
61
+ export function resolveSelection(
62
+ view: TreeView,
63
+ preferredId: string | undefined,
64
+ currentRowId: string | undefined,
65
+ previousIndex?: number,
66
+ ): number {
67
+ if (view.rows.length === 0) return -1
68
+
69
+ if (preferredId !== undefined) {
70
+ const exact = view.indexById[preferredId]
71
+ if (exact !== undefined) return exact
72
+
73
+ const owner = preferredId.split(":").slice(0, 2).join(":")
74
+ for (let i = 0; i < view.rows.length; i++) {
75
+ const row = view.rows[i]!
76
+ const rowOwner = row.kind === "branch" ? row.id : `${row.sessionID}:${row.messageID}`
77
+ if (rowOwner === owner) return i
78
+ }
79
+ }
80
+
81
+ // 2b. the row that now sits where the old selection was (filter/fold changed the list)
82
+ if (previousIndex !== undefined && previousIndex >= 0) return Math.min(previousIndex, view.rows.length - 1)
83
+
84
+ if (currentRowId !== undefined) {
85
+ const idx = view.indexById[currentRowId]
86
+ if (idx !== undefined) return idx
87
+ }
88
+
89
+ if (view.currentRowId !== undefined) {
90
+ const idx = view.indexById[view.currentRowId]
91
+ if (idx !== undefined) return idx
92
+ }
93
+
94
+ return 0
95
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Token estimation and context-size bands (DESIGN.md §3.3, §6.7).
3
+ *
4
+ * Pure, no OpenCode/opentui imports — see test/core-purity.test.ts.
5
+ */
6
+
7
+ /** chars/4 heuristic used throughout the design for anything not yet costed by the model. */
8
+ export function estimateTokens(text: string): number {
9
+ if (!text) return 0
10
+ return Math.ceil(text.length / 4)
11
+ }
12
+
13
+ export type MinimalPart = {
14
+ type: string
15
+ text?: string
16
+ tool?: string
17
+ state?: {
18
+ status?: string
19
+ input?: unknown
20
+ output?: string
21
+ title?: string
22
+ }
23
+ }
24
+
25
+ export type MinimalAssistantInfo = {
26
+ role: "assistant"
27
+ tokens?: { input?: number; output?: number; reasoning?: number; cache?: { read?: number; write?: number } }
28
+ }
29
+
30
+ export type MinimalMessageInfo = MinimalAssistantInfo | { role: "user" | "system" }
31
+
32
+ export type MinimalMessage = {
33
+ info: MinimalMessageInfo
34
+ parts: MinimalPart[]
35
+ }
36
+
37
+ /** Rough text content of a part, for chars/4 estimation of anything newer than the last assistant turn. */
38
+ function partText(part: MinimalPart): string {
39
+ if (part.type === "text" || part.type === "reasoning") return part.text ?? ""
40
+ if (part.type === "tool") {
41
+ const input = part.state?.input !== undefined ? JSON.stringify(part.state.input) : ""
42
+ const output = typeof part.state?.output === "string" ? part.state.output : ""
43
+ return `${part.tool ?? ""} ${input} ${output}`
44
+ }
45
+ return ""
46
+ }
47
+
48
+ /**
49
+ * Context size of a session's message list: the last assistant turn's real
50
+ * `tokens.input`, plus a chars/4 estimate of everything the next request will add on
51
+ * top of it — that turn's own output and tool results included (DESIGN.md §3.3 /
52
+ * §6.7). Returns `{ tokens, estimated }`; `estimated` is true whenever any part of
53
+ * the figure is a chars/4 guess.
54
+ */
55
+ export function contextSizeOf(messages: MinimalMessage[]): { tokens: number; estimated: boolean } {
56
+ let lastAssistantIndex = -1
57
+ let lastAssistantInput = 0
58
+ for (let i = 0; i < messages.length; i++) {
59
+ const info = messages[i]!.info
60
+ if (info.role === "assistant" && typeof info.tokens?.input === "number") {
61
+ lastAssistantIndex = i
62
+ lastAssistantInput = info.tokens.input
63
+ }
64
+ }
65
+
66
+ if (lastAssistantIndex === -1) {
67
+ // No costed assistant turn yet: everything is an estimate.
68
+ let estimated = 0
69
+ for (const message of messages) {
70
+ for (const part of message.parts) estimated += estimateTokens(partText(part))
71
+ }
72
+ return { tokens: estimated, estimated: true }
73
+ }
74
+
75
+ // starts AT the last assistant: its `tokens.input` is the context it was *given*, so its
76
+ // own output and tool results are only in the context from the next request on.
77
+ let newer = 0
78
+ for (let i = lastAssistantIndex; i < messages.length; i++) {
79
+ for (const part of messages[i]!.parts) newer += estimateTokens(partText(part))
80
+ }
81
+
82
+ return { tokens: lastAssistantInput + newer, estimated: newer > 0 }
83
+ }
84
+
85
+ export type ContextBand = "low" | "healthy" | "filling" | "red"
86
+
87
+ /** Absolute bands from DESIGN.md §6.7: <8k low · 8-32k healthy · 32-64k filling · >=64k red. */
88
+ export function bandFor(tokens: number): ContextBand {
89
+ if (tokens < 8_000) return "low"
90
+ if (tokens < 32_000) return "healthy"
91
+ if (tokens < 64_000) return "filling"
92
+ return "red"
93
+ }
94
+
95
+ /** `12345` → `12.3k`, `800` → `800`. */
96
+ export function formatK(tokens: number): string {
97
+ if (tokens < 1000) return String(tokens)
98
+ const k = (tokens / 1000).toFixed(1)
99
+ return `${k.endsWith(".0") ? k.slice(0, -2) : k}k`
100
+ }
101
+
102
+ /** The one context string every surface shows: `ctx ~2.3k/32.8k · low` (`~` when estimated). */
103
+ export function formatContext(size: { tokens: number; estimated: boolean }, limit?: number): string {
104
+ return `ctx ${size.estimated ? "~" : ""}${formatK(size.tokens)}${limit ? `/${formatK(limit)}` : ""} · ${bandFor(size.tokens)}`
105
+ }