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/CHANGELOG.md +38 -0
- package/README.md +35 -25
- package/dist/server.js +6 -2
- package/dist/tui.js +1310 -490
- package/docs/USAGE.md +52 -21
- package/package.json +1 -1
- package/src/core/actions.ts +1 -0
- package/src/core/consumers.ts +55 -13
- package/src/core/decision.ts +62 -0
- package/src/core/lanes.ts +166 -5
- package/src/core/navigation.ts +39 -10
- package/src/core/tokens.ts +22 -4
- package/src/core/tree.ts +98 -5
- package/src/server/index.ts +3 -2
- package/src/shared/version.ts +3 -0
- package/src/tui/actions.ts +131 -11
- package/src/tui/index.tsx +14 -6
- package/src/tui/route.tsx +556 -181
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 {
|
|
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 {
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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: ["
|
|
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
|
-
|
|
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 `?`
|
|
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
|
-
|
|
153
|
-
"
|
|
154
|
-
"
|
|
155
|
-
"
|
|
156
|
-
"
|
|
157
|
-
"
|
|
158
|
-
"
|
|
159
|
-
"
|
|
160
|
-
"
|
|
161
|
-
"
|
|
162
|
-
"
|
|
163
|
-
"
|
|
164
|
-
"
|
|
165
|
-
"
|
|
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(
|
|
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 =
|
|
369
|
+
const h = rowsHeight()
|
|
301
370
|
const s = selected()
|
|
302
371
|
const n = view().rows.length
|
|
303
|
-
|
|
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() +
|
|
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 (
|
|
428
|
-
|
|
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
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
443
|
-
|
|
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
|
|
447
|
-
|
|
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
|
|
450
|
-
|
|
451
|
-
const
|
|
452
|
-
if (
|
|
453
|
-
|
|
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
|
-
/**
|
|
456
|
-
const
|
|
457
|
-
const
|
|
458
|
-
const
|
|
459
|
-
const
|
|
460
|
-
|
|
461
|
-
const
|
|
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.
|
|
464
|
-
else runs.push({ text:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 ?
|
|
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 =
|
|
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
|
-
|
|
582
|
-
|
|
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
|
-
|
|
705
|
-
|
|
706
|
-
|
|
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 === "
|
|
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
|
|
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,
|
|
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
|
|
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: () => (
|
|
855
|
-
{ name: "ctree.down", hidden: true, run: () => (
|
|
856
|
-
{ name: "ctree.jump_up", hidden: true,
|
|
857
|
-
{ name: "ctree.jump_down", hidden: true,
|
|
858
|
-
{ name: "ctree.
|
|
859
|
-
{ name: "ctree.
|
|
860
|
-
{ name: "ctree.
|
|
861
|
-
{ name: "ctree.
|
|
862
|
-
{ name: "ctree.
|
|
863
|
-
{ name: "ctree.
|
|
864
|
-
{ name: "ctree.
|
|
865
|
-
{ name: "ctree.
|
|
866
|
-
{ name: "ctree.
|
|
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.
|
|
1137
|
+
name: "ctree.go",
|
|
870
1138
|
hidden: true,
|
|
871
|
-
run: () =>
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
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.
|
|
1152
|
+
name: "ctree.filter_prev",
|
|
879
1153
|
hidden: true,
|
|
880
|
-
|
|
881
|
-
|
|
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 (
|
|
894
|
-
|
|
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
|
-
|
|
904
|
-
|
|
905
|
-
|
|
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: () =>
|
|
910
|
-
{ name: "ctree.auto", hidden: true, enabled:
|
|
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
|
-
|
|
933
|
-
|
|
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
|
|
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
|
-
|
|
953
|
-
|
|
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}>{
|
|
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
|
-
<
|
|
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.
|
|
969
|
-
<
|
|
970
|
-
|
|
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 ·
|
|
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
|
|
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() ?
|
|
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 ·
|
|
1004
|
-
<For each={
|
|
1005
|
-
{(
|
|
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={
|
|
1009
|
-
{sel() ? "›" : "│"} {
|
|
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
|
-
{
|
|
1016
|
-
|
|
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
|
-
<
|
|
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 === "
|
|
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
|
-
<
|
|
1037
|
-
{
|
|
1038
|
-
{
|
|
1039
|
-
|
|
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
|
-
<
|
|
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
|
}
|