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
|
@@ -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
|
+
}
|