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,421 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ /**
3
+ * TUI plugin half (DESIGN.md §3.2, §5, §7): `/tree` (aliases `/ctree`, `/panel`),
4
+ * `/branch`, `/label`, the `ctree` route, and the prompt-side gauge slot.
5
+ */
6
+ import type { TuiPluginApi, TuiPlugin } from "@opencode-ai/plugin/tui"
7
+ import { Show, createEffect, createMemo, createSignal, on } from "solid-js"
8
+ import { bandFor, contextSizeOf, formatContext, formatK, type MinimalMessage, type MinimalPart } from "../core/tokens.js"
9
+ import { JournalStore, type StorageMode } from "../shared/store.js"
10
+ import { debug } from "../shared/debug.js"
11
+ import { BRANCH_DIALOG, MERGE_TRUST, bumpJournal, clip, createNamedBranch, journalRevision, mergeBranch, mergeDialogOptions, mergeDialogTitle, setLabel, type MergeMode } from "./actions.js"
12
+ import { openSiblings } from "../core/decision.js"
13
+ import { hasEditor } from "./editor.js"
14
+ import { TreeRoute } from "./route.js"
15
+ import { parseForkTitle } from "../core/adopt.js"
16
+ import { adoptNativeForks } from "../shared/adopt.js"
17
+ import { fetchTranscript, modelContextLimit } from "./transcripts.js"
18
+
19
+ const BAND_COLOR = { low: "success", healthy: "success", filling: "warning", red: "error" } as const
20
+
21
+ type Options = { storage: StorageMode; jumpSummary: "ask" | "never"; hardCrop: boolean; keybinds: Record<string, string[]>; open: string[] }
22
+
23
+ /** `"k,up"` or `["k","up"]` → `["k","up"]`; `"none"`/`false` → `[]`. */
24
+ function keys(v: unknown): string[] | undefined {
25
+ if (v === undefined) return undefined
26
+ if (v === false || v === "none") return []
27
+ if (Array.isArray(v)) return v.filter((x): x is string => typeof x === "string")
28
+ if (typeof v === "string") return v.split(",").map((x) => x.trim()).filter(Boolean)
29
+ return undefined
30
+ }
31
+
32
+ function parseOptions(raw: Record<string, unknown> | undefined): Options {
33
+ const kb = (raw?.["keybinds"] as Record<string, unknown> | undefined) ?? {}
34
+ const keybinds: Record<string, string[]> = {}
35
+ for (const [name, v] of Object.entries(kb)) {
36
+ const k = keys(v)
37
+ if (k) keybinds[name] = k
38
+ }
39
+ return {
40
+ storage: raw?.["storage"] === "global" ? "global" : "local",
41
+ jumpSummary: raw?.["jumpSummary"] === "never" ? "never" : "ask",
42
+ hardCrop: raw?.["hardCrop"] === true,
43
+ keybinds,
44
+ open: keys(kb["open"]) ?? ["ctrl+q"],
45
+ }
46
+ }
47
+
48
+ function toMinimalMessages(messages: readonly any[], part: (messageID: string) => readonly any[]): MinimalMessage[] {
49
+ return messages.map((m) => ({
50
+ info: m.role === "assistant" ? { role: "assistant", tokens: m.tokens } : { role: m.role },
51
+ parts: part(m.id).map(
52
+ (p): MinimalPart => ({
53
+ type: p.type,
54
+ text: p.type === "text" || p.type === "reasoning" ? p.text : undefined,
55
+ tool: p.type === "tool" ? p.tool : undefined,
56
+ state: p.type === "tool" ? { status: p.state?.status, input: p.state?.input, output: p.state?.output } : undefined,
57
+ }),
58
+ ),
59
+ }))
60
+ }
61
+
62
+ /** A branch's display name: adopted native forks carry no journal `name`, so fall back to
63
+ * the session's own title (DESIGN.md §4.1's `kind: "native"`). */
64
+ function branchLabel(api: TuiPluginApi, sessionID: string, name: string | undefined, max?: number): string {
65
+ const label = name ?? api.state.session.get(sessionID)?.title ?? "branch"
66
+ return max === undefined ? label : clip(label, max)
67
+ }
68
+
69
+ /** OpenCode's sidebar is narrow; anything longer wraps and orphans the tail of the line. */
70
+ const CARD_COLUMNS = 28
71
+
72
+ function currentSession(api: TuiPluginApi): string | undefined {
73
+ const cur = api.route.current
74
+ return cur.name === "session" ? ((cur.params as { sessionID?: string } | undefined)?.sessionID ?? undefined) : undefined
75
+ }
76
+
77
+ const tui: TuiPlugin = async (api, rawOptions) => {
78
+ const options = parseOptions(rawOptions as Record<string, unknown> | undefined)
79
+ const directory = api.state.path.directory
80
+ const store = new JournalStore({ worktree: api.state.path.worktree || directory, stateDir: api.state.path.state, mode: options.storage })
81
+ debug("tui.loaded", { path: api.state.path, options })
82
+
83
+ // Native `/fork` sessions are invisible to the journal until adopted; `adopted` lets an
84
+ // open route know a branch appeared (DESIGN.md §4.1's `kind: "native"`).
85
+ const [adopted, setAdopted] = createSignal(0)
86
+ const adopt = async () => {
87
+ const found = await adoptNativeForks({
88
+ store,
89
+ directory,
90
+ actor: "tui",
91
+ listSessions: async () => {
92
+ const res = await api.client.session.list({ directory })
93
+ return ((res.data as any[]) ?? []).map((s) => ({ id: s.id as string, title: (s.title as string) ?? "", created: (s.time?.created as number) ?? 0, parentID: s.parentID as string | undefined, directory: s.directory as string | undefined }))
94
+ },
95
+ messagesOf: async (sessionID) => (await fetchTranscript(api, sessionID, directory)).messages.map((m) => ({ id: m.id, role: m.role, created: m.time.created })),
96
+ })
97
+ // the server half may have adopted first: refresh either way so the card and route re-read
98
+ setAdopted((n) => n + 1)
99
+ bumpJournal()
100
+ return found
101
+ }
102
+
103
+ /** The `session.created` event fires before the fork's messages are copied, so wait, then retry. */
104
+ const adoptSoon = async () => {
105
+ for (let attempt = 0; attempt < 3; attempt++) {
106
+ await new Promise((r) => setTimeout(r, 1000))
107
+ if ((await adopt()).length > 0) return
108
+ }
109
+ }
110
+
111
+ const offCreated = api.event.on("session.created", (event) => {
112
+ const info = event.properties.info
113
+ if (!info.parentID && parseForkTitle(info.title ?? "")) void adoptSoon()
114
+ })
115
+ api.lifecycle?.onDispose(offCreated)
116
+
117
+ const promptDialog = (title: string, placeholder?: string) =>
118
+ new Promise<string | undefined>((resolve) => {
119
+ api.ui.dialog.replace(
120
+ () =>
121
+ api.ui.DialogPrompt({
122
+ title,
123
+ placeholder,
124
+ onConfirm: (v) => {
125
+ debug("prompt.confirm", { v })
126
+ resolve(v)
127
+ api.ui.dialog.clear()
128
+ },
129
+ onCancel: () => {
130
+ debug("prompt.cancel")
131
+ resolve(undefined)
132
+ api.ui.dialog.clear()
133
+ },
134
+ }),
135
+ () => {
136
+ debug("prompt.close")
137
+ resolve(undefined)
138
+ },
139
+ )
140
+ })
141
+
142
+ // Palette `run` handlers must return synchronously: an awaited promise keeps the palette
143
+ // open, and its own close then clears any dialog we opened. Fire-and-forget instead.
144
+ const detached = (fn: () => Promise<void>) => () => {
145
+ void fn().catch((e) => api.ui.toast({ variant: "error", message: e instanceof Error ? e.message : String(e) }))
146
+ }
147
+
148
+ api.keymap.registerLayer({
149
+ commands: [
150
+ {
151
+ namespace: "palette",
152
+ name: "ctree.open",
153
+ title: "Context tree",
154
+ description: "Tree + trajectory of this session",
155
+ category: "Context",
156
+ slashName: "tree",
157
+ slashAliases: ["ctree", "panel"],
158
+ run: () => {
159
+ const sessionID = currentSession(api)
160
+ void adopt()
161
+ api.route.navigate("ctree", sessionID ? { sessionID } : {})
162
+ api.ui.dialog.clear()
163
+ },
164
+ },
165
+ {
166
+ namespace: "palette",
167
+ name: "ctree.branch",
168
+ title: "Branch here",
169
+ description: "Fork the current session into a named branch",
170
+ category: "Context",
171
+ slashName: "branch",
172
+ enabled: () => Boolean(currentSession(api)),
173
+ run: detached(async () => {
174
+ const sessionID = currentSession(api)
175
+ if (!sessionID) return
176
+ await new Promise((r) => setTimeout(r, 30))
177
+ const name = await promptDialog(BRANCH_DIALOG.title, BRANCH_DIALOG.placeholder)
178
+ debug("branch.named", { name })
179
+ if (!name) return
180
+ try {
181
+ await createNamedBranch({ api, store, directory }, { sessionID, name })
182
+ } catch (e) {
183
+ api.ui.toast({ variant: "error", message: `branch: ${e instanceof Error ? e.message : String(e)}` })
184
+ }
185
+ }),
186
+ },
187
+ {
188
+ namespace: "palette",
189
+ name: "ctree.label",
190
+ title: "Label this point",
191
+ description: "Bookmark the last message of the session",
192
+ category: "Context",
193
+ slashName: "label",
194
+ enabled: () => Boolean(currentSession(api)),
195
+ run: detached(async () => {
196
+ const sessionID = currentSession(api)
197
+ if (!sessionID) return
198
+ await new Promise((r) => setTimeout(r, 30))
199
+ const last = api.state.session.messages(sessionID).at(-1)
200
+ if (!last) return
201
+ const value = await promptDialog("Label (empty to remove)", "checkpoint")
202
+ if (value === undefined) return
203
+ setLabel({ api, store, directory }, { sessionID, messageID: last.id, label: value.trim() || null })
204
+ api.ui.toast({ variant: "success", message: value.trim() ? `labelled: ${value.trim()}` : "label removed" })
205
+ }),
206
+ },
207
+ ],
208
+ bindings: options.open.map((key) => ({ key, cmd: "ctree.open" })),
209
+ })
210
+
211
+ api.keymap.registerLayer({
212
+ commands: [
213
+ {
214
+ namespace: "palette",
215
+ name: "ctree.merge",
216
+ title: "Merge branch",
217
+ description: "Close this branch: squash to a ◆ decision record, discard, or tournament",
218
+ category: "Context",
219
+ slashName: "merge",
220
+ enabled: () => Boolean(currentSession(api)),
221
+ run: detached(async () => {
222
+ const sessionID = currentSession(api)
223
+ if (!sessionID) return
224
+ await new Promise((r) => setTimeout(r, 30))
225
+ const state = store.stateForSession(sessionID)
226
+ const branch = state?.sessions[sessionID]
227
+ if (!state || !branch || branch.status !== "open") {
228
+ api.ui.toast({ message: "not on an open branch — /branch first" })
229
+ return
230
+ }
231
+ const mode = await new Promise<MergeMode | undefined>((resolve) => {
232
+ api.ui.dialog.replace(
233
+ () =>
234
+ api.ui.DialogSelect<MergeMode>({
235
+ title: mergeDialogTitle(branch.name ?? "branch", api.state.session.get(branch.parentSessionID)?.title),
236
+ options: mergeDialogOptions({ siblings: openSiblings(state, sessionID).length }),
237
+ onSelect: (o) => {
238
+ resolve(o.value)
239
+ api.ui.dialog.clear()
240
+ },
241
+ }),
242
+ () => resolve(undefined),
243
+ )
244
+ })
245
+ if (!mode) return
246
+ const inApp = !hasEditor()
247
+ ? async (draft: string) =>
248
+ new Promise<string | undefined>((resolve) => {
249
+ api.ui.dialog.replace(
250
+ () =>
251
+ api.ui.DialogConfirm({
252
+ title: "Accept the drafted record as-is?",
253
+ message: `${draft.slice(0, 400)}${draft.length > 400 ? "…" : ""}\n\n${MERGE_TRUST}`,
254
+ onConfirm: () => {
255
+ resolve(draft)
256
+ api.ui.dialog.clear()
257
+ },
258
+ onCancel: () => {
259
+ resolve(undefined)
260
+ api.ui.dialog.clear()
261
+ },
262
+ }),
263
+ () => resolve(undefined),
264
+ )
265
+ })
266
+ : undefined
267
+ try {
268
+ await mergeBranch({ api, store, directory }, { sessionID, mode, confirm: inApp })
269
+ } catch (e) {
270
+ api.ui.toast({ variant: "error", message: `merge: ${e instanceof Error ? e.message : String(e)}` })
271
+ }
272
+ }),
273
+ },
274
+ {
275
+ namespace: "palette",
276
+ name: "ctree.decisions",
277
+ title: "Decisions",
278
+ description: "◆ decision records on this tree",
279
+ category: "Context",
280
+ slashName: "decisions",
281
+ run: () => {
282
+ const sessionID = currentSession(api)
283
+ api.route.navigate("ctree", sessionID ? { sessionID, view: "decisions" } : { view: "decisions" })
284
+ api.ui.dialog.clear()
285
+ },
286
+ },
287
+ ],
288
+ })
289
+
290
+ api.route.register([
291
+ {
292
+ name: "ctree",
293
+ render: ({ params }) => (
294
+ <TreeRoute
295
+ api={api}
296
+ store={store}
297
+ directory={directory}
298
+ sessionID={params?.["sessionID"] as string | undefined}
299
+ refresh={adopted}
300
+ options={{ jumpSummary: options.jumpSummary, hardCrop: options.hardCrop, keybinds: options.keybinds }}
301
+ initialView={params?.["view"] === "decisions" ? "decisions" : "tree"}
302
+ />
303
+ ),
304
+ },
305
+ ])
306
+
307
+ api.slots.register({
308
+ slots: {
309
+ sidebar_content: (_ctx, props: { session_id: string }) => {
310
+ const t = api.theme.current
311
+ // the journal is plain files: without the revision the card would render once per session
312
+ const st = createMemo(() => {
313
+ journalRevision()
314
+ return store.stateForSession(props.session_id)
315
+ })
316
+ const branch = () => st()?.sessions[props.session_id]
317
+ const crops = () => st()?.activeCrops(props.session_id) ?? []
318
+ const hidden = () => crops().reduce((s, c) => s + c.targets.reduce((x, y) => x + y.estTokens, 0), 0)
319
+ const siblings = () => Object.values(st()?.sessions ?? {}).filter((b) => b.parentSessionID === props.session_id && b.status === "open").length
320
+ // status and parent go on their own line: a branch name long enough to wrap used to
321
+ // leave "· open" orphaned underneath it
322
+ const status = () => {
323
+ const b = branch()!
324
+ const title = api.state.session.get(b.parentSessionID)?.title
325
+ const room = CARD_COLUMNS - b.status.length - 10
326
+ return `${b.status}${title && room > 3 ? ` · from "${clip(title, room)}"` : ""}`
327
+ }
328
+ return (
329
+ <box flexDirection="column">
330
+ <text fg={t.text}>
331
+ <b>Context tree</b>
332
+ </text>
333
+ <Show when={branch()} fallback={<text fg={t.text}>{`trunk${siblings() ? ` · ${siblings()} branch${siblings() === 1 ? "" : "es"}` : ""}`}</text>}>
334
+ <text fg={t.success}>{`⎇ ${branchLabel(api, props.session_id, branch()!.name, CARD_COLUMNS - 2)}`}</text>
335
+ <text fg={t.textMuted}>{status()}</text>
336
+ </Show>
337
+ <Show when={crops().length}>
338
+ <text fg={t.warning}>{`✂ ${crops().length} crop${crops().length === 1 ? "" : "s"} · ~${formatK(hidden())} hidden`}</text>
339
+ </Show>
340
+ <text fg={t.textMuted}>/tree · ctrl+q</text>
341
+ </box>
342
+ )
343
+ },
344
+ session_prompt_right: (_ctx, props: { session_id: string }) => {
345
+ const t = api.theme.current
346
+ const size = createMemo(() => contextSizeOf(toMinimalMessages(api.state.session.messages(props.session_id), api.state.part)))
347
+ const band = () => bandFor(size().tokens)
348
+ const branch = () => {
349
+ journalRevision() // the journal is plain files: without the revision `⎇ name` never refreshes
350
+ return store.stateForSession(props.session_id)?.sessions[props.session_id]
351
+ }
352
+ // model context limit + compaction reserve, for the guard (DESIGN.md §6.7)
353
+ const limit = createMemo(() => modelContextLimit(api, props.session_id))
354
+ const reserve = () => (api.state.config as { compaction?: { reserved?: number } }).compaction?.reserved ?? 16_384
355
+ // trend + attribution: an effect compares each new size with the previous one
356
+ // (side effects and closure state stay out of the memo graph)
357
+ const [trend, setTrend] = createSignal("")
358
+ let prevTokens = 0
359
+ let prevParts = new Map<string, number>()
360
+ let redNudged = false
361
+ let guardNudged = false
362
+ // the slot is reused across sessions: carrying this over reports a bogus trend and
363
+ // swallows the first red/guard nudge of the session we just moved to
364
+ createEffect(
365
+ on(
366
+ () => props.session_id,
367
+ () => {
368
+ prevTokens = 0
369
+ prevParts = new Map()
370
+ redNudged = false
371
+ guardNudged = false
372
+ setTrend("")
373
+ },
374
+ { defer: true },
375
+ ),
376
+ )
377
+ const partSizes = createMemo(() => {
378
+ const parts = new Map<string, { len: number; key: string }>()
379
+ for (const m of api.state.session.messages(props.session_id)) {
380
+ for (const p of api.state.part(m.id) as unknown as { id: string; type: string; tool?: string; text?: string; state?: { output?: string } }[]) {
381
+ const len = p.type === "tool" ? (p.state?.output?.length ?? 0) : (p.text?.length ?? 0)
382
+ parts.set(p.id, { len, key: p.type === "tool" ? (p.tool ?? "tool") : p.type === "text" ? "text" : p.type })
383
+ }
384
+ }
385
+ return parts
386
+ })
387
+ createEffect(() => {
388
+ const now = size().tokens
389
+ const parts = partSizes()
390
+ let biggest: { key: string; delta: number } | undefined
391
+ for (const [id, { len, key }] of parts) {
392
+ const delta = len - (prevParts.get(id) ?? 0)
393
+ if (delta > 0 && (!biggest || delta > biggest.delta)) biggest = { key, delta }
394
+ }
395
+ const rise = prevTokens > 0 ? (now - prevTokens) / prevTokens : 0
396
+ if (now !== prevTokens) setTrend(rise >= 0.1 && biggest ? ` ▲ +${Math.round(rise * 100)}% (${biggest.key})` : "")
397
+ prevTokens = now
398
+ prevParts = new Map([...parts].map(([id, v]) => [id, v.len]))
399
+ })
400
+ createEffect(() => {
401
+ const b = band()
402
+ if (b === "red" && !redNudged) {
403
+ redNudged = true
404
+ api.ui.toast({ variant: "warning", message: "context is in the red band (≥64k) — consider /tree → c crop, or /merge a branch", duration: 6000 })
405
+ } else if (b === "low" || b === "healthy") redNudged = false
406
+ const lim = limit()
407
+ if (lim && size().tokens >= lim - reserve() && !guardNudged) {
408
+ guardNudged = true
409
+ api.ui.toast({ variant: "error", message: "OpenCode will auto-compact soon (lossy). Crop or merge first if you want to keep the source material.", duration: 8000 })
410
+ } else if (lim && size().tokens < lim - reserve() * 2) guardNudged = false
411
+ })
412
+ return (
413
+ // the same string the tree header shows, so both surfaces read identically
414
+ <text fg={t[BAND_COLOR[band()]]}>{`${branch() ? `⎇ ${branchLabel(api, props.session_id, branch()!.name, 24)} · ` : ""}${formatContext(size(), limit())}${trend()}`}</text>
415
+ )
416
+ },
417
+ },
418
+ })
419
+ }
420
+
421
+ export default { id: "opencode-context-tree", tui }