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