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/CHANGELOG.md +28 -0
- package/README.md +22 -24
- package/dist/tui.js +1294 -489
- package/docs/USAGE.md +40 -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/tui/actions.ts +131 -11
- package/src/tui/index.tsx +14 -6
- package/src/tui/route.tsx +544 -180
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 {
|
|
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 {
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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: ["
|
|
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
|
-
|
|
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 `?`
|
|
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
|
-
"
|
|
154
|
-
"
|
|
155
|
-
"
|
|
156
|
-
"
|
|
157
|
-
"
|
|
158
|
-
"
|
|
159
|
-
"
|
|
160
|
-
"
|
|
161
|
-
"
|
|
162
|
-
"
|
|
163
|
-
"
|
|
164
|
-
"
|
|
165
|
-
"
|
|
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(
|
|
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 =
|
|
367
|
+
const h = rowsHeight()
|
|
301
368
|
const s = selected()
|
|
302
369
|
const n = view().rows.length
|
|
303
|
-
|
|
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() +
|
|
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 (
|
|
428
|
-
|
|
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
|
|
431
|
-
|
|
432
|
-
|
|
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
|
-
|
|
447
|
-
return col < 0 ? -1 : laneSeries().cellFor(col)
|
|
537
|
+
return stripIndexFor(strip(), mid, pid)
|
|
448
538
|
})
|
|
449
|
-
const
|
|
450
|
-
|
|
451
|
-
const
|
|
452
|
-
if (
|
|
453
|
-
|
|
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
|
-
/**
|
|
456
|
-
const
|
|
457
|
-
const
|
|
458
|
-
const
|
|
459
|
-
const
|
|
460
|
-
|
|
461
|
-
const
|
|
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.
|
|
464
|
-
else runs.push({ text:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 ?
|
|
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 =
|
|
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
|
-
|
|
582
|
-
|
|
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
|
-
|
|
705
|
-
|
|
706
|
-
|
|
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 === "
|
|
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
|
|
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,
|
|
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
|
|
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: () => (
|
|
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() },
|
|
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.
|
|
1126
|
+
name: "ctree.go",
|
|
870
1127
|
hidden: true,
|
|
871
|
-
run: () =>
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
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.
|
|
1141
|
+
name: "ctree.filter_prev",
|
|
879
1142
|
hidden: true,
|
|
880
|
-
|
|
881
|
-
|
|
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 (
|
|
894
|
-
|
|
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
|
-
|
|
904
|
-
|
|
905
|
-
|
|
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: () =>
|
|
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") },
|
|
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
|
-
|
|
933
|
-
|
|
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
|
|
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
|
-
|
|
953
|
-
|
|
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}>{
|
|
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
|
-
<
|
|
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.
|
|
969
|
-
<
|
|
970
|
-
|
|
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 ·
|
|
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
|
|
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() ?
|
|
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 ·
|
|
1004
|
-
<For each={
|
|
1005
|
-
{(
|
|
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={
|
|
1009
|
-
{sel() ? "›" : "│"} {
|
|
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
|
-
{
|
|
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>
|
|
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
|
-
<
|
|
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 === "
|
|
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
|
-
<
|
|
1037
|
-
{
|
|
1038
|
-
{
|
|
1039
|
-
|
|
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
|
-
<
|
|
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
|
}
|