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,421 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
/**
|
|
3
|
+
* TUI plugin half (DESIGN.md §3.2, §5, §7): `/tree` (aliases `/ctree`, `/panel`),
|
|
4
|
+
* `/branch`, `/label`, the `ctree` route, and the prompt-side gauge slot.
|
|
5
|
+
*/
|
|
6
|
+
import type { TuiPluginApi, TuiPlugin } from "@opencode-ai/plugin/tui"
|
|
7
|
+
import { Show, createEffect, createMemo, createSignal, on } from "solid-js"
|
|
8
|
+
import { bandFor, contextSizeOf, formatContext, formatK, type MinimalMessage, type MinimalPart } from "../core/tokens.js"
|
|
9
|
+
import { JournalStore, type StorageMode } from "../shared/store.js"
|
|
10
|
+
import { debug } from "../shared/debug.js"
|
|
11
|
+
import { BRANCH_DIALOG, MERGE_TRUST, bumpJournal, clip, createNamedBranch, journalRevision, mergeBranch, mergeDialogOptions, mergeDialogTitle, setLabel, type MergeMode } from "./actions.js"
|
|
12
|
+
import { openSiblings } from "../core/decision.js"
|
|
13
|
+
import { hasEditor } from "./editor.js"
|
|
14
|
+
import { TreeRoute } from "./route.js"
|
|
15
|
+
import { parseForkTitle } from "../core/adopt.js"
|
|
16
|
+
import { adoptNativeForks } from "../shared/adopt.js"
|
|
17
|
+
import { fetchTranscript, modelContextLimit } from "./transcripts.js"
|
|
18
|
+
|
|
19
|
+
const BAND_COLOR = { low: "success", healthy: "success", filling: "warning", red: "error" } as const
|
|
20
|
+
|
|
21
|
+
type Options = { storage: StorageMode; jumpSummary: "ask" | "never"; hardCrop: boolean; keybinds: Record<string, string[]>; open: string[] }
|
|
22
|
+
|
|
23
|
+
/** `"k,up"` or `["k","up"]` → `["k","up"]`; `"none"`/`false` → `[]`. */
|
|
24
|
+
function keys(v: unknown): string[] | undefined {
|
|
25
|
+
if (v === undefined) return undefined
|
|
26
|
+
if (v === false || v === "none") return []
|
|
27
|
+
if (Array.isArray(v)) return v.filter((x): x is string => typeof x === "string")
|
|
28
|
+
if (typeof v === "string") return v.split(",").map((x) => x.trim()).filter(Boolean)
|
|
29
|
+
return undefined
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function parseOptions(raw: Record<string, unknown> | undefined): Options {
|
|
33
|
+
const kb = (raw?.["keybinds"] as Record<string, unknown> | undefined) ?? {}
|
|
34
|
+
const keybinds: Record<string, string[]> = {}
|
|
35
|
+
for (const [name, v] of Object.entries(kb)) {
|
|
36
|
+
const k = keys(v)
|
|
37
|
+
if (k) keybinds[name] = k
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
storage: raw?.["storage"] === "global" ? "global" : "local",
|
|
41
|
+
jumpSummary: raw?.["jumpSummary"] === "never" ? "never" : "ask",
|
|
42
|
+
hardCrop: raw?.["hardCrop"] === true,
|
|
43
|
+
keybinds,
|
|
44
|
+
open: keys(kb["open"]) ?? ["ctrl+q"],
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function toMinimalMessages(messages: readonly any[], part: (messageID: string) => readonly any[]): MinimalMessage[] {
|
|
49
|
+
return messages.map((m) => ({
|
|
50
|
+
info: m.role === "assistant" ? { role: "assistant", tokens: m.tokens } : { role: m.role },
|
|
51
|
+
parts: part(m.id).map(
|
|
52
|
+
(p): MinimalPart => ({
|
|
53
|
+
type: p.type,
|
|
54
|
+
text: p.type === "text" || p.type === "reasoning" ? p.text : undefined,
|
|
55
|
+
tool: p.type === "tool" ? p.tool : undefined,
|
|
56
|
+
state: p.type === "tool" ? { status: p.state?.status, input: p.state?.input, output: p.state?.output } : undefined,
|
|
57
|
+
}),
|
|
58
|
+
),
|
|
59
|
+
}))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A branch's display name: adopted native forks carry no journal `name`, so fall back to
|
|
63
|
+
* the session's own title (DESIGN.md §4.1's `kind: "native"`). */
|
|
64
|
+
function branchLabel(api: TuiPluginApi, sessionID: string, name: string | undefined, max?: number): string {
|
|
65
|
+
const label = name ?? api.state.session.get(sessionID)?.title ?? "branch"
|
|
66
|
+
return max === undefined ? label : clip(label, max)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** OpenCode's sidebar is narrow; anything longer wraps and orphans the tail of the line. */
|
|
70
|
+
const CARD_COLUMNS = 28
|
|
71
|
+
|
|
72
|
+
function currentSession(api: TuiPluginApi): string | undefined {
|
|
73
|
+
const cur = api.route.current
|
|
74
|
+
return cur.name === "session" ? ((cur.params as { sessionID?: string } | undefined)?.sessionID ?? undefined) : undefined
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const tui: TuiPlugin = async (api, rawOptions) => {
|
|
78
|
+
const options = parseOptions(rawOptions as Record<string, unknown> | undefined)
|
|
79
|
+
const directory = api.state.path.directory
|
|
80
|
+
const store = new JournalStore({ worktree: api.state.path.worktree || directory, stateDir: api.state.path.state, mode: options.storage })
|
|
81
|
+
debug("tui.loaded", { path: api.state.path, options })
|
|
82
|
+
|
|
83
|
+
// Native `/fork` sessions are invisible to the journal until adopted; `adopted` lets an
|
|
84
|
+
// open route know a branch appeared (DESIGN.md §4.1's `kind: "native"`).
|
|
85
|
+
const [adopted, setAdopted] = createSignal(0)
|
|
86
|
+
const adopt = async () => {
|
|
87
|
+
const found = await adoptNativeForks({
|
|
88
|
+
store,
|
|
89
|
+
directory,
|
|
90
|
+
actor: "tui",
|
|
91
|
+
listSessions: async () => {
|
|
92
|
+
const res = await api.client.session.list({ directory })
|
|
93
|
+
return ((res.data as any[]) ?? []).map((s) => ({ id: s.id as string, title: (s.title as string) ?? "", created: (s.time?.created as number) ?? 0, parentID: s.parentID as string | undefined, directory: s.directory as string | undefined }))
|
|
94
|
+
},
|
|
95
|
+
messagesOf: async (sessionID) => (await fetchTranscript(api, sessionID, directory)).messages.map((m) => ({ id: m.id, role: m.role, created: m.time.created })),
|
|
96
|
+
})
|
|
97
|
+
// the server half may have adopted first: refresh either way so the card and route re-read
|
|
98
|
+
setAdopted((n) => n + 1)
|
|
99
|
+
bumpJournal()
|
|
100
|
+
return found
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The `session.created` event fires before the fork's messages are copied, so wait, then retry. */
|
|
104
|
+
const adoptSoon = async () => {
|
|
105
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
106
|
+
await new Promise((r) => setTimeout(r, 1000))
|
|
107
|
+
if ((await adopt()).length > 0) return
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const offCreated = api.event.on("session.created", (event) => {
|
|
112
|
+
const info = event.properties.info
|
|
113
|
+
if (!info.parentID && parseForkTitle(info.title ?? "")) void adoptSoon()
|
|
114
|
+
})
|
|
115
|
+
api.lifecycle?.onDispose(offCreated)
|
|
116
|
+
|
|
117
|
+
const promptDialog = (title: string, placeholder?: string) =>
|
|
118
|
+
new Promise<string | undefined>((resolve) => {
|
|
119
|
+
api.ui.dialog.replace(
|
|
120
|
+
() =>
|
|
121
|
+
api.ui.DialogPrompt({
|
|
122
|
+
title,
|
|
123
|
+
placeholder,
|
|
124
|
+
onConfirm: (v) => {
|
|
125
|
+
debug("prompt.confirm", { v })
|
|
126
|
+
resolve(v)
|
|
127
|
+
api.ui.dialog.clear()
|
|
128
|
+
},
|
|
129
|
+
onCancel: () => {
|
|
130
|
+
debug("prompt.cancel")
|
|
131
|
+
resolve(undefined)
|
|
132
|
+
api.ui.dialog.clear()
|
|
133
|
+
},
|
|
134
|
+
}),
|
|
135
|
+
() => {
|
|
136
|
+
debug("prompt.close")
|
|
137
|
+
resolve(undefined)
|
|
138
|
+
},
|
|
139
|
+
)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
// Palette `run` handlers must return synchronously: an awaited promise keeps the palette
|
|
143
|
+
// open, and its own close then clears any dialog we opened. Fire-and-forget instead.
|
|
144
|
+
const detached = (fn: () => Promise<void>) => () => {
|
|
145
|
+
void fn().catch((e) => api.ui.toast({ variant: "error", message: e instanceof Error ? e.message : String(e) }))
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
api.keymap.registerLayer({
|
|
149
|
+
commands: [
|
|
150
|
+
{
|
|
151
|
+
namespace: "palette",
|
|
152
|
+
name: "ctree.open",
|
|
153
|
+
title: "Context tree",
|
|
154
|
+
description: "Tree + trajectory of this session",
|
|
155
|
+
category: "Context",
|
|
156
|
+
slashName: "tree",
|
|
157
|
+
slashAliases: ["ctree", "panel"],
|
|
158
|
+
run: () => {
|
|
159
|
+
const sessionID = currentSession(api)
|
|
160
|
+
void adopt()
|
|
161
|
+
api.route.navigate("ctree", sessionID ? { sessionID } : {})
|
|
162
|
+
api.ui.dialog.clear()
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
namespace: "palette",
|
|
167
|
+
name: "ctree.branch",
|
|
168
|
+
title: "Branch here",
|
|
169
|
+
description: "Fork the current session into a named branch",
|
|
170
|
+
category: "Context",
|
|
171
|
+
slashName: "branch",
|
|
172
|
+
enabled: () => Boolean(currentSession(api)),
|
|
173
|
+
run: detached(async () => {
|
|
174
|
+
const sessionID = currentSession(api)
|
|
175
|
+
if (!sessionID) return
|
|
176
|
+
await new Promise((r) => setTimeout(r, 30))
|
|
177
|
+
const name = await promptDialog(BRANCH_DIALOG.title, BRANCH_DIALOG.placeholder)
|
|
178
|
+
debug("branch.named", { name })
|
|
179
|
+
if (!name) return
|
|
180
|
+
try {
|
|
181
|
+
await createNamedBranch({ api, store, directory }, { sessionID, name })
|
|
182
|
+
} catch (e) {
|
|
183
|
+
api.ui.toast({ variant: "error", message: `branch: ${e instanceof Error ? e.message : String(e)}` })
|
|
184
|
+
}
|
|
185
|
+
}),
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
namespace: "palette",
|
|
189
|
+
name: "ctree.label",
|
|
190
|
+
title: "Label this point",
|
|
191
|
+
description: "Bookmark the last message of the session",
|
|
192
|
+
category: "Context",
|
|
193
|
+
slashName: "label",
|
|
194
|
+
enabled: () => Boolean(currentSession(api)),
|
|
195
|
+
run: detached(async () => {
|
|
196
|
+
const sessionID = currentSession(api)
|
|
197
|
+
if (!sessionID) return
|
|
198
|
+
await new Promise((r) => setTimeout(r, 30))
|
|
199
|
+
const last = api.state.session.messages(sessionID).at(-1)
|
|
200
|
+
if (!last) return
|
|
201
|
+
const value = await promptDialog("Label (empty to remove)", "checkpoint")
|
|
202
|
+
if (value === undefined) return
|
|
203
|
+
setLabel({ api, store, directory }, { sessionID, messageID: last.id, label: value.trim() || null })
|
|
204
|
+
api.ui.toast({ variant: "success", message: value.trim() ? `labelled: ${value.trim()}` : "label removed" })
|
|
205
|
+
}),
|
|
206
|
+
},
|
|
207
|
+
],
|
|
208
|
+
bindings: options.open.map((key) => ({ key, cmd: "ctree.open" })),
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
api.keymap.registerLayer({
|
|
212
|
+
commands: [
|
|
213
|
+
{
|
|
214
|
+
namespace: "palette",
|
|
215
|
+
name: "ctree.merge",
|
|
216
|
+
title: "Merge branch",
|
|
217
|
+
description: "Close this branch: squash to a ◆ decision record, discard, or tournament",
|
|
218
|
+
category: "Context",
|
|
219
|
+
slashName: "merge",
|
|
220
|
+
enabled: () => Boolean(currentSession(api)),
|
|
221
|
+
run: detached(async () => {
|
|
222
|
+
const sessionID = currentSession(api)
|
|
223
|
+
if (!sessionID) return
|
|
224
|
+
await new Promise((r) => setTimeout(r, 30))
|
|
225
|
+
const state = store.stateForSession(sessionID)
|
|
226
|
+
const branch = state?.sessions[sessionID]
|
|
227
|
+
if (!state || !branch || branch.status !== "open") {
|
|
228
|
+
api.ui.toast({ message: "not on an open branch — /branch first" })
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
const mode = await new Promise<MergeMode | undefined>((resolve) => {
|
|
232
|
+
api.ui.dialog.replace(
|
|
233
|
+
() =>
|
|
234
|
+
api.ui.DialogSelect<MergeMode>({
|
|
235
|
+
title: mergeDialogTitle(branch.name ?? "branch", api.state.session.get(branch.parentSessionID)?.title),
|
|
236
|
+
options: mergeDialogOptions({ siblings: openSiblings(state, sessionID).length }),
|
|
237
|
+
onSelect: (o) => {
|
|
238
|
+
resolve(o.value)
|
|
239
|
+
api.ui.dialog.clear()
|
|
240
|
+
},
|
|
241
|
+
}),
|
|
242
|
+
() => resolve(undefined),
|
|
243
|
+
)
|
|
244
|
+
})
|
|
245
|
+
if (!mode) return
|
|
246
|
+
const inApp = !hasEditor()
|
|
247
|
+
? async (draft: string) =>
|
|
248
|
+
new Promise<string | undefined>((resolve) => {
|
|
249
|
+
api.ui.dialog.replace(
|
|
250
|
+
() =>
|
|
251
|
+
api.ui.DialogConfirm({
|
|
252
|
+
title: "Accept the drafted record as-is?",
|
|
253
|
+
message: `${draft.slice(0, 400)}${draft.length > 400 ? "…" : ""}\n\n${MERGE_TRUST}`,
|
|
254
|
+
onConfirm: () => {
|
|
255
|
+
resolve(draft)
|
|
256
|
+
api.ui.dialog.clear()
|
|
257
|
+
},
|
|
258
|
+
onCancel: () => {
|
|
259
|
+
resolve(undefined)
|
|
260
|
+
api.ui.dialog.clear()
|
|
261
|
+
},
|
|
262
|
+
}),
|
|
263
|
+
() => resolve(undefined),
|
|
264
|
+
)
|
|
265
|
+
})
|
|
266
|
+
: undefined
|
|
267
|
+
try {
|
|
268
|
+
await mergeBranch({ api, store, directory }, { sessionID, mode, confirm: inApp })
|
|
269
|
+
} catch (e) {
|
|
270
|
+
api.ui.toast({ variant: "error", message: `merge: ${e instanceof Error ? e.message : String(e)}` })
|
|
271
|
+
}
|
|
272
|
+
}),
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
namespace: "palette",
|
|
276
|
+
name: "ctree.decisions",
|
|
277
|
+
title: "Decisions",
|
|
278
|
+
description: "◆ decision records on this tree",
|
|
279
|
+
category: "Context",
|
|
280
|
+
slashName: "decisions",
|
|
281
|
+
run: () => {
|
|
282
|
+
const sessionID = currentSession(api)
|
|
283
|
+
api.route.navigate("ctree", sessionID ? { sessionID, view: "decisions" } : { view: "decisions" })
|
|
284
|
+
api.ui.dialog.clear()
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
],
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
api.route.register([
|
|
291
|
+
{
|
|
292
|
+
name: "ctree",
|
|
293
|
+
render: ({ params }) => (
|
|
294
|
+
<TreeRoute
|
|
295
|
+
api={api}
|
|
296
|
+
store={store}
|
|
297
|
+
directory={directory}
|
|
298
|
+
sessionID={params?.["sessionID"] as string | undefined}
|
|
299
|
+
refresh={adopted}
|
|
300
|
+
options={{ jumpSummary: options.jumpSummary, hardCrop: options.hardCrop, keybinds: options.keybinds }}
|
|
301
|
+
initialView={params?.["view"] === "decisions" ? "decisions" : "tree"}
|
|
302
|
+
/>
|
|
303
|
+
),
|
|
304
|
+
},
|
|
305
|
+
])
|
|
306
|
+
|
|
307
|
+
api.slots.register({
|
|
308
|
+
slots: {
|
|
309
|
+
sidebar_content: (_ctx, props: { session_id: string }) => {
|
|
310
|
+
const t = api.theme.current
|
|
311
|
+
// the journal is plain files: without the revision the card would render once per session
|
|
312
|
+
const st = createMemo(() => {
|
|
313
|
+
journalRevision()
|
|
314
|
+
return store.stateForSession(props.session_id)
|
|
315
|
+
})
|
|
316
|
+
const branch = () => st()?.sessions[props.session_id]
|
|
317
|
+
const crops = () => st()?.activeCrops(props.session_id) ?? []
|
|
318
|
+
const hidden = () => crops().reduce((s, c) => s + c.targets.reduce((x, y) => x + y.estTokens, 0), 0)
|
|
319
|
+
const siblings = () => Object.values(st()?.sessions ?? {}).filter((b) => b.parentSessionID === props.session_id && b.status === "open").length
|
|
320
|
+
// status and parent go on their own line: a branch name long enough to wrap used to
|
|
321
|
+
// leave "· open" orphaned underneath it
|
|
322
|
+
const status = () => {
|
|
323
|
+
const b = branch()!
|
|
324
|
+
const title = api.state.session.get(b.parentSessionID)?.title
|
|
325
|
+
const room = CARD_COLUMNS - b.status.length - 10
|
|
326
|
+
return `${b.status}${title && room > 3 ? ` · from "${clip(title, room)}"` : ""}`
|
|
327
|
+
}
|
|
328
|
+
return (
|
|
329
|
+
<box flexDirection="column">
|
|
330
|
+
<text fg={t.text}>
|
|
331
|
+
<b>Context tree</b>
|
|
332
|
+
</text>
|
|
333
|
+
<Show when={branch()} fallback={<text fg={t.text}>{`trunk${siblings() ? ` · ${siblings()} branch${siblings() === 1 ? "" : "es"}` : ""}`}</text>}>
|
|
334
|
+
<text fg={t.success}>{`⎇ ${branchLabel(api, props.session_id, branch()!.name, CARD_COLUMNS - 2)}`}</text>
|
|
335
|
+
<text fg={t.textMuted}>{status()}</text>
|
|
336
|
+
</Show>
|
|
337
|
+
<Show when={crops().length}>
|
|
338
|
+
<text fg={t.warning}>{`✂ ${crops().length} crop${crops().length === 1 ? "" : "s"} · ~${formatK(hidden())} hidden`}</text>
|
|
339
|
+
</Show>
|
|
340
|
+
<text fg={t.textMuted}>/tree · ctrl+q</text>
|
|
341
|
+
</box>
|
|
342
|
+
)
|
|
343
|
+
},
|
|
344
|
+
session_prompt_right: (_ctx, props: { session_id: string }) => {
|
|
345
|
+
const t = api.theme.current
|
|
346
|
+
const size = createMemo(() => contextSizeOf(toMinimalMessages(api.state.session.messages(props.session_id), api.state.part)))
|
|
347
|
+
const band = () => bandFor(size().tokens)
|
|
348
|
+
const branch = () => {
|
|
349
|
+
journalRevision() // the journal is plain files: without the revision `⎇ name` never refreshes
|
|
350
|
+
return store.stateForSession(props.session_id)?.sessions[props.session_id]
|
|
351
|
+
}
|
|
352
|
+
// model context limit + compaction reserve, for the guard (DESIGN.md §6.7)
|
|
353
|
+
const limit = createMemo(() => modelContextLimit(api, props.session_id))
|
|
354
|
+
const reserve = () => (api.state.config as { compaction?: { reserved?: number } }).compaction?.reserved ?? 16_384
|
|
355
|
+
// trend + attribution: an effect compares each new size with the previous one
|
|
356
|
+
// (side effects and closure state stay out of the memo graph)
|
|
357
|
+
const [trend, setTrend] = createSignal("")
|
|
358
|
+
let prevTokens = 0
|
|
359
|
+
let prevParts = new Map<string, number>()
|
|
360
|
+
let redNudged = false
|
|
361
|
+
let guardNudged = false
|
|
362
|
+
// the slot is reused across sessions: carrying this over reports a bogus trend and
|
|
363
|
+
// swallows the first red/guard nudge of the session we just moved to
|
|
364
|
+
createEffect(
|
|
365
|
+
on(
|
|
366
|
+
() => props.session_id,
|
|
367
|
+
() => {
|
|
368
|
+
prevTokens = 0
|
|
369
|
+
prevParts = new Map()
|
|
370
|
+
redNudged = false
|
|
371
|
+
guardNudged = false
|
|
372
|
+
setTrend("")
|
|
373
|
+
},
|
|
374
|
+
{ defer: true },
|
|
375
|
+
),
|
|
376
|
+
)
|
|
377
|
+
const partSizes = createMemo(() => {
|
|
378
|
+
const parts = new Map<string, { len: number; key: string }>()
|
|
379
|
+
for (const m of api.state.session.messages(props.session_id)) {
|
|
380
|
+
for (const p of api.state.part(m.id) as unknown as { id: string; type: string; tool?: string; text?: string; state?: { output?: string } }[]) {
|
|
381
|
+
const len = p.type === "tool" ? (p.state?.output?.length ?? 0) : (p.text?.length ?? 0)
|
|
382
|
+
parts.set(p.id, { len, key: p.type === "tool" ? (p.tool ?? "tool") : p.type === "text" ? "text" : p.type })
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
return parts
|
|
386
|
+
})
|
|
387
|
+
createEffect(() => {
|
|
388
|
+
const now = size().tokens
|
|
389
|
+
const parts = partSizes()
|
|
390
|
+
let biggest: { key: string; delta: number } | undefined
|
|
391
|
+
for (const [id, { len, key }] of parts) {
|
|
392
|
+
const delta = len - (prevParts.get(id) ?? 0)
|
|
393
|
+
if (delta > 0 && (!biggest || delta > biggest.delta)) biggest = { key, delta }
|
|
394
|
+
}
|
|
395
|
+
const rise = prevTokens > 0 ? (now - prevTokens) / prevTokens : 0
|
|
396
|
+
if (now !== prevTokens) setTrend(rise >= 0.1 && biggest ? ` ▲ +${Math.round(rise * 100)}% (${biggest.key})` : "")
|
|
397
|
+
prevTokens = now
|
|
398
|
+
prevParts = new Map([...parts].map(([id, v]) => [id, v.len]))
|
|
399
|
+
})
|
|
400
|
+
createEffect(() => {
|
|
401
|
+
const b = band()
|
|
402
|
+
if (b === "red" && !redNudged) {
|
|
403
|
+
redNudged = true
|
|
404
|
+
api.ui.toast({ variant: "warning", message: "context is in the red band (≥64k) — consider /tree → c crop, or /merge a branch", duration: 6000 })
|
|
405
|
+
} else if (b === "low" || b === "healthy") redNudged = false
|
|
406
|
+
const lim = limit()
|
|
407
|
+
if (lim && size().tokens >= lim - reserve() && !guardNudged) {
|
|
408
|
+
guardNudged = true
|
|
409
|
+
api.ui.toast({ variant: "error", message: "OpenCode will auto-compact soon (lossy). Crop or merge first if you want to keep the source material.", duration: 8000 })
|
|
410
|
+
} else if (lim && size().tokens < lim - reserve() * 2) guardNudged = false
|
|
411
|
+
})
|
|
412
|
+
return (
|
|
413
|
+
// the same string the tree header shows, so both surfaces read identically
|
|
414
|
+
<text fg={t[BAND_COLOR[band()]]}>{`${branch() ? `⎇ ${branchLabel(api, props.session_id, branch()!.name, 24)} · ` : ""}${formatContext(size(), limit())}${trend()}`}</text>
|
|
415
|
+
)
|
|
416
|
+
},
|
|
417
|
+
},
|
|
418
|
+
})
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export default { id: "opencode-context-tree", tui }
|