opencode-context-indicator 1.1.6 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +95 -39
- package/index.js +8 -5
- package/lib/commands.js +15 -0
- package/lib/contributors.js +288 -0
- package/lib/limits.js +80 -23
- package/lib/state.js +16 -1
- package/package.json +3 -3
- package/tui.tsx +502 -30
package/tui.tsx
CHANGED
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
* loading"). OpenCode loads it only in the terminal TUI; it is NOT loaded in
|
|
15
15
|
* the Desktop app (Desktop exposes no TUI slot API). It renders the
|
|
16
16
|
* per-category context breakdown produced by the server plugin (index.js) into
|
|
17
|
-
* the session sidebar via `append: "sidebar.content"
|
|
17
|
+
* the session sidebar via `append: "sidebar.content"` and into a full session
|
|
18
|
+
* panel (`append: "session.panel"`) opened by the `cx` slash command / keymap
|
|
19
|
+
* layer registered from an `append: "app"` slot.
|
|
18
20
|
*
|
|
19
21
|
* Data bridge: the server plugin rewrites
|
|
20
22
|
* %TEMP%/opencode-context-indicator-state.json
|
|
@@ -25,7 +27,7 @@
|
|
|
25
27
|
*
|
|
26
28
|
* Runtime requirements: @opentui/core and solid-js are optional peers resolved
|
|
27
29
|
* by OpenCode at runtime. @opentui/solid is a pinned direct dependency (exact
|
|
28
|
-
* 0.5.
|
|
30
|
+
* 0.5.16): OpenCode's TUI loader does not expose a host instance of it, so for
|
|
29
31
|
* npm-installed plugins the JSX pragma would otherwise fail to resolve
|
|
30
32
|
* `@opentui/solid/jsx-runtime` (upstream opencode issue #33884).
|
|
31
33
|
*
|
|
@@ -34,16 +36,34 @@
|
|
|
34
36
|
* for `node_modules` paths, so a mounted slot renders once and then never
|
|
35
37
|
* live-updates (anomalyco/opencode#33884). Rather than freeze a permanent
|
|
36
38
|
* "no data yet" panel, the entry detects that case, logs a single hint and
|
|
37
|
-
* registers no slot
|
|
38
|
-
* `node_modules` for the live
|
|
39
|
+
* registers no slot (neither the sidebar nor the session panel). Install via
|
|
40
|
+
* `file://` from a path outside any `node_modules` for the live surfaces (see
|
|
41
|
+
* README → "Live sidebar").
|
|
39
42
|
*/
|
|
40
43
|
import {Plugin, usePlugin} from "@opencode/plugin/tui"
|
|
41
|
-
import {createSignal, Show} from "solid-js"
|
|
44
|
+
import {createMemo, createSignal, Show} from "solid-js"
|
|
42
45
|
import type {ColorInput} from "@opentui/core"
|
|
43
|
-
import {readFileSync} from "node:fs"
|
|
46
|
+
import {mkdirSync, readFileSync, writeFileSync} from "node:fs"
|
|
44
47
|
import {tmpdir} from "node:os"
|
|
45
|
-
import {join} from "node:path"
|
|
48
|
+
import {basename, join} from "node:path"
|
|
46
49
|
import {fileURLToPath} from "node:url"
|
|
50
|
+
type Contributor = {
|
|
51
|
+
kind: string
|
|
52
|
+
ref: string
|
|
53
|
+
chars: number
|
|
54
|
+
tokensEstimate: number
|
|
55
|
+
tool: string
|
|
56
|
+
preview: string
|
|
57
|
+
}
|
|
58
|
+
import {
|
|
59
|
+
safeText,
|
|
60
|
+
measureContributors,
|
|
61
|
+
contributorLine,
|
|
62
|
+
exportFileName,
|
|
63
|
+
buildRedactedPayload,
|
|
64
|
+
panelDenom,
|
|
65
|
+
panelRole,
|
|
66
|
+
} from "./lib/contributors.js"
|
|
47
67
|
|
|
48
68
|
const STATE_FILE =
|
|
49
69
|
process.env.OPENCODE_CONTEXT_INDICATOR_STATE_FILE ||
|
|
@@ -51,6 +71,11 @@ const STATE_FILE =
|
|
|
51
71
|
const POLL_MS = 1000
|
|
52
72
|
const LABEL_WIDTH = 12
|
|
53
73
|
|
|
74
|
+
// Session-panel contribution name. Session-panel names are shared selection
|
|
75
|
+
// values (not registered claims), so it MUST be plugin-prefixed to avoid
|
|
76
|
+
// colliding with another plugin's panel.
|
|
77
|
+
const PANEL_NAME = "context-indicator.breakdown"
|
|
78
|
+
|
|
54
79
|
// True when this module was loaded from an npm install, i.e. its own file path
|
|
55
80
|
// contains a `node_modules` segment (OpenCode's TUI loader skips the host Solid
|
|
56
81
|
// transform for such paths → slots mount once but never live-update; see the
|
|
@@ -76,11 +101,15 @@ type Categories = {
|
|
|
76
101
|
|
|
77
102
|
type StateEntry = {
|
|
78
103
|
sessionID?: string
|
|
104
|
+
parentID?: string | null
|
|
105
|
+
role?: string
|
|
106
|
+
agent?: string | null
|
|
79
107
|
model?: string
|
|
80
108
|
providerID?: string
|
|
81
109
|
ctx?: number
|
|
82
110
|
input?: number
|
|
83
111
|
usable?: number | null
|
|
112
|
+
reserve?: number | null
|
|
84
113
|
limit?: number | null
|
|
85
114
|
reasoning?: number
|
|
86
115
|
categories?: Categories
|
|
@@ -106,30 +135,125 @@ function token(v: unknown, fallback: Fg): Fg {
|
|
|
106
135
|
return v && typeof v === "object" ? (v as Fg) : fallback
|
|
107
136
|
}
|
|
108
137
|
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
function
|
|
112
|
-
|
|
138
|
+
// Reserved object keys must never be treated as session ids: state.json is
|
|
139
|
+
// untrusted input (mirrors isSafeSessionKey() in lib/estimate.js).
|
|
140
|
+
function isSafeKey(k: string): boolean {
|
|
141
|
+
return k !== "__proto__" && k !== "constructor" && k !== "prototype"
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Whole state.json snapshot ({ sessionID: entry }) or {} on any error. The
|
|
145
|
+
// sidebar resolves one entry from it and the breakdown panel walks the
|
|
146
|
+
// parentID family over it. Never throws — a missing / empty / corrupt
|
|
147
|
+
// file simply yields {}.
|
|
148
|
+
function readSnapshot(): Record<string, StateEntry> {
|
|
113
149
|
try {
|
|
114
150
|
const raw = readFileSync(STATE_FILE, "utf8")
|
|
115
151
|
const all = JSON.parse(raw) as Record<string, StateEntry>
|
|
116
|
-
if (!all || typeof all !== "object" || Array.isArray(all)) return
|
|
117
|
-
|
|
118
|
-
|
|
152
|
+
if (!all || typeof all !== "object" || Array.isArray(all)) return {}
|
|
153
|
+
// Rebuild into a fresh plain object: drops prototype-poisoning keys
|
|
154
|
+
// (__proto__ / constructor / prototype) and non-object entries in one pass.
|
|
155
|
+
const clean: Record<string, StateEntry> = {}
|
|
156
|
+
for (const [k, v] of Object.entries(all)) {
|
|
157
|
+
if (isSafeKey(k) && v && typeof v === "object") clean[k] = v
|
|
158
|
+
}
|
|
159
|
+
return clean
|
|
119
160
|
} catch {
|
|
120
|
-
return
|
|
161
|
+
return {}
|
|
121
162
|
}
|
|
122
163
|
}
|
|
123
164
|
|
|
165
|
+
// True when an entry carries no measured tokens at all (a registered session
|
|
166
|
+
// that never served a step). Mirrors isEmptyStateEntry() in lib/commands.js:
|
|
167
|
+
// `categories` is always persisted, so the emptiness test SUMS its fields —
|
|
168
|
+
// a missing categories object is not the only trigger. `system` / `toolSchemas`
|
|
169
|
+
// may legitimately be null, hence the num() coercion.
|
|
170
|
+
function isEmptyEntry(e: StateEntry | undefined): boolean {
|
|
171
|
+
const c = e?.categories
|
|
172
|
+
const sum =
|
|
173
|
+
num(c?.user) +
|
|
174
|
+
num(c?.assistant) +
|
|
175
|
+
num(c?.reasoning) +
|
|
176
|
+
num(c?.toolArgs) +
|
|
177
|
+
num(c?.system) +
|
|
178
|
+
num(c?.toolSchemas) +
|
|
179
|
+
num(c?.other)
|
|
180
|
+
return sum === 0 && num(e?.ctx) === 0 && num(e?.input) === 0
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// The panel row set: the root entry (panel.sessionID) plus every entry whose
|
|
184
|
+
// parentID chain reaches it, breadth-first. A small local walk over the snapshot
|
|
185
|
+
// — the server's collectDescendantSessionIDs lives in lib/commands.js and is NOT
|
|
186
|
+
// imported here (tui.tsx stays independent of the server code by design). A
|
|
187
|
+
// phantom node (no tokens, see isEmptyEntry) is not emitted, but traversal still
|
|
188
|
+
// descends through it so real grandchildren behind it are found. `seen` guards
|
|
189
|
+
// against cycles (parentID loops) and double-listing.
|
|
190
|
+
function collectFamily(
|
|
191
|
+
snapshot: Record<string, StateEntry>,
|
|
192
|
+
rootID: string,
|
|
193
|
+
): StateEntry[] {
|
|
194
|
+
const out: StateEntry[] = []
|
|
195
|
+
const root = snapshot[rootID]
|
|
196
|
+
if (root && typeof root === "object") out.push(root)
|
|
197
|
+
const seen = new Set<string>([rootID])
|
|
198
|
+
const queue: string[] = [rootID]
|
|
199
|
+
// Index the snapshot by parentID once (O(n)) instead of an O(n) scan per node.
|
|
200
|
+
const byParent = new Map<string, string[]>()
|
|
201
|
+
for (const [id, e] of Object.entries(snapshot)) {
|
|
202
|
+
const pid = e?.parentID
|
|
203
|
+
if (!pid || typeof pid !== "string") continue
|
|
204
|
+
const kids = byParent.get(pid)
|
|
205
|
+
if (kids) kids.push(id)
|
|
206
|
+
else byParent.set(pid, [id])
|
|
207
|
+
}
|
|
208
|
+
while (queue.length > 0) {
|
|
209
|
+
const cur = queue.shift() as string
|
|
210
|
+
const kids = byParent.get(cur)
|
|
211
|
+
if (!kids) continue
|
|
212
|
+
for (const id of kids) {
|
|
213
|
+
if (!id || seen.has(id)) continue
|
|
214
|
+
seen.add(id)
|
|
215
|
+
const e = snapshot[id]
|
|
216
|
+
if (e && !isEmptyEntry(e)) out.push(e)
|
|
217
|
+
queue.push(id) // descend through phantom parents too
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return out
|
|
221
|
+
}
|
|
222
|
+
|
|
124
223
|
function updatedAt(iso: string | undefined): string {
|
|
125
224
|
if (!iso) return "?"
|
|
126
225
|
try {
|
|
127
|
-
|
|
226
|
+
const d = new Date(iso)
|
|
227
|
+
return Number.isNaN(d.getTime()) ? "?" : d.toLocaleTimeString()
|
|
128
228
|
} catch {
|
|
129
229
|
return "?"
|
|
130
230
|
}
|
|
131
231
|
}
|
|
132
232
|
|
|
233
|
+
// Code-point-safe column fit: truncate on code points (never splitting a
|
|
234
|
+
// surrogate pair at a column boundary), mark a truncated cell with a trailing
|
|
235
|
+
// "~" so clipping is VISIBLE (a silently clipped cell reads as two columns
|
|
236
|
+
// colliding), then pad to the column width.
|
|
237
|
+
function fit(s: string, w: number): string {
|
|
238
|
+
const cps = Array.from(s)
|
|
239
|
+
if (cps.length > w) return `${cps.slice(0, Math.max(0, w - 1)).join("")}~`
|
|
240
|
+
return s.padEnd(w)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
// Measured contributors (TUI-only). The TUI data layer already holds the
|
|
245
|
+
// session's live message list in-process, so the panel can char-count each
|
|
246
|
+
// message part WITHOUT an LLM call and WITHOUT shipping message text anywhere.
|
|
247
|
+
// Only the char count, the message ordinal and a kind string leave
|
|
248
|
+
// measureContributors(); message text is read, measured and discarded. Tool
|
|
249
|
+
// RESULT outputs are counted here — the server's state.json categories never
|
|
250
|
+
// see them — which is what makes the residual `other` explicable.
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
// The TUI message data API is optional across host versions. Log a single hint
|
|
254
|
+
// (never once per poll) when it is unavailable and hide the whole section.
|
|
255
|
+
let warnedMessagesUnavailable = false
|
|
256
|
+
|
|
133
257
|
function Sidebar(props: { entry: () => StateEntry | null }) {
|
|
134
258
|
const context = usePlugin()
|
|
135
259
|
const base = () => token(context?.theme?.text?.base, "white")
|
|
@@ -187,32 +311,333 @@ function Sidebar(props: { entry: () => StateEntry | null }) {
|
|
|
187
311
|
)
|
|
188
312
|
}
|
|
189
313
|
|
|
314
|
+
// Host-owned session.panel input ({ name, sessionID, width, presentation,
|
|
315
|
+
// focused, focus(), close(), toggleFullscreen() }). Typed structurally instead
|
|
316
|
+
// of imported so this file stays independent of the host type package; the host
|
|
317
|
+
// resolves the panel API at runtime.
|
|
318
|
+
type PanelInput = {
|
|
319
|
+
readonly name: string
|
|
320
|
+
readonly sessionID: string
|
|
321
|
+
readonly width: number
|
|
322
|
+
readonly presentation: "panel" | "fullscreen"
|
|
323
|
+
readonly focused: boolean
|
|
324
|
+
readonly focus: () => void
|
|
325
|
+
readonly close: () => void
|
|
326
|
+
readonly toggleFullscreen: () => void
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Panel column layout (monospace), derived from the host-provided panel width
|
|
330
|
+
// so cells never collide on narrow terminals. Gaps between columns keep a
|
|
331
|
+
// truncated cell visually separated from its neighbour.
|
|
332
|
+
const GUTTER = 2
|
|
333
|
+
const CTX_WIDTH = 12
|
|
334
|
+
|
|
335
|
+
function panelLayout(panelWidth: number): {
|
|
336
|
+
roleW: number
|
|
337
|
+
modelW: number
|
|
338
|
+
ctxW: number
|
|
339
|
+
updW: number
|
|
340
|
+
gutter: string
|
|
341
|
+
} {
|
|
342
|
+
const w = num(panelWidth) > 0 ? num(panelWidth) : 60
|
|
343
|
+
const ctxW = CTX_WIDTH
|
|
344
|
+
const updW = Math.max(5, Math.min(8, w - ctxW - 3 * GUTTER - 24))
|
|
345
|
+
const roleW = Math.max(12, Math.min(20, Math.floor(w * 0.3)))
|
|
346
|
+
const modelW = Math.max(10, w - roleW - ctxW - updW - 3 * GUTTER)
|
|
347
|
+
return { roleW, modelW, ctxW, updW, gutter: " ".repeat(GUTTER) }
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Full session panel: header + one row per session in the family (root plus
|
|
351
|
+
// subagent descendants). It reads the same state.json bridge as the sidebar via
|
|
352
|
+
// the shared 1 s tick, so both surfaces refresh on ONE poll. `panel.sessionID`
|
|
353
|
+
// selects the root; `cx` opens the panel (registered from the "app" slot below).
|
|
354
|
+
function BreakdownPanel(props: {
|
|
355
|
+
panel: PanelInput
|
|
356
|
+
snapshot: () => Record<string, StateEntry>
|
|
357
|
+
refresh: () => void
|
|
358
|
+
}) {
|
|
359
|
+
const context = usePlugin()
|
|
360
|
+
const base = () => token(context?.theme?.text?.base, "white")
|
|
361
|
+
const muted = () => token(context?.theme?.text?.muted, base())
|
|
362
|
+
|
|
363
|
+
// Panel-local keys. The layer is owned by this component and disposed with it.
|
|
364
|
+
// mode "global" + an explicit focused check is the portable fallback when no
|
|
365
|
+
// target renderable is available: the keys act only while the panel owns input.
|
|
366
|
+
// The capability guard keeps an older host (no keymap API) from throwing.
|
|
367
|
+
const keymap = context?.keymap
|
|
368
|
+
if (keymap && typeof keymap.layer === "function") {
|
|
369
|
+
keymap.layer(() => ({
|
|
370
|
+
mode: "global",
|
|
371
|
+
priority: -1,
|
|
372
|
+
commands: [
|
|
373
|
+
{
|
|
374
|
+
id: "context-indicator.breakdown.close",
|
|
375
|
+
title: "Close the context breakdown panel",
|
|
376
|
+
bind: "escape",
|
|
377
|
+
enabled: () => props.panel.focused,
|
|
378
|
+
run: () => {
|
|
379
|
+
if (props.panel.focused) props.panel.close()
|
|
380
|
+
},
|
|
381
|
+
},
|
|
382
|
+
{
|
|
383
|
+
id: "context-indicator.breakdown.refresh",
|
|
384
|
+
title: "Refresh the context breakdown panel",
|
|
385
|
+
bind: "r",
|
|
386
|
+
enabled: () => props.panel.focused,
|
|
387
|
+
run: () => {
|
|
388
|
+
if (props.panel.focused) props.refresh()
|
|
389
|
+
},
|
|
390
|
+
},
|
|
391
|
+
{
|
|
392
|
+
id: "context-indicator.breakdown.export",
|
|
393
|
+
title: "Export a redacted breakdown snapshot",
|
|
394
|
+
bind: "e",
|
|
395
|
+
enabled: () => props.panel.focused,
|
|
396
|
+
run: () => {
|
|
397
|
+
if (props.panel.focused) exportRedacted()
|
|
398
|
+
},
|
|
399
|
+
},
|
|
400
|
+
],
|
|
401
|
+
}))
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// One snapshot read per poll tick (props.snapshot tracks the shared tick), so
|
|
405
|
+
// the memo recomputes together with the sidebar instead of polling separately.
|
|
406
|
+
const data = createMemo(() => {
|
|
407
|
+
const snap = props.snapshot()
|
|
408
|
+
return {
|
|
409
|
+
root: snap[props.panel.sessionID],
|
|
410
|
+
rows: collectFamily(snap, props.panel.sessionID),
|
|
411
|
+
}
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
// Live message list from the TUI data layer (in-process, no LLM call). The
|
|
415
|
+
// shared `props.snapshot()` tick is read FIRST so this re-reads every poll.
|
|
416
|
+
// `available: false` means the API is absent/errored -> the WHOLE measured
|
|
417
|
+
// section is hidden (and a one-time hint was logged).
|
|
418
|
+
const messages = createMemo<{ available: boolean; list: unknown[] }>(() => {
|
|
419
|
+
props.snapshot()
|
|
420
|
+
try {
|
|
421
|
+
const api = context?.data?.session?.message
|
|
422
|
+
if (!api || typeof api.list !== "function") {
|
|
423
|
+
if (!warnedMessagesUnavailable) {
|
|
424
|
+
warnedMessagesUnavailable = true
|
|
425
|
+
console.error(
|
|
426
|
+
"[context-indicator] TUI message data API unavailable; " +
|
|
427
|
+
"measured contributors section hidden",
|
|
428
|
+
)
|
|
429
|
+
}
|
|
430
|
+
return { available: false, list: [] }
|
|
431
|
+
}
|
|
432
|
+
const v = api.list(props.panel.sessionID)
|
|
433
|
+
return { available: true, list: Array.isArray(v) ? v : [] }
|
|
434
|
+
} catch {
|
|
435
|
+
if (!warnedMessagesUnavailable) {
|
|
436
|
+
warnedMessagesUnavailable = true
|
|
437
|
+
console.error(
|
|
438
|
+
"[context-indicator] TUI message read failed; " +
|
|
439
|
+
"measured contributors section hidden",
|
|
440
|
+
)
|
|
441
|
+
}
|
|
442
|
+
return { available: false, list: [] }
|
|
443
|
+
}
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
const contributors = createMemo<Contributor[]>(() => {
|
|
447
|
+
const m = messages()
|
|
448
|
+
if (!m.available) return []
|
|
449
|
+
try {
|
|
450
|
+
return measureContributors(m.list)
|
|
451
|
+
} catch {
|
|
452
|
+
return []
|
|
453
|
+
}
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
// Redacted export (key `e`). No message text, no full session id, no tool
|
|
457
|
+
// output content ever leaves this function. Never throws: a failure logs and
|
|
458
|
+
// shows an error toast instead.
|
|
459
|
+
function exportRedacted(): void {
|
|
460
|
+
let exportedName: string | null = null
|
|
461
|
+
try {
|
|
462
|
+
const now = new Date()
|
|
463
|
+
const name = exportFileName(props.panel.sessionID, now)
|
|
464
|
+
const payload = buildRedactedPayload({
|
|
465
|
+
sessionID: props.panel.sessionID,
|
|
466
|
+
root: data().root,
|
|
467
|
+
rows: data().rows,
|
|
468
|
+
contributors: contributors(),
|
|
469
|
+
now,
|
|
470
|
+
panelDenom,
|
|
471
|
+
panelRole,
|
|
472
|
+
num,
|
|
473
|
+
})
|
|
474
|
+
const dir = join(tmpdir(), "opencode-context-indicator", "exports")
|
|
475
|
+
const file = join(dir, name)
|
|
476
|
+
mkdirSync(dir, { recursive: true })
|
|
477
|
+
writeFileSync(file, JSON.stringify(payload, null, 2), "utf8")
|
|
478
|
+
exportedName = basename(file)
|
|
479
|
+
} catch (err) {
|
|
480
|
+
console.error("[context-indicator] redacted export failed:", err)
|
|
481
|
+
try {
|
|
482
|
+
context?.ui?.toast?.show({
|
|
483
|
+
message: "redacted export failed",
|
|
484
|
+
variant: "error",
|
|
485
|
+
})
|
|
486
|
+
} catch {
|
|
487
|
+
/* toast is best-effort */
|
|
488
|
+
}
|
|
489
|
+
return
|
|
490
|
+
}
|
|
491
|
+
// Outside the write try: a toast failure must not report the export as
|
|
492
|
+
// failed when the file is already on disk.
|
|
493
|
+
try {
|
|
494
|
+
context?.ui?.toast?.show({
|
|
495
|
+
message: `exported ${exportedName}`,
|
|
496
|
+
variant: "success",
|
|
497
|
+
})
|
|
498
|
+
} catch {
|
|
499
|
+
/* toast is best-effort */
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const header = (): string => {
|
|
504
|
+
const r = data().root
|
|
505
|
+
if (!r) return "Context breakdown"
|
|
506
|
+
const model = safeText(r.model && r.model !== "unknown" ? r.model : "model?")
|
|
507
|
+
const ctx = fmt(num(r.ctx))
|
|
508
|
+
const d = panelDenom(r)
|
|
509
|
+
return d > 0
|
|
510
|
+
? `Context breakdown \u2014 ${model} ${ctx}/${fmt(d)} (${Math.round(
|
|
511
|
+
(num(r.ctx) / d) * 100,
|
|
512
|
+
)}%)`
|
|
513
|
+
: `Context breakdown \u2014 ${model} ${ctx}`
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const layout = (): {
|
|
517
|
+
roleW: number
|
|
518
|
+
modelW: number
|
|
519
|
+
ctxW: number
|
|
520
|
+
updW: number
|
|
521
|
+
gutter: string
|
|
522
|
+
} => panelLayout(props.panel.width)
|
|
523
|
+
|
|
524
|
+
const columns = (): string => {
|
|
525
|
+
const L = layout()
|
|
526
|
+
return (
|
|
527
|
+
fit("role", L.roleW) +
|
|
528
|
+
L.gutter +
|
|
529
|
+
fit("model", L.modelW) +
|
|
530
|
+
L.gutter +
|
|
531
|
+
fit("ctx", L.ctxW) +
|
|
532
|
+
L.gutter +
|
|
533
|
+
"updated"
|
|
534
|
+
)
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const row = (e: StateEntry, isMain: boolean): string => {
|
|
538
|
+
const model = safeText(e.model && e.model !== "unknown" ? e.model : "model?")
|
|
539
|
+
const d = panelDenom(e)
|
|
540
|
+
const ctx =
|
|
541
|
+
d > 0
|
|
542
|
+
? `${fmt(num(e.ctx))} (${Math.round((num(e.ctx) / d) * 100)}%)`
|
|
543
|
+
: fmt(num(e.ctx))
|
|
544
|
+
const L = layout()
|
|
545
|
+
return (
|
|
546
|
+
fit(panelRole(e, isMain), L.roleW) +
|
|
547
|
+
L.gutter +
|
|
548
|
+
fit(model, L.modelW) +
|
|
549
|
+
L.gutter +
|
|
550
|
+
fit(ctx, L.ctxW) +
|
|
551
|
+
L.gutter +
|
|
552
|
+
updatedAt(e.updatedAt)
|
|
553
|
+
)
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
return (
|
|
557
|
+
<box flexDirection="column">
|
|
558
|
+
<text fg={base()}>{header()}</text>
|
|
559
|
+
<Show
|
|
560
|
+
when={data().root}
|
|
561
|
+
fallback={<text fg={muted()}>{" no data yet"}</text>}
|
|
562
|
+
>
|
|
563
|
+
<text fg={muted()}>{columns()}</text>
|
|
564
|
+
{data().rows.map((e, i) => (
|
|
565
|
+
<text fg={muted()}>{row(e, i === 0)}</text>
|
|
566
|
+
))}
|
|
567
|
+
<Show
|
|
568
|
+
when={
|
|
569
|
+
(data().root?.usable ?? 0) > 0
|
|
570
|
+
}
|
|
571
|
+
>
|
|
572
|
+
<text fg={muted()}>
|
|
573
|
+
{" compact at " +
|
|
574
|
+
fmt(num(data().root?.usable)) +
|
|
575
|
+
(num(data().root?.reserve) > 0
|
|
576
|
+
? " (" + fmt(num(data().root?.reserve)) + " reserve)"
|
|
577
|
+
: "")}
|
|
578
|
+
</text>
|
|
579
|
+
</Show>
|
|
580
|
+
<Show when={messages().available}>
|
|
581
|
+
<text fg={base()}>{"largest contributors (measured)"}</text>
|
|
582
|
+
<Show
|
|
583
|
+
when={contributors().length > 0}
|
|
584
|
+
fallback={<text fg={muted()}>{" no messages yet"}</text>}
|
|
585
|
+
>
|
|
586
|
+
{contributors()
|
|
587
|
+
.slice(0, 5)
|
|
588
|
+
.map((c, i) => (
|
|
589
|
+
<text fg={muted()}>{contributorLine(c, i + 1)}</text>
|
|
590
|
+
))}
|
|
591
|
+
</Show>
|
|
592
|
+
<text fg={muted()}>
|
|
593
|
+
{
|
|
594
|
+
"measured != server categories (tool outputs counted here; server categories exclude them)"
|
|
595
|
+
}
|
|
596
|
+
</text>
|
|
597
|
+
</Show>
|
|
598
|
+
</Show>
|
|
599
|
+
</box>
|
|
600
|
+
)
|
|
601
|
+
}
|
|
602
|
+
|
|
190
603
|
export default Plugin.define({
|
|
191
604
|
id: "context-indicator.tui",
|
|
192
605
|
setup(context) {
|
|
193
606
|
// npm installs (path under node_modules) cannot get live slot updates on
|
|
194
607
|
// this OpenCode version. Stay inert and log a single hint instead of
|
|
195
|
-
// registering
|
|
608
|
+
// registering slots that would freeze on "no data yet".
|
|
196
609
|
if (isNodeModulesInstall()) {
|
|
197
610
|
console.log(
|
|
198
|
-
"[context-indicator] TUI sidebar
|
|
199
|
-
"live updates on this OpenCode
|
|
200
|
-
|
|
201
|
-
"
|
|
611
|
+
"[context-indicator] TUI sidebar and breakdown panel disabled: " +
|
|
612
|
+
"npm-installed plugins cannot get live updates on this OpenCode " +
|
|
613
|
+
"version (anomalyco/opencode#33884). Install via file:// (see README " +
|
|
614
|
+
'→ "Live sidebar") for the live sidebar and panel. The /context slash ' +
|
|
615
|
+
"commands still work everywhere.",
|
|
202
616
|
)
|
|
203
617
|
return
|
|
204
618
|
}
|
|
205
619
|
|
|
206
|
-
// Re-read the bridge file once per second and re-render reactively.
|
|
620
|
+
// Re-read the bridge file once per second and re-render reactively. ONE
|
|
621
|
+
// ticker drives BOTH surfaces, and the snapshot is cached per tick: every
|
|
622
|
+
// consumer (sidebar, panel data, messages) shares a single readFileSync.
|
|
207
623
|
const [tick, setTick] = createSignal(0)
|
|
208
624
|
let currentSession: string | undefined
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
625
|
+
let snapCache: Record<string, StateEntry> | null = null
|
|
626
|
+
let snapAt = -1
|
|
627
|
+
const snapshot = (): Record<string, StateEntry> => {
|
|
628
|
+
const t = tick() // track the ticker so reads re-run every poll
|
|
629
|
+
if (snapCache === null || snapAt !== t) {
|
|
630
|
+
snapCache = readSnapshot()
|
|
631
|
+
snapAt = t
|
|
632
|
+
}
|
|
633
|
+
return snapCache
|
|
212
634
|
}
|
|
635
|
+
const entry = (): StateEntry | null =>
|
|
636
|
+
snapshot()[currentSession ?? ""] ?? null
|
|
637
|
+
const refresh = () => setTick((n) => n + 1)
|
|
213
638
|
const timer = setInterval(() => setTick((n) => n + 1), POLL_MS)
|
|
214
639
|
|
|
215
|
-
const
|
|
640
|
+
const unregisterSidebar = context.ui.slot({
|
|
216
641
|
append: "sidebar.content",
|
|
217
642
|
render: ({ sessionID }) => {
|
|
218
643
|
currentSession = sessionID
|
|
@@ -220,12 +645,59 @@ export default Plugin.define({
|
|
|
220
645
|
},
|
|
221
646
|
})
|
|
222
647
|
|
|
648
|
+
// A `session.panel` contribution is independently selectable: it renders
|
|
649
|
+
// only while its name is the selected one, and the root session comes from
|
|
650
|
+
// panel.sessionID.
|
|
651
|
+
const unregisterPanel = context.ui.slot({
|
|
652
|
+
append: "session.panel",
|
|
653
|
+
render: (panel) => (
|
|
654
|
+
<Show when={panel.name === PANEL_NAME}>
|
|
655
|
+
<BreakdownPanel panel={panel} snapshot={snapshot} refresh={refresh} />
|
|
656
|
+
</Show>
|
|
657
|
+
),
|
|
658
|
+
})
|
|
659
|
+
|
|
660
|
+
// The `app` slot renders nothing itself; it exists to register the global
|
|
661
|
+
// keymap layer (slash command `cx` -> open the panel). The layer is owned by
|
|
662
|
+
// this slot and disposed when the slot unregisters.
|
|
663
|
+
const unregisterCommands = context.ui.slot({
|
|
664
|
+
append: "app",
|
|
665
|
+
render: () => {
|
|
666
|
+
const keymap = context?.keymap
|
|
667
|
+
if (keymap && typeof keymap.layer === "function") {
|
|
668
|
+
keymap.layer(() => ({
|
|
669
|
+
mode: "global",
|
|
670
|
+
commands: [
|
|
671
|
+
{
|
|
672
|
+
id: "context-indicator.breakdown",
|
|
673
|
+
title: "Context breakdown panel",
|
|
674
|
+
slash: { name: "cx" },
|
|
675
|
+
run: () => {
|
|
676
|
+
const panelApi = context?.ui?.panel
|
|
677
|
+
if (panelApi && typeof panelApi.open === "function") {
|
|
678
|
+
panelApi.open(PANEL_NAME)
|
|
679
|
+
}
|
|
680
|
+
},
|
|
681
|
+
},
|
|
682
|
+
],
|
|
683
|
+
}))
|
|
684
|
+
}
|
|
685
|
+
return null
|
|
686
|
+
},
|
|
687
|
+
})
|
|
688
|
+
|
|
223
689
|
return () => {
|
|
224
690
|
clearInterval(timer)
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
691
|
+
for (const unregister of [
|
|
692
|
+
unregisterSidebar,
|
|
693
|
+
unregisterPanel,
|
|
694
|
+
unregisterCommands,
|
|
695
|
+
]) {
|
|
696
|
+
try {
|
|
697
|
+
unregister?.()
|
|
698
|
+
} catch {
|
|
699
|
+
/* ignore */
|
|
700
|
+
}
|
|
229
701
|
}
|
|
230
702
|
}
|
|
231
703
|
},
|