opencode-context-tree 0.1.1 → 0.2.1

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/src/tui/route.tsx CHANGED
@@ -4,18 +4,19 @@
4
4
  * OpenCode data through the adapters, actions through ./actions.
5
5
  */
6
6
  import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
7
+ import { PLUGIN_VERSION } from "../shared/version.js"
7
8
  import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
8
9
  import { planJump } from "../core/actions.js"
9
10
  import { foldJournal, type TreeState } from "../core/journal.js"
10
- import { cycleFilter, moveSelection, nextBranchIndex, resolveSelection, toggleExpanded } from "../core/navigation.js"
11
+ import { firstIndex, lastIndex, moveSelection, nextBranchIndex, resolveSelection, toggleExpanded } from "../core/navigation.js"
11
12
  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 { buildSpineMap, buildTreeView, currentChainOf, type Filter, type Row, type StepRow, type TurnRow } from "../core/tree.js"
13
14
  import type { Transcript } from "../core/transcript.js"
14
15
  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"
16
+ import { applyCrop, BRANCH_DIALOG, clip as clipTo, copyText, createNamedBranch, executeJump, executeUndo, mergeBranch, mergeDialogOptions, mergeDialogTitle, mergeTargetOf, MERGE_TRUST, ownTurnCount, setLabel, TRUNK_LABEL, UNDO_KEY, type ActionContext, type MergeMode, type SummaryChoice } from "./actions.js"
17
+ import { decisionSummary, exportDecisions, renderDecision } from "../core/decision.js"
18
+ import { buildEventStrip, stripIndexFor, type LaneMode, type StripCell } from "../core/lanes.js"
19
+ import { bar, consumers, type Consumer, type ConsumerEntry } from "../core/consumers.js"
19
20
  import { hasEditor } from "./editor.js"
20
21
  import fs from "node:fs"
21
22
  import path from "node:path"
@@ -64,7 +65,7 @@ function fitRow(body: string, tokens: string, width: number): string {
64
65
  }
65
66
 
66
67
  /** Display glyph, from the row's semantic flags rather than the stored glyph. */
