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,1058 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ /**
3
+ * The combined tree + trajectory route (DESIGN.md §7). Pure view model from core,
4
+ * OpenCode data through the adapters, actions through ./actions.
5
+ */
6
+ import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
7
+ import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
8
+ import { planJump } from "../core/actions.js"
9
+ import { foldJournal, type TreeState } from "../core/journal.js"
10
+ import { cycleFilter, moveSelection, nextBranchIndex, resolveSelection, toggleExpanded } from "../core/navigation.js"
11
+ import { bandFor, contextSizeOf, formatContext, formatK, type MinimalMessage } from "../core/tokens.js"
12
+ import { buildSpineMap, buildTreeView, currentChainOf, type Filter, type Row } from "../core/tree.js"
13
+ import type { Transcript } from "../core/transcript.js"
14
+ import type { JournalStore } from "../shared/store.js"
15
+ import { applyCrop, BRANCH_DIALOG, createNamedBranch, executeJump, executeUndo, mergeBranch, mergeDialogOptions, mergeDialogTitle, MERGE_TRUST, setLabel, type ActionContext, type MergeMode, type SummaryChoice } from "./actions.js"
16
+ import { exportDecisions } from "../core/decision.js"
17
+ import { buildLanes, columnFor, durationWeighted, fitColumns, sparkline, type LaneMode } from "../core/lanes.js"
18
+ import { bar, consumers } from "../core/consumers.js"
19
+ import { hasEditor } from "./editor.js"
20
+ import fs from "node:fs"
21
+ import path from "node:path"
22
+ import { autoMark, planResultCrop, planTurnCrops, reclaimed, resultCandidates, turnCandidates, type ResultCandidate, type TurnCandidate } from "../core/cropplan.js"
23
+ import { planUndo } from "../core/undo.js"
24
+ import { fetchTranscript, liveTranscript, modelContextLimit } from "./transcripts.js"
25
+ import { debug } from "../shared/debug.js"
26
+
27
+ export type TreeRouteProps = {
28
+ api: TuiPluginApi
29
+ store: JournalStore
30
+ directory: string
31
+ sessionID?: string
32
+ options: { jumpSummary: "ask" | "never"; hardCrop?: boolean; keybinds?: Record<string, string[]> }
33
+ /** Bumped by the host when native forks were adopted, so the open route refolds. */
34
+ refresh?: () => number
35
+ /** open directly on a secondary view */
36
+ initialView?: "tree" | "decisions"
37
+ }
38
+
39
+ const BAND_KEY = { low: "success", healthy: "success", filling: "warning", red: "error" } as const
40
+
41
+ function statusColor(t: TuiPluginApi["theme"]["current"], status: Row & { kind: "branch" }): unknown {
42
+ switch (status.status) {
43
+ case "open":
44
+ return t.success
45
+ case "squashed":
46
+ return t.info
47
+ case "rejected":
48
+ case "discarded":
49
+ return t.error
50
+ default:
51
+ return t.textMuted
52
+ }
53
+ }
54
+
55
+ /** Row previews come from message text: markdown emphasis is noise at one line. */
56
+ function plain(text: string): string {
57
+ return text.replace(/\*\*|`/g, "")
58
+ }
59
+
60
+ function fitRow(body: string, tokens: string, width: number): string {
61
+ const room = Math.max(10, width - tokens.length - 2)
62
+ const clipped = body.length > room ? `${body.slice(0, room - 1)}…` : body.padEnd(room)
63
+ return `${clipped} ${tokens}`
64
+ }
65
+
66
+ /** Display glyph, from the row's semantic flags rather than the stored glyph. */
67
+ function glyphOf(row: Exclude<Row, { kind: "branch" }>): string {
68
+ if (row.kind === "turn") return row.isDecision ? "◆" : row.isSummary ? "≣" : "●"
69
+ if (row.glyph === "⚙") return "⚙"
70
+ if (row.glyph === "◇") return "≣" // OpenCode-native compaction summary
71
+ return "○"
72
+ }
73
+
74
+ /** Content-forward row text — Pi's outline × DSH's trajectory: `user:` / `assistant:` inline,
75
+ * tool steps as `[bash $ …]` / `[tool: arg] → out` (from partPreview), decisions/summaries
76
+ * labelled. The gutter (drawn separately) carries the tree structure, not fixed columns. */
77
+ function textOf(row: Exclude<Row, { kind: "branch" }>): string {
78
+ if (row.kind === "turn") {
79
+ if (row.isDecision) return plain(row.preview).replace(/^◆\s*/, "").replace(/^#+\s*/, "")
80
+ if (row.isSummary) return plain(row.preview)
81
+ return `user: ${plain(row.preview)}`
82
+ }
83
+ if (row.glyph === "⚙" || row.glyph === "◇") return plain(row.preview)
84
+ return `assistant: ${plain(row.preview)}`
85
+ }
86
+
87
+ function rowLine(row: Row, width: number, here: boolean): string {
88
+ const tokens = `${row.kind !== "branch" && row.estimated ? "~" : ""}${formatK(row.tokens)}`
89
+ const marker = here ? " ← here" : ""
90
+ let body: string
91
+ if (row.kind === "branch") {
92
+ const model = row.model ? ` · ${row.model.split("/").pop()}` : ""
93
+ const fold = row.expanded ? "▾" : "▸"
94
+ const turns = `${row.turns} turn${row.turns === 1 ? "" : "s"}`
95
+ // nothing to expand yet: a ▸ caret here reads as a broken/empty branch
96
+ const meta = row.turns === 0 ? `${row.status} · just branched, nothing here yet` : `${fold} ${row.status} · ${turns}${model}`
97
+ body = `${row.gutter} ${row.name} ${meta}${marker}`
98
+ } else {
99
+ const flags =
100
+ row.kind === "step"
101
+ ? `${row.label ? ` [${row.label}]` : ""}${row.isCropped ? " ✂" : ""}${row.warn ? " ⚠" : ""}${row.isError ? " ✗" : ""}`
102
+ : row.label
103
+ ? ` [${row.label}]`
104
+ : ""
105
+ const dur = row.kind === "step" && row.durationMs !== undefined ? ` ${(row.durationMs / 1000).toFixed(row.durationMs < 10_000 ? 1 : 0)}s` : ""
106
+ body = `${row.gutter}${glyphOf(row)} ${textOf(row)}${flags}${dur}${marker}`
107
+ }
108
+ return fitRow(body, tokens, width)
109
+ }
110
+
111
+ const DEFAULT_KEYS: Record<string, string[]> = {
112
+ up: ["up", "k"],
113
+ down: ["down", "j"],
114
+ jump_up: ["shift+up", "shift+k"],
115
+ jump_down: ["shift+down", "shift+j"],
116
+ first: ["g"],
117
+ last: ["shift+g"],
118
+ prev_branch: ["["],
119
+ next_branch: ["]"],
120
+ fold: ["left", "h"],
121
+ unfold: ["right", "l"],
122
+ toggle: ["e"],
123
+ go: ["return"],
124
+ branch: ["b"],
125
+ crop: ["c"],
126
+ crop_toggle_mode: ["t"],
127
+ mark: ["space"],
128
+ auto: ["a"],
129
+ undo: ["x"],
130
+ merge: ["m"],
131
+ inspector: ["i"],
132
+ consumers: ["u"],
133
+ copy: ["y"],
134
+ mode_duration: ["1"],
135
+ mode_turns: ["2"],
136
+ mode_calls: ["3"],
137
+ lanes_off: ["0"],
138
+ decisions: ["shift+d"],
139
+ export: ["shift+e"],
140
+ label: ["shift+l"],
141
+ filter: ["f"],
142
+ search: ["/"],
143
+ // terminals disagree on whether "?" carries the shift flag, so bind both spellings
144
+ help: ["?", "shift+/"],
145
+ back: ["q", "escape"],
146
+ }
147
+
148
+ const NO_BRANCHES = "No branches yet · b forks here into a real OpenCode session; nothing is copied or deleted."
149
+
150
+ /** The `?` overlay: unindented lines are headings, indented ones body (see the render). */
151
+ const HELP = [
152
+ "? help · ? or esc closes",
153
+ "Reading the screen — a whole-tree outline (Pi) fused with a trajectory (DSH)",
154
+ " the entire tree, oldest first: trunk at the left, branches nested at their fork point",
155
+ " ● user: … · ○ assistant: … · ⚙ [bash $ …] / [tool: arg] → out · ◆ decision · ≣ summary",
156
+ " ⎇ = a branch: a real, separate OpenCode session, hung off the message it forked from",
157
+ " │ ├ ╰ connectors draw the branch topology; ← here marks the session you are in",
158
+ " ▾ open / ▸ folded — the path to where you are is open by default; → ← (or e) toggle a branch",
159
+ " right column is tokens; ~ means estimated · ⚠ ≥10k · ✂ cropped · ✗ tool error",
160
+ "Keys",
161
+ " move ↑↓ j k · J K by 20 · g G first/last · [ ] branch rows · → ← e fold",
162
+ " act ⏎ go/switch · b branch · m merge · c crop · x undo · L label · y copy",
163
+ " in crop mode: space mark · a auto · t result⇄turn · ⏎ apply · esc leave",
164
+ " DSH i inspector (Status/Payload/Result/Timing) · 1 2 3 lanes (Duration/Turns/Calls) · 0 off",
165
+ " views u consumers · D decisions · E export · f filter · / search · q back",
166
+ ]
167
+
168
+ /** Plugin option `keybinds: { <command>: "k,up" | [..] | "none" }` overrides DEFAULT_KEYS. */
169
+ function bindingsFor(overrides: Record<string, string[]> | undefined) {
170
+ const out: { key: string; cmd: string }[] = []
171
+ for (const [cmd, keys] of Object.entries(DEFAULT_KEYS)) for (const key of overrides?.[cmd] ?? keys) out.push({ key, cmd: `ctree.${cmd}` })
172
+ return out
173
+ }
174
+
175
+ export function TreeRoute(props: TreeRouteProps) {
176
+ const { api, store, directory } = props
177
+ const sessionID = props.sessionID
178
+ const ctx: ActionContext = { api, store, directory }
179
+ const t = api.theme.current
180
+
181
+ const [tick, setTick] = createSignal(0)
182
+ const bump = () => setTick((n) => n + 1)
183
+ createEffect(on(() => props.refresh?.(), () => bump(), { defer: true }))
184
+ const [expanded, setExpanded] = createSignal<Set<string>>(new Set(api.kv.get<string[]>(`ctree.expanded.${sessionID}`, [])))
185
+ const [filter, setFilter] = createSignal<Filter>(api.kv.get<Filter>("ctree.filter", "default"))
186
+ const [search, setSearch] = createSignal("")
187
+ const [selected, setSelected] = createSignal(0)
188
+ const [others, setOthers] = createSignal<Record<string, Transcript>>({})
189
+ const [busy, setBusy] = createSignal<string | undefined>()
190
+ const [cropMode, setCropMode] = createSignal<"result" | "turn" | undefined>()
191
+ const [panel, setPanel] = createSignal<"tree" | "decisions" | "consumers" | "help">(props.initialView ?? "tree")
192
+ const [laneMode, setLaneMode] = createSignal<LaneMode>(api.kv.get<LaneMode>("ctree.lanes", "turns"))
193
+ // DSH lanes and inspector are first-class but off by default, so the first screen reads as
194
+ // Pi's clean outline (header + tree + footer); `1/2/3` and `i` bring them in, one keystroke.
195
+ const [lanesOn, setLanesOn] = createSignal<boolean>(api.kv.get<boolean>("ctree.lanesOn", false))
196
+ const [inspector, setInspector] = createSignal<boolean>(api.kv.get<boolean>("ctree.inspector", false))
197
+ const [consumerIndex, setConsumerIndex] = createSignal(0)
198
+ const [decisionIndex, setDecisionIndex] = createSignal(0)
199
+ const [marked, setMarked] = createSignal<Set<string>>(new Set())
200
+
201
+ const state = createMemo<TreeState>(() => {
202
+ tick()
203
+ return (sessionID && store.stateForSession(sessionID)) || foldJournal([], "none")
204
+ })
205
+
206
+ // Sessions in the tree other than the current one, through the SDK. Closed/forgotten
207
+ // branches are immutable, so they are fetched once; open ones are refreshed per tick.
208
+ // A sequence number drops responses that were overtaken by a newer run.
209
+ let fetchSeq = 0
210
+ createEffect(
211
+ on([state, tick], async () => {
212
+ if (!sessionID) return
213
+ const seq = ++fetchSeq
214
+ const st = state()
215
+ const ids = new Set<string>()
216
+ for (const id of Object.keys(st.sessions)) ids.add(id)
217
+ for (const b of Object.values(st.sessions)) ids.add(b.parentSessionID)
218
+ if (st.root) ids.add(st.root)
219
+ ids.delete(sessionID)
220
+ const cached = others()
221
+ const onPath = new Set(currentChainOf(st, sessionID))
222
+ const wanted = [...ids].filter((id) => {
223
+ const tr = cached[id]
224
+ // uncached, or an open (still-mutable) branch: always (re)fetch
225
+ if (!tr || (st.sessions[id]?.status ?? "open") === "open") return true
226
+ // a closed on-path ancestor whose single fetch failed comes back status:"deleted"; without
227
+ // this it would never retry and its rows would stay missing from the tree (core keeps the
228
+ // current session visible meanwhile). Retry while it is still missing/deleted.
229
+ return onPath.has(id) && tr.status === "deleted"
230
+ })
231
+ if (wanted.length === 0) return
232
+ const loaded = await Promise.all(wanted.map((id) => fetchTranscript(api, id, directory)))
233
+ if (seq !== fetchSeq) return
234
+ setOthers((prev) => ({ ...prev, ...Object.fromEntries(loaded.map((tr) => [tr.sessionID, tr])) }))
235
+ }),
236
+ )
237
+
238
+ const transcripts = createMemo(() => (sessionID ? { ...others(), [sessionID]: liveTranscript(api, sessionID) } : {}))
239
+ const spine = createMemo(() => buildSpineMap({ state: state(), transcripts: transcripts(), currentSessionID: sessionID ?? "" }))
240
+
241
+ const view = createMemo(() => {
242
+ if (!sessionID) return { rows: [] as Row[], indexById: {}, currentRowId: undefined, totalTokens: 0, totalEstimated: false }
243
+ const st = state()
244
+ const labels: Record<string, string> = {}
245
+ for (const l of Object.values(st.labels)) labels[l.messageID] = l.label
246
+ // crop targets are recorded with the current session's ids; prefix rows carry the
247
+ // ancestor's, so translate through the spine map before the view compares them
248
+ const crops = st.activeCrops(sessionID).flatMap((c) =>
249
+ c.targets.map((x) => {
250
+ if (x.partID) {
251
+ const owner = spine().partFromCurrent(x.messageID, x.partID)
252
+ return owner ? { messageID: owner.messageID, partID: owner.partID } : { messageID: x.messageID, partID: x.partID }
253
+ }
254
+ const owner = spine().fromCurrent(x.messageID)
255
+ return { messageID: owner?.messageID ?? x.messageID, partID: undefined }
256
+ }),
257
+ )
258
+ return buildTreeView({
259
+ state: st,
260
+ transcripts: transcripts(),
261
+ currentSessionID: sessionID,
262
+ expanded: expanded(),
263
+ filter: filter(),
264
+ search: search() || undefined,
265
+ labels,
266
+ crops,
267
+ })
268
+ })
269
+
270
+ // Keep the cursor sensible when the list is rebuilt.
271
+ let lastId: string | undefined
272
+ let initialised = false
273
+ createEffect(
274
+ on(view, (v) => {
275
+ if (!initialised) {
276
+ initialised = true
277
+ setSelected(resolveSelection(v, undefined, v.currentRowId))
278
+ } else {
279
+ setSelected(resolveSelection(v, lastId, v.currentRowId, selected()))
280
+ }
281
+ lastId = v.rows[selected()]?.id
282
+ }),
283
+ )
284
+ createEffect(() => {
285
+ lastId = view().rows[selected()]?.id
286
+ })
287
+
288
+ const current = () => view().rows[selected()]
289
+ // renderer.width/height are plain fields, so the terminal size only relayouts if we listen
290
+ const terminal = () => api.renderer as unknown as { width?: number; height?: number }
291
+ const [size, setSize] = createSignal({ cols: terminal().width ?? 120, rows: terminal().height ?? 30 })
292
+ const onResize = () => setSize({ cols: terminal().width ?? 120, rows: terminal().height ?? 30 })
293
+ api.renderer.on("resize", onResize)
294
+ onCleanup(() => void api.renderer.off("resize", onResize))
295
+ const cols = () => size().cols
296
+ // chrome above/below the rows: padding, header, status, footer (+3 lane lines when lanes are on)
297
+ const height = () => Math.max(8, size().rows - 8 - (lanesOn() && size().rows >= 12 ? 3 : 0))
298
+ const width = () => Math.max(60, cols() - 4)
299
+ const windowStart = createMemo(() => {
300
+ const h = height()
301
+ const s = selected()
302
+ const n = view().rows.length
303
+ const start = Math.max(0, Math.min(s - Math.floor(h / 2), n - h))
304
+ return start
305
+ })
306
+ const visible = createMemo(() => view().rows.slice(windowStart(), windowStart() + height()))
307
+
308
+ // ---- crop mode -----------------------------------------------------------
309
+ // Crops act on the *current* session's context. Spine rows above the fork point carry
310
+ // the ancestor's message IDs, but the current session holds a positional copy of that
311
+ // prefix; the spine map (built from unfiltered transcripts) translates both ways.
312
+ const live = () => (sessionID ? liveTranscript(api, sessionID) : undefined)
313
+ const currentMessageOf = (row: Row): string | undefined => {
314
+ if (row.kind === "branch") return undefined
315
+ if (row.sessionID === sessionID) return row.messageID
316
+ return spine().toCurrent(row.sessionID, row.messageID)
317
+ }
318
+ const currentPartOf = (row: Row & { kind: "step" }): string | undefined => {
319
+ if (row.sessionID === sessionID) return row.partID
320
+ return spine().partToCurrent(row.sessionID, row.messageID, row.partID)
321
+ }
322
+ const alreadyCropped = createMemo(() => {
323
+ const set = new Set<string>()
324
+ if (!sessionID) return set
325
+ for (const c of state().activeCrops(sessionID)) for (const t of c.targets) set.add(t.partID ?? t.messageID)
326
+ return set
327
+ })
328
+ const resultCands = createMemo(() => (live() ? resultCandidates(live()!, { alreadyCropped: alreadyCropped() }) : []))
329
+ const turnCands = createMemo(() => (live() ? turnCandidates(live()!, { alreadyDropped: alreadyCropped() }) : []))
330
+ const candidateOf = (row: Row): ResultCandidate | TurnCandidate | undefined => {
331
+ const mode = cropMode()
332
+ if (!mode) return undefined
333
+ if (mode === "result") {
334
+ if (row.kind !== "step" || row.glyph !== "⚙") return undefined
335
+ const pid = currentPartOf(row)
336
+ return resultCands().find((c) => c.partID === pid)
337
+ }
338
+ const mid = currentMessageOf(row)
339
+ return turnCands().find((c) => c.anchorMessageID === mid)
340
+ }
341
+ const markKey = (c: ResultCandidate | TurnCandidate) => (c.kind === "result" ? c.partID : c.anchorMessageID)
342
+ const selectedCandidates = createMemo(() => {
343
+ const m = marked()
344
+ const list: (ResultCandidate | TurnCandidate)[] = cropMode() === "result" ? resultCands() : turnCands()
345
+ return list.filter((c) => m.has(markKey(c)))
346
+ })
347
+
348
+ function toggleMark() {
349
+ const row = current()
350
+ if (!row) return
351
+ const c = candidateOf(row)
352
+ debug("crop.mark", { row: row.id, candidate: c ? { kind: c.kind, protections: c.protections } : undefined, marked: [...marked()] })
353
+ if (!c) {
354
+ api.ui.toast({ message: cropMode() === "result" ? "select a tool result row" : "select a turn row" })
355
+ return
356
+ }
357
+ const hard = c.protections.filter((p) => p !== "too-small")
358
+ const next = new Set(marked())
359
+ const key = markKey(c)
360
+ if (next.has(key)) {
361
+ next.delete(key)
362
+ next.delete(`${key}:warned`) // unmarking forgets the warning, so re-marking asks again
363
+ } else if (hard.length && !(next.has(`${key}:warned`))) {
364
+ next.add(`${key}:warned`)
365
+ api.ui.toast({ variant: "warning", message: `protected (${hard.join(", ")}) — press space again to mark anyway` })
366
+ } else next.add(key)
367
+ setMarked(next)
368
+ }
369
+
370
+ function autoMarkAll() {
371
+ if (cropMode() !== "result") return
372
+ const picks = autoMark(resultCands())
373
+ setMarked(new Set(picks.map((c) => c.partID)))
374
+ api.ui.toast({ message: picks.length ? `auto-marked ${picks.length} result${picks.length === 1 ? "" : "s"} (≥10k tokens, older than 2 turns)` : "nothing matches the auto rules" })
375
+ }
376
+
377
+ async function applyMarked() {
378
+ if (!sessionID) return
379
+ const picks = selectedCandidates()
380
+ debug("crop.apply", { picks: picks.length, marked: [...marked()] })
381
+ if (picks.length === 0) {
382
+ api.ui.toast({ message: "nothing marked — space marks a row, a auto-marks" })
383
+ return
384
+ }
385
+ // count the plans, not the marks: planTurnCrops refuses the current turn (cropplan.ts)
386
+ const result = cropMode() === "result"
387
+ const plans = result ? [planResultCrop(sessionID, picks as ResultCandidate[])].filter((p) => p !== undefined) : planTurnCrops(sessionID, picks as TurnCandidate[])
388
+ const n = result ? (plans[0]?.targets.length ?? 0) : plans.length
389
+ if (n === 0) {
390
+ api.ui.toast({ message: "nothing to crop — the current turn always stays in context" })
391
+ return
392
+ }
393
+ const total = plans.reduce((s, p) => s + p.targets.reduce((x, t) => x + t.estTokens, 0), 0)
394
+ const ok = await confirm(`Crop ${n} ${result ? "result" : "turn"}${n === 1 ? "" : "s"}?`, `~${formatK(total)} tokens leave the model's context on the next turn. Your transcript is never rewritten; the model just stops seeing these. /undo restores.`)
395
+ if (!ok) return
396
+ await guarded("crop", async () => {
397
+ for (const plan of plans) await applyCrop(ctx, plan, { hard: result && Boolean(props.options.hardCrop) })
398
+ api.ui.toast({ variant: "success", message: `✂ cropped ${n} · ~${formatK(total)} reclaimed` })
399
+ setMarked(new Set<string>())
400
+ setCropMode(undefined)
401
+ })
402
+ }
403
+
404
+ async function undo() {
405
+ if (!sessionID) return
406
+ const st = state()
407
+ const plan = planUndo(store.entriesFor(st.treeId), st, sessionID)
408
+ if (plan.kind === "nothing") {
409
+ api.ui.toast({ message: "nothing to undo on this path" })
410
+ return
411
+ }
412
+ const what =
413
+ plan.kind === "restore-crop" ? `restore the ${plan.mode === "turn" ? "dropped turn" : "cropped result"} (~${formatK(plan.estTokens)} tokens)` : plan.kind === "abandon-branch" ? `leave ⎇ ${plan.name ?? "this branch"} and return to its parent` : `re-open the ${plan.status} branch`
414
+ const ok = await confirm("Undo?", `This will ${what}. Nothing is deleted.`)
415
+ if (!ok) return
416
+ await guarded("undo", async () => {
417
+ await executeUndo(ctx, sessionID, plan)
418
+ })
419
+ }
420
+
421
+ // the same figure the prompt gauge shows: context of the session, not of the drawn rows
422
+ const contextSize = createMemo(() => contextSizeOf((live()?.messages ?? []).map((m): MinimalMessage => ({ info: m.role === "assistant" ? { role: "assistant", tokens: m.tokens } : { role: "user" }, parts: m.parts }))))
423
+ const band = () => bandFor(contextSize().tokens)
424
+ const branchOfCurrent = () => (sessionID ? state().sessions[sessionID] : undefined)
425
+ const userTurns = () => (live()?.messages ?? []).filter((m) => m.role === "user").length
426
+
427
+ // ---- lanes (minimap) -----------------------------------------------------
428
+ const lanes = createMemo(() => (live() ? buildLanes(live()!, laneMode() === "duration" ? "turns" : laneMode()) : { mode: laneMode(), columns: [] }))
429
+ const laneWidth = () => Math.max(10, Math.min(width() - 46, 80))
430
+ const laneSeries = createMemo(() => {
431
+ const l = lanes()
432
+ if (laneMode() === "duration") {
433
+ const w = durationWeighted(l, laneWidth())
434
+ return { input: w.input, output: w.output, tool: w.tool, toolError: w.toolError, cellFor: (col: number) => w.input.findIndex((_, i) => w.columnAt(i) === col) }
435
+ }
436
+ const n = l.columns.length
437
+ const cellFor = (col: number) => (n === 0 ? -1 : Math.floor((col * laneWidth()) / Math.max(n, laneWidth())) + (n < laneWidth() ? Math.floor(laneWidth() / n / 2) : 0))
438
+ return { input: l.columns.map((c) => c.input), output: l.columns.map((c) => c.output), tool: l.columns.map((c) => c.tool), toolError: l.columns.map((c) => c.toolError), cellFor }
439
+ })
440
+ const cursorCell = createMemo(() => {
441
+ const row = current()
442
+ if (!row || row.kind === "branch") return -1
443
+ // spine rows above the fork carry ancestor ids; map to the current session first
444
+ const mid = currentMessageOf(row) ?? row.messageID
445
+ const pid = row.kind === "step" ? (currentPartOf(row) ?? row.partID) : undefined
446
+ const col = columnFor(lanes(), mid, pid)
447
+ return col < 0 ? -1 : laneSeries().cellFor(col)
448
+ })
449
+ const laneLine = (values: number[], scale?: number) => {
450
+ const line = sparkline(fitColumns(values, laneWidth()), laneWidth(), scale)
451
+ const cur = cursorCell()
452
+ if (cur < 0 || cur >= line.length) return line
453
+ return `${line.slice(0, cur)}▮${line.slice(cur + 1)}`
454
+ }
455
+ /** Tool cells split into same-colour runs so errored calls draw red (DESIGN.md §7.1). */
456
+ const toolRuns = createMemo(() => {
457
+ const line = laneLine(laneSeries().tool)
458
+ const mask = fitColumns(laneSeries().toolError.map((e) => (e ? 1 : 0)), laneWidth())
459
+ const runs: { text: string; error: boolean }[] = []
460
+ for (let i = 0; i < line.length; i++) {
461
+ const error = (mask[i] ?? 0) > 0
462
+ const last = runs[runs.length - 1]
463
+ if (last && last.error === error) last.text += line[i]
464
+ else runs.push({ text: line[i]!, error })
465
+ }
466
+ return runs
467
+ })
468
+ // the Input lane is scaled against the context window, so a two-message session stays small
469
+ const contextLimit = createMemo(() => (sessionID ? modelContextLimit(api, sessionID) : undefined))
470
+ // under three turns every bar is either full or empty, which reads as "context full"
471
+ const laneRoom = () => height() >= 12 && panel() === "tree"
472
+ const showLanes = () => laneRoom() && lanesOn() && userTurns() >= 3
473
+ /** DESIGN.md §7.6: below 80 columns the minimap is the Input sparkline alone. */
474
+ const showAllLanes = () => cols() >= 80
475
+ /** `1/2/3` turn the DSH lanes on and pick the x-axis; the active one again (or `0`) hides them. */
476
+ function setLane(mode: LaneMode) {
477
+ if (lanesOn() && laneMode() === mode) {
478
+ setLanesOn(false)
479
+ api.kv.set("ctree.lanesOn", false)
480
+ return
481
+ }
482
+ setLaneMode(mode)
483
+ setLanesOn(true)
484
+ api.kv.set("ctree.lanes", mode)
485
+ api.kv.set("ctree.lanesOn", true)
486
+ }
487
+
488
+ // ---- inspector -----------------------------------------------------------
489
+ const showInspector = () => inspector() && panel() === "tree" && cols() >= 110
490
+ const inspectorWidth = () => Math.min(56, Math.max(36, Math.floor(width() * 0.4)))
491
+ const rowWidth = () => (showInspector() ? width() - inspectorWidth() - 2 : width()) - (cropMode() ? 4 : 0)
492
+ // wraps badly next to the inspector, so break it at the ";" rather than mid-clause
493
+ const noBranchesLines = () => (NO_BRANCHES.length + 2 <= rowWidth() ? [NO_BRANCHES] : NO_BRANCHES.split(/(?<=;) /))
494
+ const inspectorLines = createMemo((): { fg: unknown; text: string }[] => {
495
+ const row = current()
496
+ if (!row) return []
497
+ const w = inspectorWidth() - 3
498
+ const clip = (x: string) => (x.length > w ? `${x.slice(0, w - 1)}…` : x)
499
+ const out: { fg: unknown; text: string }[] = []
500
+ const head = (x: string) => out.push({ fg: t.primary, text: clip(x) })
501
+ const kv = (k: string, v: string) => out.push({ fg: t.text, text: clip(`${k.padEnd(10)}${v}`) })
502
+ const muted = (x: string) => out.push({ fg: t.textMuted, text: clip(x) })
503
+ const block = (label: string, text: string, max: number) => {
504
+ const lines = text.split("\n").filter((l) => l.length)
505
+ kv(label, lines[0] ?? "")
506
+ for (const l of lines.slice(1, max)) out.push({ fg: t.text, text: clip(` ${l}`) })
507
+ if (lines.length > max) muted(` … ${lines.length - max} more lines (y to copy)`)
508
+ }
509
+ if (row.kind === "branch") {
510
+ head(`⎇ ${row.name}`)
511
+ kv("Status", row.status)
512
+ if (row.note) muted(`note: ${row.note}`)
513
+ kv("Parent", others()[row.parentSessionID]?.title ?? row.parentSessionID)
514
+ kv("Anchor", row.anchorMessageID.slice(0, 20))
515
+ kv("Turns", String(row.turns))
516
+ kv("Tokens", `~${formatK(row.tokens)}`)
517
+ if (row.model) kv("Model", row.model)
518
+ muted(row.isCurrent ? "you are here" : row.expanded ? "← fold" : "→ expand · ⏎ switch to it")
519
+ return out
520
+ }
521
+ const tr = row.sessionID === sessionID ? live() : others()[row.sessionID]
522
+ const msg = tr?.messages.find((m) => m.id === row.messageID)
523
+ const turn = view().rows.slice(0, view().indexById[row.id]! + 1).filter((r) => r.kind === "turn").at(-1)
524
+ if (row.kind === "turn") {
525
+ head(`${row.isDecision ? "◆ decision" : row.isSummary ? "◇ summary" : "● user"} · T${row.turn}`)
526
+ if (row.label) kv("Label", row.label)
527
+ kv("Tokens", `~${formatK(row.tokens)}`)
528
+ kv("At", msg ? new Date(msg.time.created).toISOString().slice(11, 19) : "?")
529
+ block("Text", msg?.parts.map((p) => p.text ?? "").join("\n") ?? row.preview, 14)
530
+ return out
531
+ }
532
+ const part = msg?.parts.find((p) => p.id === row.partID)
533
+ const stepNo = msg ? msg.parts.filter((p) => p.type === "tool" || p.type === "text").findIndex((p) => p.id === row.partID) + 1 : 0
534
+ head(`${row.glyph} ${part?.type === "tool" ? part.tool : row.glyph === "◇" ? "compaction" : "assistant"} · T${turn?.kind === "turn" ? turn.turn : "?"} · step ${stepNo}`)
535
+ kv("Hierarchy", `T${turn?.kind === "turn" ? turn.turn : "?"} › assistant › step ${stepNo}`)
536
+ if (part?.type === "tool") {
537
+ const st = part.state
538
+ const dur = st?.time?.start !== undefined && st?.time?.end !== undefined ? `${st.time.end - st.time.start} ms` : "?"
539
+ kv("Status", `${st?.status ?? "?"} · ${dur}`)
540
+ kv("Tokens", `~${formatK(row.tokens)} · ${view().totalTokens ? `${((row.tokens / view().totalTokens) * 100).toFixed(1)}% of context` : ""}`)
541
+ block("Payload", JSON.stringify(st?.input ?? {}, null, 1), 8)
542
+ block("Result", String(st?.output ?? ""), 10)
543
+ kv("Timing", st?.time?.start ? `started ${new Date(st.time.start).toISOString().slice(11, 23)} · ${dur} · session ts` : "n/a")
544
+ const cand = resultCands().find((c) => c.partID === (currentPartOf(row) ?? row.partID))
545
+ kv("Crop", row.isCropped ? "✂ cropped (x to restore)" : cand ? (cand.protections.length ? `protected: ${cand.protections.join(", ")}` : "c then space to stub this result") : "n/a")
546
+ } else {
547
+ kv("Tokens", `~${formatK(row.tokens)}`)
548
+ if (row.durationMs !== undefined) kv("Duration", `${(row.durationMs / 1000).toFixed(1)} s`)
549
+ block("Text", part?.text ?? row.preview, 14)
550
+ }
551
+ return out
552
+ })
553
+
554
+ // ---- consumers -------------------------------------------------------------
555
+ const consumerRows = createMemo(() => (live() ? consumers(live()!, { cropped: alreadyCropped() }) : []))
556
+
557
+ /** From the consumers panel: back to the tree in crop mode with that source's
558
+ * unprotected results pre-marked (DESIGN.md §7.4). */
559
+ function cropConsumer() {
560
+ const c = consumerRows()[consumerIndex()]
561
+ setPanel("tree")
562
+ if (!c || c.kind !== "tool") {
563
+ setCropMode("result")
564
+ api.ui.toast({ message: c ? `${c.source} is not a tool result; mark rows by hand` : "nothing to crop" })
565
+ return
566
+ }
567
+ setCropMode("result")
568
+ const picks = resultCands().filter((r) => r.tool === c.source && r.protections.length === 0)
569
+ setMarked(new Set<string>(picks.map((r) => r.partID)))
570
+ api.ui.toast({ message: picks.length ? `marked ${picks.length} unprotected ${c.source} result${picks.length === 1 ? "" : "s"} — ⏎ to apply` : `every ${c.source} result is protected; mark with space (twice) to override` })
571
+ }
572
+
573
+ function copySelected() {
574
+ const row = current()
575
+ if (!row || row.kind === "branch") return
576
+ const tr = row.sessionID === sessionID ? live() : others()[row.sessionID]
577
+ const msg = tr?.messages.find((m) => m.id === row.messageID)
578
+ const text = row.kind === "step" ? String(msg?.parts.find((p) => p.id === row.partID)?.state?.output ?? msg?.parts.find((p) => p.id === row.partID)?.text ?? "") : (msg?.parts.map((p) => p.text ?? "").join("\n") ?? "")
579
+ const file = path.join(directory, ".opencode", "context-tree", "last-copy.txt")
580
+ try {
581
+ fs.mkdirSync(path.dirname(file), { recursive: true })
582
+ fs.writeFileSync(file, text)
583
+ api.ui.toast({ message: `saved ${text.length} chars → .opencode/context-tree/last-copy.txt` })
584
+ } catch (e) {
585
+ api.ui.toast({ variant: "error", message: String(e) })
586
+ }
587
+ }
588
+
589
+ function back() {
590
+ if (sessionID) api.route.navigate("session", { sessionID })
591
+ else api.route.navigate("home")
592
+ }
593
+
594
+ function askSummary(): Promise<SummaryChoice> {
595
+ if (props.options.jumpSummary === "never") return Promise.resolve({ kind: "none" })
596
+ return new Promise((resolve) => {
597
+ api.ui.dialog.replace(
598
+ () =>
599
+ api.ui.DialogSelect({
600
+ title: "Summarize the branch you are leaving?",
601
+ options: [
602
+ { title: "No summary", value: "none", description: "just move" },
603
+ { title: "Summarize", value: "summarize", description: "Pi-style Goal / Progress / Decisions / Next steps" },
604
+ { title: "Summarize with custom prompt", value: "custom" },
605
+ ],
606
+ onSelect: (o) => {
607
+ if (o.value === "custom") {
608
+ api.ui.dialog.replace(
609
+ () =>
610
+ api.ui.DialogPrompt({
611
+ title: "Custom summarization instructions",
612
+ placeholder: "focus on…",
613
+ onConfirm: (value) => {
614
+ resolve({ kind: "summarize", customInstructions: value || undefined })
615
+ api.ui.dialog.clear()
616
+ },
617
+ onCancel: () => {
618
+ resolve({ kind: "none" })
619
+ api.ui.dialog.clear()
620
+ },
621
+ }),
622
+ () => resolve({ kind: "none" }),
623
+ )
624
+ return
625
+ }
626
+ resolve(o.value === "summarize" ? { kind: "summarize" } : { kind: "none" })
627
+ api.ui.dialog.clear()
628
+ },
629
+ }),
630
+ () => resolve({ kind: "none" }),
631
+ )
632
+ })
633
+ }
634
+
635
+ function confirm(title: string, message: string): Promise<boolean> {
636
+ return new Promise((resolve) => {
637
+ api.ui.dialog.replace(
638
+ () =>
639
+ api.ui.DialogConfirm({
640
+ title,
641
+ message,
642
+ onConfirm: () => {
643
+ resolve(true)
644
+ api.ui.dialog.clear()
645
+ },
646
+ onCancel: () => {
647
+ resolve(false)
648
+ api.ui.dialog.clear()
649
+ },
650
+ }),
651
+ () => resolve(false),
652
+ )
653
+ })
654
+ }
655
+
656
+ function prompt(title: string, placeholder?: string, value?: string): Promise<string | undefined> {
657
+ return new Promise((resolve) => {
658
+ api.ui.dialog.replace(
659
+ () =>
660
+ api.ui.DialogPrompt({
661
+ title,
662
+ placeholder,
663
+ value,
664
+ onConfirm: (v) => {
665
+ resolve(v)
666
+ api.ui.dialog.clear()
667
+ },
668
+ onCancel: () => {
669
+ resolve(undefined)
670
+ api.ui.dialog.clear()
671
+ },
672
+ }),
673
+ () => resolve(undefined),
674
+ )
675
+ })
676
+ }
677
+
678
+ async function guarded(label: string, fn: () => Promise<void>) {
679
+ if (busy()) {
680
+ debug("route.busy", { label, busy: busy() })
681
+ return
682
+ }
683
+ setBusy(label)
684
+ try {
685
+ await fn()
686
+ } catch (e) {
687
+ api.ui.toast({ variant: "error", message: `${label}: ${e instanceof Error ? e.message : String(e)}` })
688
+ } finally {
689
+ setBusy(undefined)
690
+ bump()
691
+ }
692
+ }
693
+
694
+ async function jump() {
695
+ const row = current()
696
+ if (!row || !sessionID) return
697
+ const plan = planJump(row, { transcripts: transcripts(), currentSessionID: sessionID })
698
+ debug("route.jump", { row: { kind: row.kind, id: row.id }, plan })
699
+ await guarded("jump", async () => {
700
+ if (plan.kind === "noop") {
701
+ api.ui.toast({ message: plan.reason })
702
+ return
703
+ }
704
+ if (plan.kind === "fork" && plan.mode === "redo") {
705
+ const ok = await confirm("Redo this turn on a new branch?", "The message is copied into the prompt; nothing is deleted.")
706
+ if (!ok) return
707
+ }
708
+ const summary = await askSummary()
709
+ await executeJump(ctx, plan, { currentSessionID: sessionID, summary })
710
+ })
711
+ }
712
+
713
+ async function branch() {
714
+ if (!sessionID) return
715
+ const name = await prompt(BRANCH_DIALOG.title, BRANCH_DIALOG.placeholder)
716
+ if (!name) return
717
+ const model = await new Promise<string | undefined>((resolve) => {
718
+ api.ui.dialog.replace(
719
+ () =>
720
+ api.ui.DialogSelect({
721
+ title: BRANCH_DIALOG.modelTitle,
722
+ options: [
723
+ { title: "Keep the current model", value: "" },
724
+ { title: "Other… (provider/model)", value: "other" },
725
+ ],
726
+ onSelect: (o) => {
727
+ if (o.value === "other") {
728
+ api.ui.dialog.replace(
729
+ () =>
730
+ api.ui.DialogPrompt({
731
+ title: "provider/model",
732
+ placeholder: "anthropic/claude-haiku-4-5",
733
+ onConfirm: (v) => {
734
+ resolve(v || undefined)
735
+ api.ui.dialog.clear()
736
+ },
737
+ onCancel: () => {
738
+ resolve(undefined)
739
+ api.ui.dialog.clear()
740
+ },
741
+ }),
742
+ () => resolve(undefined),
743
+ )
744
+ return
745
+ }
746
+ resolve(undefined)
747
+ api.ui.dialog.clear()
748
+ },
749
+ }),
750
+ () => resolve(undefined),
751
+ )
752
+ })
753
+ const last = [...(api.state.session.messages(sessionID) as unknown as { role: string; providerID?: string; modelID?: string }[])].reverse().find((m) => m.role === "assistant")
754
+ const trunkModel = last?.providerID && last.modelID ? `${last.providerID}/${last.modelID}` : undefined
755
+ await guarded("branch", () => createNamedBranch(ctx, { sessionID, name, model, trunkModel }).then(() => undefined))
756
+ }
757
+
758
+ async function label() {
759
+ const row = current()
760
+ if (!row || row.kind === "branch") return
761
+ const st = state()
762
+ const existing = st.labels[row.messageID]?.label
763
+ const value = await prompt("Label (empty to remove)", "checkpoint", existing)
764
+ if (value === undefined) return
765
+ setLabel(ctx, { sessionID: row.sessionID, messageID: row.messageID, label: value.trim() ? value.trim() : null })
766
+ bump()
767
+ }
768
+
769
+ function foldOrUnfold(open: boolean) {
770
+ const row = current()
771
+ if (!row) return
772
+ const target = row.kind === "branch" ? row.sessionID : row.depth > 0 ? row.sessionID : undefined
773
+ if (!target) return
774
+ // the row's resolved state, not raw set membership: on-path branches start open, so
775
+ // `expanded` membership inverts there (see tree.ts shownExpanded). A visible nested row
776
+ // means its branch is shown; a branch row carries its resolved state in `expanded`.
777
+ const shown = row.kind === "branch" ? row.expanded : true
778
+ if (open === shown) return
779
+ const next = toggleExpanded(expanded(), target)
780
+ setExpanded(next)
781
+ api.kv.set(`ctree.expanded.${sessionID}`, [...next])
782
+ }
783
+
784
+ const decisions = createMemo(() =>
785
+ Object.values(state().decisions)
786
+ .sort((a, b) => a.recordedAt - b.recordedAt),
787
+ )
788
+
789
+ function select<T>(title: string, options: { title: string; value: T; description?: string }[]): Promise<T | undefined> {
790
+ return new Promise((resolve) => {
791
+ api.ui.dialog.replace(
792
+ () =>
793
+ api.ui.DialogSelect<T>({
794
+ title,
795
+ options,
796
+ onSelect: (o) => {
797
+ resolve(o.value)
798
+ api.ui.dialog.clear()
799
+ },
800
+ }),
801
+ () => resolve(undefined),
802
+ )
803
+ })
804
+ }
805
+
806
+ async function merge() {
807
+ if (!sessionID) return
808
+ const b = branchOfCurrent()
809
+ if (!b || b.status !== "open") {
810
+ api.ui.toast({ message: "not on an open branch — /branch first, or open the tree from a branch" })
811
+ return
812
+ }
813
+ const siblings = Object.values(state().sessions).filter((x) => x.parentSessionID === b.parentSessionID && x.sessionID !== sessionID && x.status === "open").length
814
+ const mode = await select<MergeMode>(mergeDialogTitle(b.name ?? "branch", others()[b.parentSessionID]?.title), mergeDialogOptions({ siblings }))
815
+ if (!mode) return
816
+ let note: string | undefined
817
+ if (mode === "discard") note = (await prompt("Why? (optional note on the close marker)", "dead end")) ?? undefined
818
+ const inApp = !hasEditor()
819
+ ? async (draft: string) => {
820
+ const ok = await confirm("Accept the drafted record as-is?", `${draft.slice(0, 400)}${draft.length > 400 ? "…" : ""}\n\n${MERGE_TRUST}\n\n(set $EDITOR to review it in your editor)`)
821
+ return ok ? draft : undefined
822
+ }
823
+ : undefined
824
+ await guarded("merge", async () => {
825
+ await mergeBranch(ctx, { sessionID, mode, note, confirm: inApp })
826
+ })
827
+ }
828
+
829
+ function exportDecisionsFile() {
830
+ const records = decisions().filter((d) => d.text).map((d) => ({ branchName: d.branchName, text: d.text!, sessionID: d.sessionID, at: d.recordedAt }))
831
+ const file = path.join(directory, "ctree-decisions.md")
832
+ try {
833
+ fs.writeFileSync(file, exportDecisions(records))
834
+ api.ui.toast({ variant: "success", message: `wrote ${records.length} record${records.length === 1 ? "" : "s"} → ${file}` })
835
+ } catch (e) {
836
+ api.ui.toast({ variant: "error", message: `export failed: ${e instanceof Error ? e.message : String(e)}` })
837
+ }
838
+ }
839
+
840
+ function jumpToDecision() {
841
+ const d = decisions()[decisionIndex()]
842
+ if (!d) return
843
+ const idx = view().rows.findIndex((r) => r.kind !== "branch" && r.messageID === d.messageID)
844
+ setPanel("tree")
845
+ if (idx >= 0) setSelected(idx)
846
+ else api.ui.toast({ message: "that record lives in another session" })
847
+ }
848
+
849
+ const off = api.keymap.registerLayer({
850
+ // OpenCode's own bare-letter layers do the same: dialogs push "modal", so without this
851
+ // typing "bash" into a prompt would fire b/a/s/h as route commands
852
+ mode: "base",
853
+ commands: [
854
+ { name: "ctree.up", hidden: true, run: () => (panel() === "decisions" ? setDecisionIndex((i) => Math.max(0, i - 1)) : panel() === "consumers" ? setConsumerIndex((i) => Math.max(0, i - 1)) : setSelected((i) => moveSelection(view().rows, i, -1))) },
855
+ { name: "ctree.down", hidden: true, run: () => (panel() === "decisions" ? setDecisionIndex((i) => Math.min(Math.max(0, decisions().length - 1), i + 1)) : panel() === "consumers" ? setConsumerIndex((i) => Math.min(Math.max(0, consumerRows().length - 1), i + 1)) : setSelected((i) => moveSelection(view().rows, i, 1))) },
856
+ { name: "ctree.jump_up", hidden: true, run: () => setSelected((i) => moveSelection(view().rows, i, -20)) },
857
+ { name: "ctree.jump_down", hidden: true, run: () => setSelected((i) => moveSelection(view().rows, i, 20)) },
858
+ { name: "ctree.first", hidden: true, run: () => setSelected(0) },
859
+ { name: "ctree.last", hidden: true, run: () => setSelected(Math.max(0, view().rows.length - 1)) },
860
+ { name: "ctree.prev_branch", hidden: true, run: () => setSelected((i) => nextBranchIndex(view().rows, i, -1)) },
861
+ { name: "ctree.next_branch", hidden: true, run: () => setSelected((i) => nextBranchIndex(view().rows, i, 1)) },
862
+ { name: "ctree.fold", hidden: true, run: () => foldOrUnfold(false) },
863
+ { name: "ctree.unfold", hidden: true, run: () => foldOrUnfold(true) },
864
+ { name: "ctree.toggle", hidden: true, run: () => foldOrUnfold(!(current()?.kind === "branch" && (current() as Row & { kind: "branch" }).expanded)) },
865
+ { name: "ctree.go", hidden: true, run: () => void (panel() === "decisions" ? jumpToDecision() : panel() === "consumers" ? cropConsumer() : cropMode() ? applyMarked() : jump()) },
866
+ { name: "ctree.branch", hidden: true, run: () => void branch() },
867
+ { name: "ctree.label", hidden: true, run: () => void label() },
868
+ {
869
+ name: "ctree.filter",
870
+ hidden: true,
871
+ run: () => {
872
+ const next = cycleFilter(filter())
873
+ setFilter(next)
874
+ api.kv.set("ctree.filter", next)
875
+ },
876
+ },
877
+ {
878
+ name: "ctree.search",
879
+ hidden: true,
880
+ run: () =>
881
+ void prompt("Search rows (empty to clear)", "bash, redis, label…", search()).then((v) => {
882
+ if (v !== undefined) setSearch(v.trim())
883
+ }),
884
+ },
885
+ {
886
+ name: "ctree.crop",
887
+ hidden: true,
888
+ run: () => {
889
+ if (panel() === "consumers") {
890
+ cropConsumer()
891
+ return
892
+ }
893
+ if (panel() !== "tree") return
894
+ if (cropMode()) {
895
+ setCropMode(undefined)
896
+ setMarked(new Set<string>())
897
+ } else setCropMode("result")
898
+ },
899
+ },
900
+ {
901
+ name: "ctree.crop_toggle_mode",
902
+ hidden: true,
903
+ run: () => {
904
+ if (!cropMode()) return
905
+ setCropMode(cropMode() === "result" ? "turn" : "result")
906
+ setMarked(new Set<string>())
907
+ },
908
+ },
909
+ { name: "ctree.mark", hidden: true, enabled: () => Boolean(cropMode()), run: () => toggleMark() },
910
+ { name: "ctree.auto", hidden: true, enabled: () => Boolean(cropMode()), run: () => autoMarkAll() },
911
+ { name: "ctree.undo", hidden: true, run: () => void undo() },
912
+ { name: "ctree.merge", hidden: true, run: () => void merge() },
913
+ { name: "ctree.inspector", hidden: true, run: () => { setInspector(!inspector()); api.kv.set("ctree.inspector", inspector()) } },
914
+ { name: "ctree.consumers", hidden: true, run: () => setPanel(panel() === "consumers" ? "tree" : "consumers") },
915
+ { name: "ctree.copy", hidden: true, run: () => copySelected() },
916
+ { name: "ctree.mode_duration", hidden: true, run: () => setLane("duration") },
917
+ { name: "ctree.mode_turns", hidden: true, run: () => setLane("turns") },
918
+ { name: "ctree.mode_calls", hidden: true, run: () => setLane("calls") },
919
+ { name: "ctree.lanes_off", hidden: true, run: () => { setLanesOn(false); api.kv.set("ctree.lanesOn", false) } },
920
+ { name: "ctree.decisions", hidden: true, run: () => setPanel(panel() === "decisions" ? "tree" : "decisions") },
921
+ { name: "ctree.export", hidden: true, enabled: () => panel() === "decisions", run: () => exportDecisionsFile() },
922
+ { name: "ctree.help", hidden: true, run: () => setPanel(panel() === "help" ? "tree" : "help") },
923
+ {
924
+ name: "ctree.back",
925
+ hidden: true,
926
+ run: () => {
927
+ if (panel() !== "tree") {
928
+ setPanel("tree")
929
+ return
930
+ }
931
+ if (cropMode()) {
932
+ setCropMode(undefined)
933
+ setMarked(new Set<string>())
934
+ return
935
+ }
936
+ back()
937
+ },
938
+ },
939
+ ],
940
+ bindings: bindingsFor(props.options.keybinds),
941
+ })
942
+ onCleanup(() => off())
943
+
944
+ const sessionTitle = () => (sessionID ? (api.state.session.get(sessionID)?.title ?? sessionID) : "no session")
945
+ /** The tree is titled by its trunk: a branch's own session title is just `⎇ <name>`. */
946
+ const title = () => {
947
+ const root = state().root
948
+ return (root && root !== sessionID ? others()[root]?.title : undefined) ?? sessionTitle()
949
+ }
950
+ const where = () => {
951
+ const b = branchOfCurrent()
952
+ if (!b) return "trunk"
953
+ return `⎇ ${b.name ?? sessionTitle()} (${b.status}${b.model ? ` · ${b.model.split("/").pop()}` : ""})`
954
+ }
955
+
956
+ return (
957
+ <box flexDirection="column" padding={1} backgroundColor={t.background} width="100%" height="100%">
958
+ <box flexDirection="row">
959
+ {/* one expression: JSX would trim the gap before the context string */}
960
+ <text fg={t.primary}>{`┌ Context tree · ${title()} · ${where()} `}</text>
961
+ <text fg={t[BAND_KEY[band()]]}>{formatContext(contextSize(), contextLimit())}</text>
962
+ </box>
963
+ <Show when={showLanes()}>
964
+ <text fg={t.info}>│ Input {laneLine(laneSeries().input, contextLimit())} {laneMode() === "duration" ? "[1] Duration" : " 1 duration"} · {laneMode() === "turns" ? "[2] Turns" : " 2 turns"} · {laneMode() === "calls" ? "[3] Calls" : " 3 calls"}</text>
965
+ <Show when={showAllLanes()}>
966
+ <text fg={t.accent}>│ Model {laneLine(laneSeries().output)}</text>
967
+ <box flexDirection="row">
968
+ <text fg={t.warning}>│ Tools </text>
969
+ <For each={toolRuns()}>{(run) => <text fg={run.error ? t.error : t.warning}>{run.text}</text>}</For>
970
+ <text fg={t.warning}> i inspector · u consumers</text>
971
+ </box>
972
+ </Show>
973
+ </Show>
974
+ <Show when={laneRoom() && lanesOn() && !showLanes()}>
975
+ <text fg={t.textMuted}>│ lanes appear after 3 turns</text>
976
+ </Show>
977
+ <text fg={cropMode() ? t.warning : t.textMuted}>
978
+ │ {cropMode() ? `✂ crop mode (${cropMode()}) · space mark · a auto · t result⇄turn · ⏎ apply · esc leave · marked ${selectedCandidates().length} ~${formatK(reclaimed(selectedCandidates()))}` : `filter: ${filter()}`}
979
+ {search() ? ` search: "${search()}"` : ""}
980
+ {busy() ? ` … ${busy()}` : ""} {view().rows.length} rows
981
+ </text>
982
+ <Show when={panel() === "decisions"}>
983
+ <text fg={t.accent}>│ ◆ decisions on this tree ({decisions().length}) · ⏎ jump to record · E export markdown · D back</text>
984
+ <Show when={decisions().length === 0}>
985
+ <text fg={t.textMuted}>│ (none yet — /merge a branch to write one)</text>
986
+ </Show>
987
+ <For each={decisions()}>
988
+ {(d, i) => {
989
+ const sel = () => i() === decisionIndex()
990
+ const lines = () => (d.text ?? "").split("\n").slice(0, sel() ? 12 : 1)
991
+ return (
992
+ <box flexDirection="column">
993
+ <text fg={sel() ? t.background : t.accent} bg={sel() ? t.primary : undefined}>
994
+ {sel() ? "›" : "│"} {d.hidden ? "◇ (hidden from model) " : "◆ "}{d.branchName} · {new Date(d.recordedAt).toISOString().slice(0, 16).replace("T", " ")}{d.siblings.length ? ` · ✗ ${d.siblings.map((x) => x.name).join(", ")}` : ""}
995
+ </text>
996
+ <For each={sel() ? lines().slice(1) : []}>{(l) => <text fg={t.text}>│ {l.slice(0, width() - 6)}</text>}</For>
997
+ </box>
998
+ )
999
+ }}
1000
+ </For>
1001
+ </Show>
1002
+ <Show when={panel() === "consumers"}>
1003
+ <text fg={t.accent}>│ what's filling the context · {formatK(view().totalTokens)} total · c crop · u/esc back</text>
1004
+ <For each={consumerRows()}>
1005
+ {(c, i) => {
1006
+ const sel = () => i() === consumerIndex()
1007
+ return (
1008
+ <text fg={sel() ? t.background : c.kind === "tool" ? t.warning : t.text} bg={sel() ? t.primary : undefined}>
1009
+ {sel() ? "›" : "│"} {c.source.padEnd(22).slice(0, 22)} {`${(c.share * 100).toFixed(0)}%`.padStart(4)} {bar(c.share, 24)} {formatK(c.tokens).padStart(6)} · {c.count} entr{c.count === 1 ? "y" : "ies"}
1010
+ </text>
1011
+ )
1012
+ }}
1013
+ </For>
1014
+ </Show>
1015
+ {/* clipped to the terminal so a short window keeps its footer */}
1016
+ <For each={panel() === "help" ? HELP.slice(0, Math.max(6, size().rows - 5)) : []}>{(l) => <text fg={l.startsWith(" ") ? t.textMuted : t.accent}>│ {l}</text>}</For>
1017
+ <Show when={panel() === "tree" && view().rows.length === 0}>
1018
+ <text fg={t.textMuted}>│ (no messages yet — chat first, then open the tree)</text>
1019
+ </Show>
1020
+ <box flexDirection="row" flexGrow={1}>
1021
+ <box flexDirection="column" flexGrow={1}>
1022
+ <For each={panel() === "tree" ? visible() : []}>
1023
+ {(row, i) => {
1024
+ const isSel = () => windowStart() + i() === selected()
1025
+ const color = () =>
1026
+ row.kind === "branch" ? statusColor(t, row) : row.kind === "turn" ? (row.isDecision ? t.accent : t.text) : row.isError ? t.error : row.warn ? t.warning : t.textMuted
1027
+ const mark = () => {
1028
+ if (!cropMode()) return ""
1029
+ const c = candidateOf(row)
1030
+ if (!c) return " "
1031
+ const on = marked().has(markKey(c))
1032
+ const prot = c.protections.filter((p) => p !== "too-small")
1033
+ return `${on ? "[x]" : "[ ]"}${prot.length ? "!" : " "}`
1034
+ }
1035
+ return (
1036
+ <text fg={isSel() ? t.background : (color() as never)} bg={isSel() ? t.primary : undefined}>
1037
+ {isSel() ? "›" : "│"} {mark()}
1038
+ {rowLine(row, rowWidth(), row.id === view().currentRowId)}
1039
+ </text>
1040
+ )
1041
+ }}
1042
+ </For>
1043
+ <For each={panel() === "tree" && view().rows.length > 0 && !view().rows.some((r) => r.kind === "branch") ? noBranchesLines() : []}>
1044
+ {(l) => <text fg={t.textMuted}>│ {l}</text>}
1045
+ </For>
1046
+ </box>
1047
+ <Show when={showInspector()}>
1048
+ <box flexDirection="column" width={inspectorWidth()} paddingLeft={1}>
1049
+ <For each={inspectorLines()}>{(l) => <text fg={l.fg as never}>┃ {l.text}</text>}</For>
1050
+ </box>
1051
+ </Show>
1052
+ </box>
1053
+ <text fg={cropMode() ? t.warning : t.textMuted}>
1054
+ └ {cropMode() ? "space mark a auto t result⇄turn ⏎ apply esc leave" : "⏎ go b branch m merge c crop i inspector 1·2·3 lanes x undo ? help q back"}
1055
+ </text>
1056
+ </box>
1057
+ )
1058
+ }