opencode-context-tree 0.1.1 → 0.2.0

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