67
- function glyphOf(row: Exclude<Row, { kind: "branch" }>): string {
68
+ function glyphOf(row: TurnRow | StepRow): string {
68
69
  if (row.kind === "turn") return row.isDecision ? "◆" : row.isSummary ? "≣" : "●"
69
70
  if (row.glyph === "⚙") return "⚙"
70
71
  if (row.glyph === "◇") return "≣" // OpenCode-native compaction summary
@@ -74,7 +75,7 @@ function glyphOf(row: Exclude<Row, { kind: "branch" }>): string {
74
75
  /** Content-forward row text — Pi's outline × DSH's trajectory: `user:` / `assistant:` inline,
75
76
  * tool steps as `[bash $ …]` / `[tool: arg] → out` (from partPreview), decisions/summaries
76
77
  * labelled. The gutter (drawn separately) carries the tree structure, not fixed columns. */
77
- function textOf(row: Exclude<Row, { kind: "branch" }>): string {
78
+ function textOf(row: TurnRow | StepRow): string {
78
79
  if (row.kind === "turn") {
79
80
  if (row.isDecision) return plain(row.preview).replace(/^◆\s*/, "").replace(/^#+\s*/, "")
80
81
  if (row.isSummary) return plain(row.preview)
@@ -84,7 +85,16 @@ function textOf(row: Exclude<Row, { kind: "branch" }>): string {
84
85
  return `assistant: ${plain(row.preview)}`
85
86
  }
86
87
 
88
+ /** The reasoning time folded onto this step by core (thinking parts have no row of their own
89
+ * outside the `all` filter); drawn dim by the caller. */
90
+ function thoughtOf(row: Row): string {
91
+ if (row.kind !== "step" || row.thinkingMs === undefined) return ""
92
+ return ` · ${(row.thinkingMs / 1000).toFixed(row.thinkingMs < 10_000 ? 1 : 0)}s thought`
93
+ }
94
+
87
95
  function rowLine(row: Row, width: number, here: boolean): string {
96
+ // decoration, not content: no glyph, no token column
97
+ if (row.kind === "separator") return `${row.gutter}${row.text}`
88
98
  const tokens = `${row.kind !== "branch" && row.estimated ? "~" : ""}${formatK(row.tokens)}`
89
99
  const marker = here ? " ← here" : ""
90
100
  let body: string
@@ -103,33 +113,63 @@ function rowLine(row: Row, width: number, here: boolean): string {
103
113
  ? ` [${row.label}]`
104
114
  : ""
105
115
  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}`
116
+ body = `${row.gutter}${glyphOf(row)} ${textOf(row)}${flags}${dur}${thoughtOf(row)}${marker}`
107
117
  }
108
118
  return fitRow(body, tokens, width)
109
119
  }
110
120
 
121
+ /** A rendered row split for colour: the live search hit and the dim `…s thought` tail.
122
+ * One segment means "draw it as one plain string" (the common case). */
123
+ type Segment = { text: string; kind: "plain" | "match" | "dim" }
124
+ function segmentsOf(line: string, query: string, thought: string): Segment[] {
125
+ const ranges: { at: number; len: number; kind: "match" | "dim" }[] = []
126
+ if (query) {
127
+ const at = line.toLowerCase().indexOf(query.toLowerCase())
128
+ if (at >= 0) ranges.push({ at, len: query.length, kind: "match" })
129
+ }
130
+ if (thought) {
131
+ const at = line.lastIndexOf(thought)
132
+ if (at >= 0) ranges.push({ at, len: thought.length, kind: "dim" })
133
+ }
134
+ if (ranges.length === 0) return [{ text: line, kind: "plain" }]
135
+ const out: Segment[] = []
136
+ let cursor = 0
137
+ for (const r of ranges.sort((a, b) => a.at - b.at)) {
138
+ if (r.at < cursor) continue // the query matched inside the tail: the first range wins
139
+ if (r.at > cursor) out.push({ text: line.slice(cursor, r.at), kind: "plain" })
140
+ out.push({ text: line.slice(r.at, r.at + r.len), kind: r.kind })
141
+ cursor = r.at + r.len
142
+ }
143
+ if (cursor < line.length) out.push({ text: line.slice(cursor), kind: "plain" })
144
+ return out
145
+ }
146
+
147
+ /** Vim-aligned defaults; every name is rebindable through the `keybinds` option. */
111
148
  const DEFAULT_KEYS: Record<string, string[]> = {
112
149
  up: ["up", "k"],
113
150
  down: ["down", "j"],
114
151
  jump_up: ["shift+up", "shift+k"],
115
152
  jump_down: ["shift+down", "shift+j"],
116
- first: ["g"],
153
+ half_up: ["ctrl+u"],
154
+ half_down: ["ctrl+d"],
155
+ // a sequence, so bare `g` is free (and never fires on its own)
156
+ first: ["gg"],
117
157
  last: ["shift+g"],
118
158
  prev_branch: ["["],
119
159
  next_branch: ["]"],
120
160
  fold: ["left", "h"],
121
161
  unfold: ["right", "l"],
122
- toggle: ["e"],
162
+ toggle: ["tab", "e"],
123
163
  go: ["return"],
124
164
  branch: ["b"],
125
165
  crop: ["c"],
126
166
  crop_toggle_mode: ["t"],
127
167
  mark: ["space"],
128
168
  auto: ["a"],
129
- undo: ["x"],
169
+ undo: ["u", "x"],
130
170
  merge: ["m"],
131
171
  inspector: ["i"],
132
- consumers: ["u"],
172
+ consumers: ["s"],
133
173
  copy: ["y"],
134
174
  mode_duration: ["1"],
135
175
  mode_turns: ["2"],
@@ -138,31 +178,50 @@ const DEFAULT_KEYS: Record<string, string[]> = {
138
178
  decisions: ["shift+d"],
139
179
  export: ["shift+e"],
140
180
  label: ["shift+l"],
141
- filter: ["f"],
181
+ filter_pick: ["f"],
182
+ filter_prev: ["shift+f"],
142
183
  search: ["/"],
184
+ search_next: ["n"],
185
+ search_prev: ["shift+n"],
143
186
  // terminals disagree on whether "?" carries the shift flag, so bind both spellings
144
187
  help: ["?", "shift+/"],
145
188
  back: ["q", "escape"],
146
189
  }
147
190
 
191
+ /** Placeholder for the strip while no session is loaded. */
192
+ const EMPTY_TRANSCRIPT: Transcript = { sessionID: "", title: "", status: "available", messages: [] }
193
+
148
194
  const NO_BRANCHES = "No branches yet · b forks here into a real OpenCode session; nothing is copied or deleted."
149
195
 
150
- /** The `?` overlay: unindented lines are headings, indented ones body (see the render). */
196
+ /** The `?` pane: unindented lines are headings, indented ones body (see the render).
197
+ * It sits under the rows, so the tree stays on screen while you read it. */
151
198
  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",
199
+ `? help · ? or esc closes · opencode-context-tree ${PLUGIN_VERSION}`,
200
+ "Move",
201
+ " ↑↓ j k · J K by 20 · ctrl+d ctrl+u half page · gg top · G bottom · [ ] branch rows",
202
+ " h l fold/unfold a branch · Tab (or e) toggle · / live search · n N next/prev match",
203
+ "Act",
204
+ " go a header switches to it · a user turn forks & prefills it · a step forks after it",
205
+ " b branch · m merge · c crop mode (space mark · a auto · t result⇄turn · apply · esc leave)",
206
+ " u undo (alias x) · L label · y copy · E export decisions",
207
+ "Views",
208
+ " i inspector · 1 2 3 lanes (duration/turns/calls) · 0 off · s consumers · D decisions · f F filter",
209
+ "Legend",
210
+ " user · assistant · tool step · decision · summary · branch (a real OpenCode session)",
211
+ " draw the topology · open folded · here is the session you are in",
212
+ " dim rows are not sent to the model; ── not in this branch's context ── is where your path forked",
213
+ " right column is tokens; ~ estimated · ⚠ ≥10k · ✂ cropped · ✗ tool error",
214
+ " ⎇ colours: open green · squashed blue · rejected/discarded red · abandoned grey",
215
+ " lanes: Input green you / grey context · Model purple answer / grey thinking · Tools orange call / red failed",
216
+ ]
217
+
218
+ /** `f` opens this as a picker; `F` steps back through it (DESIGN.md §7.5). */
219
+ const FILTERS: { title: string; value: Filter; description: string }[] = [
220
+ { title: "default", value: "default", description: "user turns, assistant text, tool steps" },
221
+ { title: "no-tools", value: "no-tools", description: "hide ⚙ tool steps" },
222
+ { title: "user-only", value: "user-only", description: "● user turns only" },
223
+ { title: "labeled", value: "labeled", description: "labelled rows only" },
224
+ { title: "all", value: "all", description: "everything, thinking parts included" },
166
225
  ]
167
226
 
168
227
  /** Plugin option `keybinds: { <command>: "k,up" | [..] | "none" }` overrides DEFAULT_KEYS. */
@@ -184,6 +243,9 @@ export function TreeRoute(props: TreeRouteProps) {
184
243
  const [expanded, setExpanded] = createSignal<Set<string>>(new Set(api.kv.get<string[]>(`ctree.expanded.${sessionID}`, [])))
185
244
  const [filter, setFilter] = createSignal<Filter>(api.kv.get<Filter>("ctree.filter", "default"))
186
245
  const [search, setSearch] = createSignal("")
246
+ // `/` types straight into the row list; esc restores the query it started from
247
+ const [searchMode, setSearchMode] = createSignal(false)
248
+ let searchBefore = ""
187
249
  const [selected, setSelected] = createSignal(0)
188
250
  const [others, setOthers] = createSignal<Record<string, Transcript>>({})
189
251
  const [busy, setBusy] = createSignal<string | undefined>()
@@ -195,7 +257,9 @@ export function TreeRoute(props: TreeRouteProps) {
195
257
  const [lanesOn, setLanesOn] = createSignal<boolean>(api.kv.get<boolean>("ctree.lanesOn", false))
196
258
  const [inspector, setInspector] = createSignal<boolean>(api.kv.get<boolean>("ctree.inspector", false))
197
259
  const [consumerIndex, setConsumerIndex] = createSignal(0)
260
+ const [consumerOpen, setConsumerOpen] = createSignal<Set<string>>(new Set())
198
261
  const [decisionIndex, setDecisionIndex] = createSignal(0)
262
+ const [decisionScroll, setDecisionScroll] = createSignal(0)
199
263
  const [marked, setMarked] = createSignal<Set<string>>(new Set())
200
264
 
201
265
  const state = createMemo<TreeState>(() => {
@@ -293,17 +357,23 @@ export function TreeRoute(props: TreeRouteProps) {
293
357
  api.renderer.on("resize", onResize)
294
358
  onCleanup(() => void api.renderer.off("resize", onResize))
295
359
  const cols = () => size().cols
360
+ // the `?` pane sits under the rows so the tree stays visible: it takes its space from them
361
+ const helpHeight = () => (panel() === "help" ? Math.min(HELP.length, Math.max(0, size().rows - 12)) : 0)
296
362
  // 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))
363
+ const height = () => Math.max(4, size().rows - 8 - (lanesOn() && size().rows >= 12 ? 3 : 0) - helpHeight())
298
364
  const width = () => Math.max(60, cols() - 4)
365
+ /** Two lines go to the `↑ n more` / `… n more ↓` cues as soon as the list does not fit. */
366
+ const overflow = () => view().rows.length > height() - 2
367
+ const rowsHeight = () => (overflow() ? height() - 2 : height())
299
368
  const windowStart = createMemo(() => {
300
- const h = height()
369
+ const h = rowsHeight()
301
370
  const s = selected()
302
371
  const n = view().rows.length
303
- const start = Math.max(0, Math.min(s - Math.floor(h / 2), n - h))
304
- return start
372
+ return Math.max(0, Math.min(s - Math.floor(h / 2), n - h))
305
373
  })
306
- const visible = createMemo(() => view().rows.slice(windowStart(), windowStart() + height()))
374
+ const visible = createMemo(() => view().rows.slice(windowStart(), windowStart() + rowsHeight()))
375
+ const hiddenAbove = () => windowStart()
376
+ const hiddenBelow = () => Math.max(0, view().rows.length - windowStart() - rowsHeight())
307
377
 
308
378
  // ---- crop mode -----------------------------------------------------------
309
379
  // Crops act on the *current* session's context. Spine rows above the fork point carry
@@ -311,7 +381,7 @@ export function TreeRoute(props: TreeRouteProps) {
311
381
  // prefix; the spine map (built from unfiltered transcripts) translates both ways.
312
382
  const live = () => (sessionID ? liveTranscript(api, sessionID) : undefined)
313
383
  const currentMessageOf = (row: Row): string | undefined => {
314
- if (row.kind === "branch") return undefined
384
+ if (row.kind === "branch" || row.kind === "separator") return undefined
315
385
  if (row.sessionID === sessionID) return row.messageID
316
386
  return spine().toCurrent(row.sessionID, row.messageID)
317
387
  }
@@ -345,9 +415,33 @@ export function TreeRoute(props: TreeRouteProps) {
345
415
  return list.filter((c) => m.has(markKey(c)))
346
416
  })
347
417
 
418
+ /** The crop mode a row can be marked in, so `space` alone can enter crop mode on it. */
419
+ function modeForRow(row: Row): "result" | "turn" | undefined {
420
+ if (row.kind === "step" && resultCands().some((c) => c.partID === (currentPartOf(row) ?? row.partID))) return "result"
421
+ if (row.kind !== "branch" && row.kind !== "separator" && turnCands().some((c) => c.anchorMessageID === currentMessageOf(row))) return "turn"
422
+ return undefined
423
+ }
424
+
425
+ /** True when one more `space` on the selected row would override its protection. */
426
+ const armed = () => {
427
+ const row = current()
428
+ const c = row ? candidateOf(row) : undefined
429
+ if (!c) return false
430
+ const key = markKey(c)
431
+ return marked().has(`${key}:warned`) && !marked().has(key)
432
+ }
433
+
348
434
  function toggleMark() {
349
435
  const row = current()
350
436
  if (!row) return
437
+ if (!cropMode()) {
438
+ const mode = modeForRow(row)
439
+ if (!mode) {
440
+ api.ui.toast({ message: "nothing croppable on this row — c opens crop mode" })
441
+ return
442
+ }
443
+ setCropMode(mode)
444
+ }
351
445
  const c = candidateOf(row)
352
446
  debug("crop.mark", { row: row.id, candidate: c ? { kind: c.kind, protections: c.protections } : undefined, marked: [...marked()] })
353
447
  if (!c) {
@@ -367,6 +461,14 @@ export function TreeRoute(props: TreeRouteProps) {
367
461
  setMarked(next)
368
462
  }
369
463
 
464
+ /** Leaving crop mode throws marks away, so say how many before it happens. */
465
+ async function leaveCropMode() {
466
+ const n = selectedCandidates().length
467
+ if (n > 0 && !(await confirm(`Drop ${n} mark${n === 1 ? "" : "s"}?`, "Nothing has been cropped yet — the marks are lost, the transcript is untouched."))) return
468
+ setCropMode(undefined)
469
+ setMarked(new Set<string>())
470
+ }
471
+
370
472
  function autoMarkAll() {
371
473
  if (cropMode() !== "result") return
372
474
  const picks = autoMark(resultCands())
@@ -420,57 +522,63 @@ export function TreeRoute(props: TreeRouteProps) {
420
522
 
421
523
  // the same figure the prompt gauge shows: context of the session, not of the drawn rows
422
524
  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)
525
+ const band = () => bandFor(contextSize().tokens, contextLimit())
424
526
  const branchOfCurrent = () => (sessionID ? state().sessions[sessionID] : undefined)
425
527
  const userTurns = () => (live()?.messages ?? []).filter((m) => m.role === "user").length
426
528
 
427
- // ---- lanes (minimap) -----------------------------------------------------
428
- const lanes = createMemo(() => (live() ? buildLanes(live()!, laneMode() === "duration" ? "turns" : laneMode()) : { mode: laneMode(), columns: [] }))
529
+ // ---- lanes (DSH event strip) ---------------------------------------------
530
+ // One pill per event on a shared time axis categorical colour, nothing scaled by tokens.
429
531
  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(() => {
532
+ const strip = createMemo(() => buildEventStrip(live() ?? EMPTY_TRANSCRIPT, laneMode(), laneWidth()))
533
+ /** The strip events the cursor sits on; their cells draw inverted, so there is no cursor block.
534
+ * A step row also owns the thinking pills folded into it (the list shows them as `· Ns thought`),
535
+ * otherwise the grey reasoning pills would never light up. */
536
+ const cursorEvents = createMemo<Set<number>>(() => {
441
537
  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
538
+ const hit = new Set<number>()
539
+ if (!row || row.kind === "branch" || row.kind === "separator") return hit
444
540
  const mid = currentMessageOf(row) ?? row.messageID
445
541
  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)
542
+ const own = stripIndexFor(strip(), mid, pid)
543
+ if (own >= 0) hit.add(own)
544
+ strip().events.forEach((e, i) => {
545
+ if (e.messageID !== mid) return
546
+ if (row.kind === "turn" ? e.lane === "input" : e.kind === "reasoning") hit.add(i)
547
+ })
548
+ return hit
448
549
  })
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)}`
550
+ const cellColor = (cell: StripCell): unknown => {
551
+ if (cell.error) return t.error
552
+ const e = strip().events[cell.eventIndex]
553
+ if (!e) return t.textMuted
554
+ if (e.lane === "tools") return t.warning
555
+ if (e.lane === "input") return e.kind === "user" ? t.success : t.textMuted
556
+ return e.kind === "reasoning" ? t.textMuted : t.accent
454
557
  }
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
558
+ /** Adjacent cells of the same colour collapse into one <text>, so a lane is a few nodes. */
559
+ const laneRuns = (lane: "input" | "model" | "tools") => {
560
+ const cur = cursorEvents()
561
+ const runs: { text: string; fg: unknown; bg: unknown }[] = []
562
+ for (const cell of strip().lanes[lane]) {
563
+ const sel = cell !== null && cur.has(cell.eventIndex)
564
+ const color = cell === null ? t.textMuted : cellColor(cell)
565
+ const fg = sel ? t.background : color
566
+ const bg = sel ? color : undefined
462
567
  const last = runs[runs.length - 1]
463
- if (last && last.error === error) last.text += line[i]
464
- else runs.push({ text: line[i]!, error })
568
+ if (last && last.fg === fg && last.bg === bg) last.text += cell?.glyph ?? " "
569
+ else runs.push({ text: cell?.glyph ?? " ", fg, bg })
465
570
  }
466
571
  return runs
467
- })
468
- // the Input lane is scaled against the context window, so a two-message session stays small
572
+ }
573
+ const inputRuns = createMemo(() => laneRuns("input"))
574
+ const modelRuns = createMemo(() => laneRuns("model"))
575
+ const toolRuns = createMemo(() => laneRuns("tools"))
576
+ // the context window no longer scales anything, but the Input lane still needs its limit
469
577
  const contextLimit = createMemo(() => (sessionID ? modelContextLimit(api, sessionID) : undefined))
470
- // under three turns every bar is either full or empty, which reads as "context full"
578
+ // under three turns every lane is one or two pills, which reads as a glitch rather than a strip
471
579
  const laneRoom = () => height() >= 12 && panel() === "tree"
472
580
  const showLanes = () => laneRoom() && lanesOn() && userTurns() >= 3
473
- /** DESIGN.md §7.6: below 80 columns the minimap is the Input sparkline alone. */
581
+ /** DESIGN.md §7.6: below 80 columns the strip is the Input lane alone. */
474
582
  const showAllLanes = () => cols() >= 80
475
583
  /** `1/2/3` turn the DSH lanes on and pick the x-axis; the active one again (or `0`) hides them. */
476
584
  function setLane(mode: LaneMode) {
@@ -493,7 +601,7 @@ export function TreeRoute(props: TreeRouteProps) {
493
601
  const noBranchesLines = () => (NO_BRANCHES.length + 2 <= rowWidth() ? [NO_BRANCHES] : NO_BRANCHES.split(/(?<=;) /))
494
602
  const inspectorLines = createMemo((): { fg: unknown; text: string }[] => {
495
603
  const row = current()
496
- if (!row) return []
604
+ if (!row || row.kind === "separator") return []
497
605
  const w = inspectorWidth() - 3
498
606
  const clip = (x: string) => (x.length > w ? `${x.slice(0, w - 1)}…` : x)
499
607
  const out: { fg: unknown; text: string }[] = []
@@ -522,17 +630,29 @@ export function TreeRoute(props: TreeRouteProps) {
522
630
  const msg = tr?.messages.find((m) => m.id === row.messageID)
523
631
  const turn = view().rows.slice(0, view().indexById[row.id]! + 1).filter((r) => r.kind === "turn").at(-1)
524
632
  if (row.kind === "turn") {
525
- head(`${row.isDecision ? "◆ decision" : row.isSummary ? "◇ summary" : "● user"} · T${row.turn}`)
633
+ const text = msg?.parts.map((p) => p.text ?? "").join("\n") ?? row.preview
634
+ if (row.isDecision) {
635
+ // a record is prose, not a payload: markdown off, wrapped to the pane
636
+ head(`◆ ${decisionSummary(text).title}`)
637
+ kv("Tokens", `~${formatK(row.tokens)}`)
638
+ const lines = renderDecision(text, w)
639
+ for (const l of lines.slice(0, 16)) out.push({ fg: t.text, text: l })
640
+ if (lines.length > 16) muted(`… ${lines.length - 16} more lines (y to copy)`)
641
+ return out
642
+ }
643
+ head(`${row.isSummary ? "◇ summary" : "● user"} · T${row.turn}`)
526
644
  if (row.label) kv("Label", row.label)
527
645
  kv("Tokens", `~${formatK(row.tokens)}`)
528
646
  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)
647
+ if (!row.inContext) muted("not in this branch's context")
648
+ block("Text", text, 14)
530
649
  return out
531
650
  }
532
651
  const part = msg?.parts.find((p) => p.id === row.partID)
533
652
  const stepNo = msg ? msg.parts.filter((p) => p.type === "tool" || p.type === "text").findIndex((p) => p.id === row.partID) + 1 : 0
534
653
  head(`${row.glyph} ${part?.type === "tool" ? part.tool : row.glyph === "◇" ? "compaction" : "assistant"} · T${turn?.kind === "turn" ? turn.turn : "?"} · step ${stepNo}`)
535
654
  kv("Hierarchy", `T${turn?.kind === "turn" ? turn.turn : "?"} › assistant › step ${stepNo}`)
655
+ if (!row.inContext) muted("not in this branch's context")
536
656
  if (part?.type === "tool") {
537
657
  const st = part.state
538
658
  const dur = st?.time?.start !== undefined && st?.time?.end !== undefined ? `${st.time.end - st.time.start} ms` : "?"
@@ -542,26 +662,62 @@ export function TreeRoute(props: TreeRouteProps) {
542
662
  block("Result", String(st?.output ?? ""), 10)
543
663
  kv("Timing", st?.time?.start ? `started ${new Date(st.time.start).toISOString().slice(11, 23)} · ${dur} · session ts` : "n/a")
544
664
  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")
665
+ kv("Crop", row.isCropped ? `✂ cropped (${UNDO_KEY} to restore)` : cand ? (cand.protections.length ? `protected: ${cand.protections.join(", ")}` : "c then space to stub this result") : "n/a")
546
666
  } else {
547
667
  kv("Tokens", `~${formatK(row.tokens)}`)
548
668
  if (row.durationMs !== undefined) kv("Duration", `${(row.durationMs / 1000).toFixed(1)} s`)
669
+ if (row.thinkingMs !== undefined) kv("Thought", `${(row.thinkingMs / 1000).toFixed(1)} s`)
549
670
  block("Text", part?.text ?? row.preview, 14)
550
671
  }
551
672
  return out
552
673
  })
553
674
 
554
675
  // ---- consumers -------------------------------------------------------------
555
- const consumerRows = createMemo(() => (live() ? consumers(live()!, { cropped: alreadyCropped() }) : []))
676
+ const consumerRows = createMemo(() => (live() ? consumers(live()!, { cropped: alreadyCropped(), limit: contextLimit() }) : []))
677
+ /** Buckets plus the entries of every expanded one, flattened so ↑↓ walks both. */
678
+ type ConsumerLine = { bucket: Consumer; entry?: ConsumerEntry }
679
+ const consumerLines = createMemo((): ConsumerLine[] =>
680
+ consumerRows().flatMap((c) => [{ bucket: c } as ConsumerLine, ...(consumerOpen().has(c.source) ? c.entries.map((e) => ({ bucket: c, entry: e })) : [])]),
681
+ )
682
+ const consumerLine = () => consumerLines()[Math.min(consumerIndex(), consumerLines().length - 1)]
683
+ /** Bars are scaled to the biggest bucket, not to the window: the shape is the point. */
684
+ const consumerMax = () => Math.max(1, ...consumerRows().map((c) => c.tokens))
685
+
686
+ function toggleConsumer(open: boolean) {
687
+ const line = consumerLine()
688
+ if (!line) return
689
+ const next = new Set(consumerOpen())
690
+ if (open) next.add(line.bucket.source)
691
+ else next.delete(line.bucket.source)
692
+ setConsumerOpen(next)
693
+ }
694
+
695
+ /** `space` in the consumers panel marks one part for crop where it stands — entering crop
696
+ * mode is implied. Entries that cannot be stubbed carry their reason on the row itself. */
697
+ function markConsumerEntry() {
698
+ const line = consumerLine()
699
+ if (!line) return
700
+ if (!line.entry) {
701
+ toggleConsumer(true)
702
+ return
703
+ }
704
+ const cand = line.entry.croppable ? resultCands().find((r) => r.partID === line.entry?.partID) : undefined
705
+ if (!cand) return
706
+ setCropMode("result")
707
+ const next = new Set(marked())
708
+ if (next.has(cand.partID)) next.delete(cand.partID)
709
+ else next.add(cand.partID)
710
+ setMarked(next)
711
+ }
556
712
 
557
713
  /** From the consumers panel: back to the tree in crop mode with that source's
558
714
  * unprotected results pre-marked (DESIGN.md §7.4). */
559
715
  function cropConsumer() {
560
- const c = consumerRows()[consumerIndex()]
716
+ const c = consumerLine()?.bucket
561
717
  setPanel("tree")
562
718
  if (!c || c.kind !== "tool") {
563
719
  setCropMode("result")
564
- api.ui.toast({ message: c ? `${c.source} is not a tool result; mark rows by hand` : "nothing to crop" })
720
+ api.ui.toast({ message: c ? (c.note ?? `${c.source} is not a tool result; mark rows by hand`) : "nothing to crop" })
565
721
  return
566
722
  }
567
723
  setCropMode("result")
@@ -572,15 +728,13 @@ export function TreeRoute(props: TreeRouteProps) {
572
728
 
573
729
  function copySelected() {
574
730
  const row = current()
575
- if (!row || row.kind === "branch") return
731
+ if (!row || row.kind === "branch" || row.kind === "separator") return
576
732
  const tr = row.sessionID === sessionID ? live() : others()[row.sessionID]
577
733
  const msg = tr?.messages.find((m) => m.id === row.messageID)
578
734
  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
735
  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` })
736
+ const { hint } = copyText(api, text, directory)
737
+ api.ui.toast({ message: `copied ${text.length} chars → ${hint}` })
584
738
  } catch (e) {
585
739
  api.ui.toast({ variant: "error", message: String(e) })
586
740
  }
@@ -691,6 +845,12 @@ export function TreeRoute(props: TreeRouteProps) {
691
845
  }
692
846
  }
693
847
 
848
+ /** How a session reads in a dialog: its branch name if it has one, else its title. */
849
+ const sessionLabel = (id: string) => {
850
+ const name = state().sessions[id]?.name
851
+ return name ? `⎇ ${name}` : (others()[id]?.title ?? api.state.session.get(id)?.title ?? id)
852
+ }
853
+
694
854
  async function jump() {
695
855
  const row = current()
696
856
  if (!row || !sessionID) return
@@ -701,12 +861,15 @@ export function TreeRoute(props: TreeRouteProps) {
701
861
  api.ui.toast({ message: plan.reason })
702
862
  return
703
863
  }
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
- }
864
+ const from = sessionLabel(plan.sessionID)
865
+ const ok = await confirm(
866
+ plan.kind === "switch" ? `Switch to ${from}?` : plan.mode === "redo" ? "Fork & prefill this turn?" : "Fork after this step?",
867
+ plan.kind === "switch" ? `The session you are on now stays exactly as it is. ${UNDO_KEY} undoes this.` : `A new OpenCode session forks from ${from} at this point; nothing is deleted. ${UNDO_KEY} undoes this.`,
868
+ )
869
+ if (!ok) return
708
870
  const summary = await askSummary()
709
- await executeJump(ctx, plan, { currentSessionID: sessionID, summary })
871
+ const target = await executeJump(ctx, plan, { currentSessionID: sessionID, summary })
872
+ if (target) api.ui.toast({ message: `moved to ${sessionLabel(target)} · ${UNDO_KEY} undoes it` })
710
873
  })
711
874
  }
712
875
 
@@ -757,7 +920,7 @@ export function TreeRoute(props: TreeRouteProps) {
757
920
 
758
921
  async function label() {
759
922
  const row = current()
760
- if (!row || row.kind === "branch") return
923
+ if (!row || row.kind === "branch" || row.kind === "separator") return
761
924
  const st = state()
762
925
  const existing = st.labels[row.messageID]?.label
763
926
  const value = await prompt("Label (empty to remove)", "checkpoint", existing)
@@ -769,7 +932,7 @@ export function TreeRoute(props: TreeRouteProps) {
769
932
  function foldOrUnfold(open: boolean) {
770
933
  const row = current()
771
934
  if (!row) return
772
- const target = row.kind === "branch" ? row.sessionID : row.depth > 0 ? row.sessionID : undefined
935
+ const target = row.kind === "separator" ? undefined : row.kind === "branch" || row.depth > 0 ? row.sessionID : undefined
773
936
  if (!target) return
774
937
  // the row's resolved state, not raw set membership: on-path branches start open, so
775
938
  // `expanded` membership inverts there (see tree.ts shownExpanded). A visible nested row
@@ -811,18 +974,20 @@ export function TreeRoute(props: TreeRouteProps) {
811
974
  return
812
975
  }
813
976
  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 }))
977
+ const parent = others()[b.parentSessionID]
978
+ const target = mergeTargetOf(b.parentSessionID === state().root ? TRUNK_LABEL : (state().sessions[b.parentSessionID]?.name ?? TRUNK_LABEL), parent?.messages ?? [])
979
+ const turns = ownTurnCount(live()?.messages ?? [], { messageID: b.anchorMessageID, parentMessageIDs: parent?.messages.map((m) => m.id) ?? [] })
980
+ const mode = await select<MergeMode>(mergeDialogTitle(b.name ?? "branch", target), mergeDialogOptions({ siblings, turns }))
815
981
  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
982
  const inApp = !hasEditor()
819
983
  ? async (draft: string) => {
820
984
  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
985
  return ok ? draft : undefined
822
986
  }
823
987
  : undefined
988
+ // mergeBranch owns the discard gate (its confirm + "Why?" note prompt), so there is none here
824
989
  await guarded("merge", async () => {
825
- await mergeBranch(ctx, { sessionID, mode, note, confirm: inApp })
990
+ await mergeBranch(ctx, { sessionID, mode, confirm: inApp })
826
991
  })
827
992
  }
828
993
 
@@ -840,84 +1005,190 @@ export function TreeRoute(props: TreeRouteProps) {
840
1005
  function jumpToDecision() {
841
1006
  const d = decisions()[decisionIndex()]
842
1007
  if (!d) return
843
- const idx = view().rows.findIndex((r) => r.kind !== "branch" && r.messageID === d.messageID)
1008
+ const idx = view().rows.findIndex((r) => (r.kind === "turn" || r.kind === "step") && r.messageID === d.messageID)
844
1009
  setPanel("tree")
845
1010
  if (idx >= 0) setSelected(idx)
846
1011
  else api.ui.toast({ message: "that record lives in another session" })
847
1012
  }
848
1013
 
1014
+ // ---- keys ------------------------------------------------------------------
1015
+ const treePanel = () => panel() === "tree"
1016
+ const inCrop = () => cropMode() !== undefined
1017
+ /** Crop mode is exclusive: it owns space/⏎/esc, so the other verbs step aside. */
1018
+ const treeIdle = () => treePanel() && !inCrop()
1019
+ const listPanel = () => treePanel() || panel() === "consumers"
1020
+
1021
+ function setFilterTo(next: Filter) {
1022
+ setFilter(next)
1023
+ api.kv.set("ctree.filter", next)
1024
+ }
1025
+
1026
+ async function pickFilter() {
1027
+ const next = await select<Filter>(
1028
+ "Filter rows",
1029
+ FILTERS.map((f) => ({ title: `${f.value === filter() ? "●" : " "} ${f.title}`, value: f.value, description: f.description })),
1030
+ )
1031
+ if (next) setFilterTo(next)
1032
+ }
1033
+
1034
+ /** ↑↓ walk whichever list is on screen. */
1035
+ function moveIndex(delta: number) {
1036
+ if (panel() === "decisions") {
1037
+ setDecisionIndex((i) => Math.min(Math.max(0, decisions().length - 1), Math.max(0, i + delta)))
1038
+ setDecisionScroll(0)
1039
+ return
1040
+ }
1041
+ if (panel() === "consumers") {
1042
+ setConsumerIndex((i) => Math.min(Math.max(0, consumerLines().length - 1), Math.max(0, i + delta)))
1043
+ return
1044
+ }
1045
+ setSelected((i) => moveSelection(view().rows, i, delta))
1046
+ }
1047
+
1048
+ function halfPage(dir: 1 | -1) {
1049
+ const half = Math.max(1, Math.floor(height() / 2))
1050
+ // in the decisions panel a half page scrolls the open record, not the record list
1051
+ if (panel() === "decisions") setDecisionScroll((s) => Math.max(0, s + dir * half))
1052
+ else moveIndex(dir * half)
1053
+ }
1054
+
1055
+ function gotoEdge(dir: 1 | -1) {
1056
+ if (panel() === "decisions") {
1057
+ setDecisionIndex(dir === -1 ? 0 : Math.max(0, decisions().length - 1))
1058
+ setDecisionScroll(0)
1059
+ return
1060
+ }
1061
+ if (panel() === "consumers") {
1062
+ setConsumerIndex(dir === -1 ? 0 : Math.max(0, consumerLines().length - 1))
1063
+ return
1064
+ }
1065
+ const i = dir === -1 ? firstIndex(view().rows) : lastIndex(view().rows)
1066
+ if (i >= 0) setSelected(i)
1067
+ }
1068
+
1069
+ /** Where the live query sits in a rendered row (-1 when the row only survived the filter
1070
+ * because one of the rows it owns matched). */
1071
+ const matchIn = (line: string): number => {
1072
+ const q = search().trim().toLowerCase()
1073
+ return q ? line.toLowerCase().indexOf(q) : -1
1074
+ }
1075
+
1076
+ function moveMatch(dir: 1 | -1) {
1077
+ const rows = view().rows
1078
+ const q = search().trim()
1079
+ if (rows.length === 0 || !q) return
1080
+ for (let step = 1; step <= rows.length; step++) {
1081
+ const i = (((selected() + dir * step) % rows.length) + rows.length) % rows.length
1082
+ if (rows[i]!.kind !== "separator" && matchIn(rowLine(rows[i]!, rowWidth(), false)) >= 0) {
1083
+ setSelected(i)
1084
+ return
1085
+ }
1086
+ }
1087
+ api.ui.toast({ message: `no other row matches "${q}"` })
1088
+ }
1089
+
1090
+ function enterSearch() {
1091
+ searchBefore = search()
1092
+ setSearchMode(true)
1093
+ }
1094
+
1095
+ /** `esc` puts the view back the way it was; `⏎` keeps the filter and leaves the mode. */
1096
+ function exitSearch(commit: boolean) {
1097
+ if (!commit) setSearch(searchBefore)
1098
+ setSearchMode(false)
1099
+ }
1100
+
1101
+ // Search types straight into the row list, and the layer below would fire j/k/b as commands:
1102
+ // while the mode is on we take the key before any binding sees it.
1103
+ const stopTyping = api.keymap.intercept("key", (input) => {
1104
+ if (!searchMode()) return
1105
+ const ev = input.event
1106
+ if (ev.eventType === "release" || ev.ctrl || ev.meta) return
1107
+ const take = () => input.consume({ preventDefault: true, stopPropagation: true })
1108
+ if (ev.name === "escape") return void (exitSearch(false), take())
1109
+ if (ev.name === "return" || ev.name === "enter") return void (exitSearch(true), take())
1110
+ if (ev.name === "backspace") return void (setSearch((q) => q.slice(0, -1)), take())
1111
+ const char = ev.sequence?.length === 1 && ev.sequence >= " " && ev.sequence !== "\x7f" ? ev.sequence : ev.name.length === 1 ? ev.name : undefined
1112
+ if (char === undefined) return
1113
+ setSearch((q) => q + char)
1114
+ take()
1115
+ })
1116
+ onCleanup(() => stopTyping())
1117
+
849
1118
  const off = api.keymap.registerLayer({
850
1119
  // OpenCode's own bare-letter layers do the same: dialogs push "modal", so without this
851
1120
  // typing "bash" into a prompt would fire b/a/s/h as route commands
852
1121
  mode: "base",
853
1122
  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() },
1123
+ { name: "ctree.up", hidden: true, run: () => moveIndex(-1) },
1124
+ { name: "ctree.down", hidden: true, run: () => moveIndex(1) },
1125
+ { name: "ctree.jump_up", hidden: true, enabled: treePanel, run: () => moveIndex(-20) },
1126
+ { name: "ctree.jump_down", hidden: true, enabled: treePanel, run: () => moveIndex(20) },
1127
+ { name: "ctree.half_up", hidden: true, run: () => halfPage(-1) },
1128
+ { name: "ctree.half_down", hidden: true, run: () => halfPage(1) },
1129
+ { name: "ctree.first", hidden: true, run: () => gotoEdge(-1) },
1130
+ { name: "ctree.last", hidden: true, run: () => gotoEdge(1) },
1131
+ { name: "ctree.prev_branch", hidden: true, enabled: treePanel, run: () => setSelected((i) => nextBranchIndex(view().rows, i, -1)) },
1132
+ { name: "ctree.next_branch", hidden: true, enabled: treePanel, run: () => setSelected((i) => nextBranchIndex(view().rows, i, 1)) },
1133
+ { name: "ctree.fold", hidden: true, enabled: listPanel, run: () => (panel() === "consumers" ? toggleConsumer(false) : foldOrUnfold(false)) },
1134
+ { name: "ctree.unfold", hidden: true, enabled: listPanel, run: () => (panel() === "consumers" ? toggleConsumer(true) : foldOrUnfold(true)) },
1135
+ { name: "ctree.toggle", hidden: true, enabled: treePanel, run: () => foldOrUnfold(!(current()?.kind === "branch" && (current() as Row & { kind: "branch" }).expanded)) },
868
1136
  {
869
- name: "ctree.filter",
1137
+ name: "ctree.go",
870
1138
  hidden: true,
871
- run: () => {
872
- const next = cycleFilter(filter())
873
- setFilter(next)
874
- api.kv.set("ctree.filter", next)
875
- },
1139
+ run: () =>
1140
+ void (panel() === "decisions"
1141
+ ? jumpToDecision()
1142
+ : panel() === "consumers"
1143
+ ? toggleConsumer(!consumerOpen().has(consumerLine()?.bucket.source ?? ""))
1144
+ : cropMode()
1145
+ ? applyMarked()
1146
+ : jump()),
876
1147
  },
1148
+ { name: "ctree.branch", hidden: true, enabled: treeIdle, run: () => void branch() },
1149
+ { name: "ctree.label", hidden: true, enabled: treeIdle, run: () => void label() },
1150
+ { name: "ctree.filter_pick", hidden: true, enabled: () => !inCrop(), run: () => void pickFilter() },
877
1151
  {
878
- name: "ctree.search",
1152
+ name: "ctree.filter_prev",
879
1153
  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
- }),
1154
+ enabled: () => !inCrop(),
1155
+ run: () => setFilterTo(FILTERS[(FILTERS.findIndex((f) => f.value === filter()) - 1 + FILTERS.length) % FILTERS.length]!.value),
884
1156
  },
1157
+ { name: "ctree.search", hidden: true, enabled: treeIdle, run: () => enterSearch() },
1158
+ { name: "ctree.search_next", hidden: true, enabled: treePanel, run: () => moveMatch(1) },
1159
+ { name: "ctree.search_prev", hidden: true, enabled: treePanel, run: () => moveMatch(-1) },
885
1160
  {
886
1161
  name: "ctree.crop",
887
1162
  hidden: true,
1163
+ enabled: listPanel,
888
1164
  run: () => {
889
1165
  if (panel() === "consumers") {
890
1166
  cropConsumer()
891
1167
  return
892
1168
  }
893
- if (panel() !== "tree") return
894
- if (cropMode()) {
895
- setCropMode(undefined)
896
- setMarked(new Set<string>())
897
- } else setCropMode("result")
1169
+ if (cropMode()) void leaveCropMode()
1170
+ else setCropMode("result")
898
1171
  },
899
1172
  },
900
1173
  {
901
1174
  name: "ctree.crop_toggle_mode",
902
1175
  hidden: true,
903
- run: () => {
904
- if (!cropMode()) return
905
- setCropMode(cropMode() === "result" ? "turn" : "result")
906
- setMarked(new Set<string>())
907
- },
1176
+ enabled: inCrop,
1177
+ // marks are keyed per mode, so switching the lens keeps both sets alive
1178
+ run: () => setCropMode(cropMode() === "result" ? "turn" : "result"),
908
1179
  },
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") },
1180
+ { name: "ctree.mark", hidden: true, enabled: listPanel, run: () => (panel() === "consumers" ? markConsumerEntry() : toggleMark()) },
1181
+ { name: "ctree.auto", hidden: true, enabled: inCrop, run: () => autoMarkAll() },
1182
+ { name: "ctree.undo", hidden: true, enabled: treeIdle, run: () => void undo() },
1183
+ { name: "ctree.merge", hidden: true, enabled: treeIdle, run: () => void merge() },
1184
+ { name: "ctree.inspector", hidden: true, enabled: () => !inCrop(), run: () => { setInspector(!inspector()); api.kv.set("ctree.inspector", inspector()) } },
1185
+ { name: "ctree.consumers", hidden: true, enabled: () => !inCrop(), run: () => setPanel(panel() === "consumers" ? "tree" : "consumers") },
1186
+ { name: "ctree.copy", hidden: true, enabled: treeIdle, run: () => copySelected() },
1187
+ { name: "ctree.mode_duration", hidden: true, enabled: treePanel, run: () => setLane("duration") },
1188
+ { name: "ctree.mode_turns", hidden: true, enabled: treePanel, run: () => setLane("turns") },
1189
+ { name: "ctree.mode_calls", hidden: true, enabled: treePanel, run: () => setLane("calls") },
1190
+ { name: "ctree.lanes_off", hidden: true, enabled: treePanel, run: () => { setLanesOn(false); api.kv.set("ctree.lanesOn", false) } },
1191
+ { name: "ctree.decisions", hidden: true, enabled: () => !inCrop(), run: () => setPanel(panel() === "decisions" ? "tree" : "decisions") },
921
1192
  { name: "ctree.export", hidden: true, enabled: () => panel() === "decisions", run: () => exportDecisionsFile() },
922
1193
  { name: "ctree.help", hidden: true, run: () => setPanel(panel() === "help" ? "tree" : "help") },
923
1194
  {
@@ -929,8 +1200,12 @@ export function TreeRoute(props: TreeRouteProps) {
929
1200
  return
930
1201
  }
931
1202
  if (cropMode()) {
932
- setCropMode(undefined)
933
- setMarked(new Set<string>())
1203
+ void leaveCropMode()
1204
+ return
1205
+ }
1206
+ // a live filter is invisible chrome once search mode is off: clear it before leaving
1207
+ if (search()) {
1208
+ setSearch("")
934
1209
  return
935
1210
  }
936
1211
  back()
@@ -947,83 +1222,162 @@ export function TreeRoute(props: TreeRouteProps) {
947
1222
  const root = state().root
948
1223
  return (root && root !== sessionID ? others()[root]?.title : undefined) ?? sessionTitle()
949
1224
  }
950
- const where = () => {
1225
+ const modeTag = () => (cropMode() ? " · crop mode" : searchMode() ? " · search" : "")
1226
+ /** `┌ Context tree · ⎇ fix-flaky ← Fix flaky test`: from a branch the trunk title is a
1227
+ * suffix, not a repeat. Both titles are cut so the `ctx …` string never clips. */
1228
+ const headLine = () => {
951
1229
  const b = branchOfCurrent()
952
- if (!b) return "trunk"
953
- return `⎇ ${b.name ?? sessionTitle()} (${b.status}${b.model ? ` · ${b.model.split("/").pop()}` : ""})`
1230
+ const lead = "┌ Context tree · "
1231
+ const where = b ? `⎇ ${clipTo(b.name ?? sessionTitle(), 28)}${b.status === "open" ? "" : ` (${b.status})`}` : ""
1232
+ const tail = b ? "" : " · trunk"
1233
+ const room = cols() - 4 - formatContext(contextSize(), contextLimit()).length - modeTag().length - lead.length - where.length - tail.length - 3
1234
+ return `${lead}${where}${clipTo(title(), Math.max(8, room))}${tail}${modeTag()} `
1235
+ }
1236
+
1237
+ const statusLine = () => {
1238
+ const n = view().rows.length
1239
+ const pos = `${n ? Math.min(selected() + 1, n) : 0}/${n}`
1240
+ if (cropMode()) {
1241
+ const a = armed()
1242
+ return `✂ crop mode (${cropMode()}) · space mark · a auto · t result⇄turn · ⏎ apply · esc leave · marked ${selectedCandidates().length} ~${formatK(reclaimed(selectedCandidates()))}${a ? " · armed — space again to override" : ""}`
1243
+ }
1244
+ if (searchMode()) return `search: ${search()}▏ · ${pos} rows · ⏎ keeps it · esc clears`
1245
+ return `filter: ${filter()}${search() ? ` search: "${search()}"` : ""}${busy() ? ` … ${busy()}` : ""} ${pos} rows`
1246
+ }
1247
+
1248
+ /** `⏎` does four different things; the footer says which one for the row under the cursor. */
1249
+ const goVerb = () => {
1250
+ const row = current()
1251
+ if (!row) return "⏎ go"
1252
+ if (row.kind === "branch") return row.isCurrent ? "⏎ you are here" : `⏎ switch to ⎇ ${clipTo(row.name, 20)}`
1253
+ if (row.kind === "separator") return "⏎ go"
1254
+ if (row.id === view().currentRowId) return "⏎ you are here"
1255
+ return row.kind === "turn" ? "⏎ fork & prefill this turn" : "⏎ fork after this step"
1256
+ }
1257
+
1258
+ const footer = () => {
1259
+ if (cropMode()) return "space mark a auto t result⇄turn ⏎ apply esc leave"
1260
+ if (panel() === "decisions") return "⏎ jump to record E export q back"
1261
+ if (panel() === "consumers") return "⏎ expand space mark c crop q back"
1262
+ if (panel() === "help") return "esc/q back"
1263
+ return `${goVerb()} b branch m merge c crop ${UNDO_KEY} undo s consumers ? help q back`
1264
+ }
1265
+
1266
+ const showsTree = () => panel() === "tree" || panel() === "help"
1267
+ /** Never "no messages yet" when a filter or a search is what emptied the list. */
1268
+ const emptyText = () => {
1269
+ const q = search().trim()
1270
+ if (q) return `no rows match "${q}" · esc clears`
1271
+ if (filter() !== "default") return `no rows match filter: ${filter()} · f changes it`
1272
+ return "(no messages yet — chat first, then open the tree)"
954
1273
  }
955
1274
 
956
1275
  return (
957
1276
  <box flexDirection="column" padding={1} backgroundColor={t.background} width="100%" height="100%">
958
1277
  <box flexDirection="row">
959
1278
  {/* one expression: JSX would trim the gap before the context string */}
960
- <text fg={t.primary}>{`┌ Context tree · ${title()} · ${where()} `}</text>
1279
+ <text fg={t.primary}>{headLine()}</text>
961
1280
  <text fg={t[BAND_KEY[band()]]}>{formatContext(contextSize(), contextLimit())}</text>
962
1281
  </box>
963
1282
  <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>
1283
+ <box flexDirection="row">
1284
+ {/* one expression per label: JSX trims the gap between a text node and an expression */}
1285
+ <text fg={t.textMuted}>{`│ Input ${strip().truncatedLeft > 0 ? `…${strip().truncatedLeft}` : ""}`}</text>
1286
+ <Show when={!strip().empty.input} fallback={<text fg={t.textMuted}>{"no input".padEnd(laneWidth())}</text>}>
1287
+ <For each={inputRuns()}>{(r) => <text fg={r.fg as never} bg={r.bg as never}>{r.text}</text>}</For>
1288
+ </Show>
1289
+ <text fg={t.textMuted}>{` ${laneMode() === "duration" ? "[1] Duration" : " 1 duration"} · ${laneMode() === "turns" ? "[2] Turns" : " 2 turns"} · ${laneMode() === "calls" ? "[3] Calls" : " 3 calls"} · 0 off`}</text>
1290
+ </box>
965
1291
  <Show when={showAllLanes()}>
966
- <text fg={t.accent}>│ Model {laneLine(laneSeries().output)}</text>
967
1292
  <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>
1293
+ <text fg={t.textMuted}>{"│ Model "}</text>
1294
+ <Show when={!strip().empty.model} fallback={<text fg={t.textMuted}>{"no model steps".padEnd(laneWidth())}</text>}>
1295
+ <For each={modelRuns()}>{(r) => <text fg={r.fg as never} bg={r.bg as never}>{r.text}</text>}</For>
1296
+ </Show>
1297
+ </box>
1298
+ <box flexDirection="row">
1299
+ <text fg={t.textMuted}>{"│ Tools "}</text>
1300
+ <Show when={!strip().empty.tools} fallback={<text fg={t.textMuted}>{"no tool calls".padEnd(laneWidth())}</text>}>
1301
+ <For each={toolRuns()}>{(r) => <text fg={r.fg as never} bg={r.bg as never}>{r.text}</text>}</For>
1302
+ </Show>
1303
+ <text fg={t.textMuted}>{" i inspector · s consumers"}</text>
971
1304
  </box>
972
1305
  </Show>
973
1306
  </Show>
974
1307
  <Show when={laneRoom() && lanesOn() && !showLanes()}>
975
1308
  <text fg={t.textMuted}>│ lanes appear after 3 turns</text>
976
1309
  </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>
1310
+ <text fg={cropMode() ? t.warning : searchMode() ? t.accent : t.textMuted}>│ {statusLine()}</text>
982
1311
  <Show when={panel() === "decisions"}>
983
- <text fg={t.accent}>│ ◆ decisions on this tree ({decisions().length}) · ⏎ jump to record · E export markdown · D back</text>
1312
+ <text fg={t.accent}>│ ◆ decisions on this tree ({decisions().length}) · ⏎ jump to record · E export markdown · q back</text>
984
1313
  <Show when={decisions().length === 0}>
985
1314
  <text fg={t.textMuted}>│ (none yet — /merge a branch to write one)</text>
986
1315
  </Show>
987
1316
  <For each={decisions()}>
988
1317
  {(d, i) => {
989
1318
  const sel = () => i() === decisionIndex()
990
- const lines = () => (d.text ?? "").split("\n").slice(0, sel() ? 12 : 1)
1319
+ const body = () => renderDecision(d.text ?? "", width() - 6)
1320
+ const room = () => Math.max(3, height() - decisions().length)
1321
+ const start = () => Math.min(decisionScroll(), Math.max(0, body().length - room()))
1322
+ const more = () => Math.max(0, body().length - start() - room())
991
1323
  return (
992
1324
  <box flexDirection="column">
993
1325
  <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(", ")}` : ""}
1326
+ {sel() ? "›" : "│"} {d.hidden ? "◇ (hidden from model) " : "◆ "}{clipTo(decisionSummary(d.text ?? "").title || d.branchName, 48)} · {new Date(d.recordedAt).toISOString().slice(0, 16).replace("T", " ")}{d.siblings.length ? ` · ✗ ${d.siblings.map((x) => x.name).join(", ")}` : ""}
995
1327
  </text>
996
- <For each={sel() ? lines().slice(1) : []}>{(l) => <text fg={t.text}>│ {l.slice(0, width() - 6)}</text>}</For>
1328
+ <For each={sel() ? body().slice(start(), start() + room()) : []}>{(l) => <text fg={t.text}>{`│ ${l}`}</text>}</For>
1329
+ <Show when={sel() && more() > 0}>
1330
+ <text fg={t.textMuted}>{`│ … ${more()} more lines ↓ (ctrl+d)`}</text>
1331
+ </Show>
997
1332
  </box>
998
1333
  )
999
1334
  }}
1000
1335
  </For>
1001
1336
  </Show>
1002
1337
  <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) => {
1338
+ <text fg={t.accent}>│ what's filling the context · {formatK(view().totalTokens)} total · source · %tree · %window · tokens · entries</text>
1339
+ <For each={consumerLines()}>
1340
+ {(line, i) => {
1006
1341
  const sel = () => i() === consumerIndex()
1342
+ const c = line.bucket
1343
+ const fg = () => (sel() ? t.background : line.entry ? t.textMuted : c.kind === "tool" ? t.warning : t.text)
1344
+ const window = () => (c.shareOfWindow === undefined ? "–" : `${(c.shareOfWindow * 100).toFixed(0)}%`)
1345
+ const entry = line.entry
1007
1346
  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"}
1347
+ <text fg={fg()} bg={sel() ? t.primary : undefined}>
1348
+ {sel() ? "›" : "│"} {entry
1349
+ ? fitRow(` ${entry.croppable ? (marked().has(entry.partID ?? "") ? "[x]" : "[ ]") : " "} ${plain(entry.preview)}${entry.croppable ? "" : ` · ${c.note ?? "not a completed tool result"}`}`, formatK(entry.tokens), width() - 4)
1350
+ : `${consumerOpen().has(c.source) ? "▾" : "▸"} ${c.source.padEnd(20).slice(0, 20)} ${`${(c.share * 100).toFixed(0)}%`.padStart(4)} ${window().padStart(5)} ${bar(c.tokens / consumerMax(), 18)} ${formatK(c.tokens).padStart(6)} · ${c.count} entr${c.count === 1 ? "y" : "ies"}${c.note ? ` · ${c.note}` : ""}`}
1010
1351
  </text>
1011
1352
  )
1012
1353
  }}
1013
1354
  </For>
1014
1355
  </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>
1356
+ <Show when={showsTree() && view().rows.length === 0}>
1357
+ <text fg={t.textMuted}>│ {emptyText()}</text>
1019
1358
  </Show>
1020
1359
  <box flexDirection="row" flexGrow={1}>
1021
1360
  <box flexDirection="column" flexGrow={1}>
1022
- <For each={panel() === "tree" ? visible() : []}>
1361
+ <Show when={showsTree() && overflow()}>
1362
+ <text fg={t.textMuted}>│ {hiddenAbove() > 0 ? `↑ ${hiddenAbove()} more` : ""}</text>
1363
+ </Show>
1364
+ <For each={showsTree() ? visible() : []}>
1023
1365
  {(row, i) => {
1024
1366
  const isSel = () => windowStart() + i() === selected()
1025
1367
  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
1368
+ row.kind === "separator"
1369
+ ? t.textMuted
1370
+ : row.kind === "branch"
1371
+ ? statusColor(t, row)
1372
+ : !row.inContext
1373
+ ? t.textMuted // the model is never shown these: an ancestor's rows past our fork point
1374
+ : row.kind === "turn"
1375
+ ? (row.isDecision ? t.accent : t.text)
1376
+ : row.isError
1377
+ ? t.error
1378
+ : row.warn
1379
+ ? t.warning
1380
+ : t.textMuted
1027
1381
  const mark = () => {
1028
1382
  if (!cropMode()) return ""
1029
1383
  const c = candidateOf(row)
@@ -1032,17 +1386,40 @@ export function TreeRoute(props: TreeRouteProps) {
1032
1386
  const prot = c.protections.filter((p) => p !== "too-small")
1033
1387
  return `${on ? "[x]" : "[ ]"}${prot.length ? "!" : " "}`
1034
1388
  }
1389
+ const prefix = () => `${isSel() ? "›" : "│"} ${mark()}`
1390
+ const segs = () => segmentsOf(rowLine(row, rowWidth(), row.id === view().currentRowId), search().trim(), thoughtOf(row))
1035
1391
  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>
1392
+ <Show
1393
+ when={segs().length > 1}
1394
+ fallback={
1395
+ <text fg={isSel() ? t.background : (color() as never)} bg={isSel() ? t.primary : undefined}>
1396
+ {prefix()}
1397
+ {segs()[0]?.text}
1398
+ </text>
1399
+ }
1400
+ >
1401
+ <box flexDirection="row">
1402
+ <text fg={isSel() ? t.background : (color() as never)} bg={isSel() ? t.primary : undefined}>{prefix()}</text>
1403
+ <For each={segs()}>
1404
+ {(s) => (
1405
+ <text fg={(s.kind === "match" ? t.background : s.kind === "dim" ? t.textMuted : isSel() ? t.background : color()) as never} bg={s.kind === "match" ? t.accent : isSel() ? t.primary : undefined}>
1406
+ {s.text}
1407
+ </text>
1408
+ )}
1409
+ </For>
1410
+ </box>
1411
+ </Show>
1040
1412
  )
1041
1413
  }}
1042
1414
  </For>
1043
- <For each={panel() === "tree" && view().rows.length > 0 && !view().rows.some((r) => r.kind === "branch") ? noBranchesLines() : []}>
1415
+ <Show when={showsTree() && overflow()}>
1416
+ <text fg={t.textMuted}>│ {hiddenBelow() > 0 ? `… ${hiddenBelow()} more ↓` : ""}</text>
1417
+ </Show>
1418
+ <For each={showsTree() && view().rows.length > 0 && !view().rows.some((r) => r.kind === "branch") ? noBranchesLines() : []}>
1044
1419
  {(l) => <text fg={t.textMuted}>│ {l}</text>}
1045
1420
  </For>
1421
+ {/* the help pane sits under the rows, so the tree it explains stays on screen */}
1422
+ <For each={panel() === "help" ? HELP.slice(0, helpHeight()) : []}>{(l) => <text fg={l.startsWith(" ") ? t.textMuted : t.accent}>│ {l}</text>}</For>
1046
1423
  </box>
1047
1424
  <Show when={showInspector()}>
1048
1425
  <box flexDirection="column" width={inspectorWidth()} paddingLeft={1}>
@@ -1050,9 +1427,7 @@ export function TreeRoute(props: TreeRouteProps) {
1050
1427
  </box>
1051
1428
  </Show>
1052
1429
  </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>
1430
+ <text fg={cropMode() ? t.warning : t.textMuted}>└ {footer()}</text>
1056
1431
  </box>
1057
1432
  )
1058
1433
  }