opencode-context-indicator 1.1.6 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -38
- package/package.json +1 -1
- package/tui.tsx +710 -29
package/README.md
CHANGED
|
@@ -8,8 +8,13 @@ filling it: system prompt, tool schemas, user/assistant text, reasoning, tool
|
|
|
8
8
|
arguments and the residual "other" bucket.
|
|
9
9
|
|
|
10
10
|
The package ships a **dual V1 + V2 plugin** (one entry works in both OpenCode
|
|
11
|
-
generations) plus an optional TUI
|
|
12
|
-
|
|
11
|
+
generations) plus an optional TUI module (`./tui` entry) that renders a **live
|
|
12
|
+
sidebar** and a **zero-LLM `/cx` breakdown panel** with measured contributors
|
|
13
|
+
and a redacted export. The V2 path is **tested against opencode v2.0.22**; the
|
|
14
|
+
V1 path against 1.18.29+.
|
|
15
|
+
|
|
16
|
+
> **V1 is feature-frozen.** It is kept working for compatibility (bug fixes
|
|
17
|
+
> only); all new features land in the V2 path.
|
|
13
18
|
|
|
14
19
|

|
|
15
20
|
|
|
@@ -38,8 +43,10 @@ For every served request the plugin estimates how the context window is split:
|
|
|
38
43
|
(Cyrillic ≈ 2.5, CJK ≈ 1.5, latin/ASCII ≈ 4 chars/token) — not a tokenizer.
|
|
39
44
|
`reasoning` also carries the exact value reported by the model when present.
|
|
40
45
|
* `other` is the residual (`input − sum of estimates`).
|
|
41
|
-
* Tool **results** are deliberately not counted
|
|
42
|
-
|
|
46
|
+
* Tool **results** are deliberately not counted in the server-side categories;
|
|
47
|
+
only tool **call arguments** are. The TUI panel's *largest contributors*
|
|
48
|
+
section **does** measure retained results — that is why it can explain the
|
|
49
|
+
(often large) residual `other` bucket.
|
|
43
50
|
* `ctx` is OpenCode's native overflow count: `tokens.total` when present, else
|
|
44
51
|
`input + output + cache.read + cache.write`. Percentages are **not clamped**
|
|
45
52
|
at 100% — exceeding the window stays visible.
|
|
@@ -218,23 +225,57 @@ back to a plain notice if needed.
|
|
|
218
225
|
is evicted by the `state.json` LRU cap (`MAX_TRACKED_SESSIONS`) — it may
|
|
219
226
|
momentarily appear in the table.
|
|
220
227
|
|
|
228
|
+
### TUI session panel (V2, terminal only)
|
|
229
|
+
|
|
230
|
+
#### TUI panel (/cx) — zero-LLM breakdown
|
|
231
|
+
|
|
232
|
+
`/cx` opens a full `session.panel` showing the breakdown for the current session
|
|
233
|
+
**and its subagent descendants**, read from the same `state.json` bridge as the
|
|
234
|
+
sidebar. Keys: `/cx` opens, `esc` closes, `r` refreshes (re-read the bridge),
|
|
235
|
+
`e` exports a redacted snapshot.
|
|
236
|
+
|
|
237
|
+
**Zero LLM calls.** Unlike `/context` and `/context-breakdown` (which run a real
|
|
238
|
+
agent turn so the model renders the table), the panel only reads local TUI data
|
|
239
|
+
and the state file; it never wakes the session and never grows the context.
|
|
240
|
+
|
|
241
|
+
The **largest contributors (measured)** section char-counts the in-process
|
|
242
|
+
message list and labels each row `assistant 21:04 reasoning — 4.5k "…"`: role
|
|
243
|
+
in full words, the message's own clock time, the bucket (`reasoning` /
|
|
244
|
+
`tool args (bash)` / `tool result (task)`), the token estimate and a bounded
|
|
245
|
+
one-line preview of that part. Tool calls are measured **per tool call** (each
|
|
246
|
+
carries its tool name); tool **result outputs** are counted here but not by the
|
|
247
|
+
server's categories — which is why the residual `other` can look large. Top 5
|
|
248
|
+
are shown. Empty state: `no messages yet`.
|
|
249
|
+
|
|
250
|
+
Live only for a `file://` install from a path outside `node_modules`; the npm
|
|
251
|
+
path auto-disables it (upstream `#33884`). The Desktop has no TUI, so it uses
|
|
252
|
+
`/context` and `/context-breakdown` instead.
|
|
253
|
+
|
|
254
|
+
#### Export (redacted)
|
|
255
|
+
|
|
256
|
+
`e` writes a redacted JSON snapshot — no message text, no full session id, no
|
|
257
|
+
tool output content — under `%TEMP%/opencode-context-indicator/exports/`
|
|
258
|
+
(`$TMPDIR/...` elsewhere), named `context-<id8>-<yyyymmdd-HHMMSSmmm>.json`, and
|
|
259
|
+
shows a toast with the file name. It never throws; on failure it logs and shows
|
|
260
|
+
an error toast.
|
|
261
|
+
|
|
221
262
|
---
|
|
222
263
|
|
|
223
264
|
## Surface matrix
|
|
224
265
|
|
|
225
|
-
| Surface | Toast | TUI sidebar | Breakdown log file | Slash commands |
|
|
226
|
-
| --- | :---: | :---: | :---: | :---: |
|
|
227
|
-
| OpenCode **1.x** — Desktop | ✅ | — | ✅ | — |
|
|
228
|
-
| OpenCode **1.x** — TUI | ✅ | — | ✅ | — |
|
|
229
|
-
| OpenCode **2.x** — TUI | — | ✅ | ✅ | ✅ |
|
|
230
|
-
| OpenCode **2.x** — Desktop | — | — | ✅ | ✅ |
|
|
266
|
+
| Surface | Toast | TUI sidebar | TUI panel (/cx) | Breakdown log file | Slash commands |
|
|
267
|
+
| --- | :---: | :---: | :---: | :---: | :---: |
|
|
268
|
+
| OpenCode **1.x** — Desktop | ✅ | — | — | ✅ | — |
|
|
269
|
+
| OpenCode **1.x** — TUI | ✅ | — | — | ✅ | — |
|
|
270
|
+
| OpenCode **2.x** — TUI | — | ✅ | ✅ | ✅ | ✅ |
|
|
271
|
+
| OpenCode **2.x** — Desktop | — | — | — | ✅ | ✅ |
|
|
231
272
|
|
|
232
273
|
Notes — these reflect what the plugin API actually exposes today:
|
|
233
274
|
|
|
234
275
|
* **V2 has no toast channel on the server side.** The V2 plugin API
|
|
235
276
|
(`ctx.*`) exposes no TUI/toast method (opencode issue `#49380`); the V2 path
|
|
236
|
-
therefore delivers everything through the log file, the TUI sidebar
|
|
237
|
-
`/context` / `/context-breakdown` slash commands. The sidebar is a TUI-process
|
|
277
|
+
therefore delivers everything through the log file, the TUI sidebar, the
|
|
278
|
+
`/cx` session panel and the `/context` / `/context-breakdown` slash commands. The sidebar is a TUI-process
|
|
238
279
|
slot and does not exist in the Desktop app; the slash commands work in **both**.
|
|
239
280
|
The TUI sidebar is **live only for a `file://` install** from a path outside
|
|
240
281
|
`node_modules`; on the npm path it is auto-disabled (upstream `#33884`) — see
|
|
@@ -255,7 +296,7 @@ and `$TMPDIR` (usually `/tmp`) elsewhere:
|
|
|
255
296
|
| File | Purpose |
|
|
256
297
|
| --- | --- |
|
|
257
298
|
| `context-breakdown.log` | Human-readable live snapshot + bounded final summaries |
|
|
258
|
-
| `opencode-context-indicator-state.json` | Machine-readable snapshot consumed by the TUI sidebar |
|
|
299
|
+
| `opencode-context-indicator-state.json` | Machine-readable snapshot consumed by the TUI sidebar and the `/cx` panel |
|
|
259
300
|
| `context-events.log` | Raw event tap — **only** when `DEBUG_EVENTS` is flipped to `true` in the source (off by default) |
|
|
260
301
|
|
|
261
302
|
The live `context-breakdown.log` snapshot is **rewritten** (never grows); final
|
|
@@ -306,15 +347,15 @@ limitation (`anomalyco/opencode#33884`), not a plugin bug: OpenCode's TUI loader
|
|
|
306
347
|
skips the host Solid transform for any path inside a `node_modules` directory, so
|
|
307
348
|
a slot mounted from an npm install renders once and then never live-updates.
|
|
308
349
|
|
|
309
|
-
| Install | Desktop | TUI commands | TUI live sidebar |
|
|
310
|
-
| --- | :---: | :---: | :---: |
|
|
311
|
-
| npm (`opencode plugin add opencode-context-indicator`) | ✅ | ✅ | ❌ auto-disabled (upstream #33884) |
|
|
312
|
-
| `file://` (path **outside** any `node_modules`) | ✅ | ✅ | ✅ live |
|
|
350
|
+
| Install | Desktop | TUI commands | TUI live sidebar | TUI panel (/cx) |
|
|
351
|
+
| --- | :---: | :---: | :---: | :---: |
|
|
352
|
+
| npm (`opencode plugin add opencode-context-indicator`) | ✅ | ✅ | ❌ auto-disabled (upstream #33884) | ❌ auto-disabled |
|
|
353
|
+
| `file://` (path **outside** any `node_modules`) | ✅ | ✅ | ✅ live | ✅ live |
|
|
313
354
|
|
|
314
355
|
On the **npm** path the plugin detects that it lives under `node_modules` and
|
|
315
|
-
**disables the sidebar gracefully** — it logs a single hint and
|
|
316
|
-
slot, so you never see a frozen "no data yet" panel. The `/context`
|
|
317
|
-
`/context-breakdown` slash commands and the breakdown log file keep working on
|
|
356
|
+
**disables the sidebar and the panel gracefully** — it logs a single hint and
|
|
357
|
+
registers no slot, so you never see a frozen "no data yet" panel. The `/context`
|
|
358
|
+
/ `/context-breakdown` slash commands and the breakdown log file keep working on
|
|
318
359
|
every install.
|
|
319
360
|
|
|
320
361
|
#### Getting the live sidebar (`file://` install)
|
|
@@ -350,13 +391,21 @@ Add the package name to the `plugin` array in your `opencode.json`:
|
|
|
350
391
|
|
|
351
392
|
### Local file (no npm)
|
|
352
393
|
|
|
353
|
-
Copy `index.js` and `lib
|
|
354
|
-
the `lib/` subdirectory next to the
|
|
355
|
-
|
|
394
|
+
Copy `index.js` and the whole `lib/` directory into
|
|
395
|
+
`~/.config/opencode/plugins/`, keeping the `lib/` subdirectory next to the
|
|
396
|
+
plugin file so the plugin's relative imports resolve. The `lib/` folder is
|
|
397
|
+
support code, not a separate plugin entry:
|
|
356
398
|
|
|
357
399
|
```
|
|
358
400
|
~/.config/opencode/plugins/context-indicator.js
|
|
401
|
+
~/.config/opencode/plugins/lib/cache.js
|
|
402
|
+
~/.config/opencode/plugins/lib/commands.js
|
|
359
403
|
~/.config/opencode/plugins/lib/dedup.js
|
|
404
|
+
~/.config/opencode/plugins/lib/estimate.js
|
|
405
|
+
~/.config/opencode/plugins/lib/limits.js
|
|
406
|
+
~/.config/opencode/plugins/lib/state.js
|
|
407
|
+
~/.config/opencode/plugins/lib/v1.js
|
|
408
|
+
~/.config/opencode/plugins/lib/v2.js
|
|
360
409
|
```
|
|
361
410
|
|
|
362
411
|
To use the sidebar locally as well, point OpenCode at a `file://` copy of the
|
|
@@ -372,19 +421,21 @@ the sidebar instead.
|
|
|
372
421
|
* **Node.js ≥ 18** (the main plugin uses only Node built-ins). `@opencode/plugin`
|
|
373
422
|
is an **optional** peer dependency: OpenCode resolves it at runtime, and
|
|
374
423
|
`index.js` itself does not import it (the V2 `define` helper is inlined).
|
|
375
|
-
* **OpenCode ≥ 1.18.29** for the V1 path. ⚠️
|
|
376
|
-
|
|
377
|
-
|
|
424
|
+
* **OpenCode ≥ 1.18.29** for the V1 path. ⚠️ **V1 is feature-frozen** — it
|
|
425
|
+
receives bug fixes only; all new features land in the V2 path. The V1 path
|
|
426
|
+
relies on `experimental.chat.*` hooks, which are **experimental** and may
|
|
427
|
+
change or stop firing in future OpenCode 1.x releases; if they do, the
|
|
428
|
+
indicator degrades
|
|
378
429
|
gracefully (the breakdown falls back to throttled `session.messages` fetches
|
|
379
430
|
and toasts keep working).
|
|
380
431
|
* **OpenCode ≥ 2.0.16** for the V2 path (built and verified against
|
|
381
|
-
**2.0.
|
|
432
|
+
**2.0.22**).
|
|
382
433
|
* **TUI sidebar**: terminal TUI only, requires the OpenTUI rendering stack that
|
|
383
434
|
ships with OpenCode. `@opentui/core` and `solid-js` are **optional** peer
|
|
384
435
|
dependencies resolved by OpenCode at runtime; the main plugin installs and runs
|
|
385
436
|
fine without them (the sidebar simply is not available). `@opentui/solid` is
|
|
386
437
|
instead shipped as a **pinned direct dependency** (exact `0.5.14`, matching
|
|
387
|
-
the OpenTUI stack OpenCode 2.0.
|
|
438
|
+
the OpenTUI stack OpenCode 2.0.22 ships): OpenCode's
|
|
388
439
|
TUI loader does not expose a host instance of it, so for npm-installed plugins
|
|
389
440
|
the JSX pragma would otherwise fail to resolve `@opentui/solid/jsx-runtime`
|
|
390
441
|
(upstream: opencode issue #33884 — `node_modules` plugins are excluded from the
|
|
@@ -420,7 +471,10 @@ The package default export is a dual plugin:
|
|
|
420
471
|
experimental transform hooks + toasts.
|
|
421
472
|
|
|
422
473
|
Both paths write the same breakdown log; the V2 path additionally feeds the
|
|
423
|
-
sidebar through `opencode-context-indicator-state.json`.
|
|
474
|
+
sidebar and the `/cx` panel through `opencode-context-indicator-state.json`.
|
|
475
|
+
The `./tui` entry (OpenCode 2.x terminal TUI) renders the sidebar slot and the
|
|
476
|
+
`session.panel` contribution (`/cx`), with measured per-part contributors and
|
|
477
|
+
the redacted export. Cross-instance
|
|
424
478
|
duplicate final summaries are suppressed with an atomic claim marker
|
|
425
479
|
(`lib/dedup.js`). Every file / estimate / subagent path is fault-tolerant: a
|
|
426
480
|
failure is logged and never breaks the main event or toast path.
|
|
@@ -430,14 +484,6 @@ This plugin does not touch `opencode-token-monitor`
|
|
|
430
484
|
|
|
431
485
|
---
|
|
432
486
|
|
|
433
|
-
## Screenshots
|
|
434
|
-
|
|
435
|
-
<!-- TODO: add screenshots -->
|
|
436
|
-
|
|
437
|
-
_(to be added)_
|
|
438
|
-
|
|
439
|
-
---
|
|
440
|
-
|
|
441
487
|
## Contributing
|
|
442
488
|
|
|
443
489
|
Issues and pull requests are welcome. Please keep changes minimal and
|
|
@@ -468,10 +514,12 @@ STEP 80 s, ~3 min total.
|
|
|
468
514
|
|
|
469
515
|
| Step | What | Why |
|
|
470
516
|
|---|---|---|
|
|
471
|
-
| (a) tarball composition | `package.json` `files[]`
|
|
517
|
+
| (a) tarball composition | `package.json` `files[]` expands to the base files plus every `lib/*` module (count asserted dynamically) | no test/node_modules/tsconfig in the tarball |
|
|
472
518
|
| (b) pragma regression | esbuild `--jsx=automatic` → `@opentui/solid/jsx-runtime` present, `react/jsx-runtime` absent | a removed/broken pragma kills the sidebar on npm install |
|
|
473
519
|
| (c) resolve chain | replica install → `import.meta.resolve` for all tui.tsx imports | broken peer/optional dep silently breaks the sidebar |
|
|
474
520
|
| (d) unit harness T1–T12 | all limit/agent/state fix assertions | regressions in modelLimits, writeStateFile, hydrate |
|
|
521
|
+
| (e) corporate-identifier scan | no forbidden identifiers in the published surface + test tree | compliance |
|
|
522
|
+
| (f) non-English scan | no non-ASCII letters (`\p{L}` above U+007F) in any repo file | English-only source rule |
|
|
475
523
|
|
|
476
524
|
**Isolated E2E — `test/e2e-tui.mjs`** (requires the OpenCode CLI)
|
|
477
525
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-context-indicator",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Real-time OpenCode context-window usage indicator: per-category token breakdown written to a log file, plus an optional TUI sidebar. Dual V1 + V2 plugin entry.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
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
|
|
@@ -34,15 +36,16 @@
|
|
|
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"
|
|
47
50
|
|
|
48
51
|
const STATE_FILE =
|
|
@@ -51,6 +54,11 @@ const STATE_FILE =
|
|
|
51
54
|
const POLL_MS = 1000
|
|
52
55
|
const LABEL_WIDTH = 12
|
|
53
56
|
|
|
57
|
+
// Session-panel contribution name. Session-panel names are shared selection
|
|
58
|
+
// values (not registered claims), so it MUST be plugin-prefixed to avoid
|
|
59
|
+
// colliding with another plugin's panel.
|
|
60
|
+
const PANEL_NAME = "context-indicator.breakdown"
|
|
61
|
+
|
|
54
62
|
// True when this module was loaded from an npm install, i.e. its own file path
|
|
55
63
|
// contains a `node_modules` segment (OpenCode's TUI loader skips the host Solid
|
|
56
64
|
// transform for such paths → slots mount once but never live-update; see the
|
|
@@ -76,6 +84,9 @@ type Categories = {
|
|
|
76
84
|
|
|
77
85
|
type StateEntry = {
|
|
78
86
|
sessionID?: string
|
|
87
|
+
parentID?: string | null
|
|
88
|
+
role?: string
|
|
89
|
+
agent?: string | null
|
|
79
90
|
model?: string
|
|
80
91
|
providerID?: string
|
|
81
92
|
ctx?: number
|
|
@@ -106,30 +117,325 @@ function token(v: unknown, fallback: Fg): Fg {
|
|
|
106
117
|
return v && typeof v === "object" ? (v as Fg) : fallback
|
|
107
118
|
}
|
|
108
119
|
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
function
|
|
112
|
-
|
|
120
|
+
// Reserved object keys must never be treated as session ids: state.json is
|
|
121
|
+
// untrusted input (mirrors isSafeSessionKey() in lib/estimate.js).
|
|
122
|
+
function isSafeKey(k: string): boolean {
|
|
123
|
+
return k !== "__proto__" && k !== "constructor" && k !== "prototype"
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Whole state.json snapshot ({ sessionID: entry }) or {} on any error. The
|
|
127
|
+
// sidebar resolves one entry from it and the breakdown panel walks the
|
|
128
|
+
// parentID family over it. Never throws — a missing / empty / corrupt
|
|
129
|
+
// file simply yields {}.
|
|
130
|
+
function readSnapshot(): Record<string, StateEntry> {
|
|
113
131
|
try {
|
|
114
132
|
const raw = readFileSync(STATE_FILE, "utf8")
|
|
115
133
|
const all = JSON.parse(raw) as Record<string, StateEntry>
|
|
116
|
-
if (!all || typeof all !== "object" || Array.isArray(all)) return
|
|
117
|
-
|
|
118
|
-
|
|
134
|
+
if (!all || typeof all !== "object" || Array.isArray(all)) return {}
|
|
135
|
+
// Rebuild into a fresh plain object: drops prototype-poisoning keys
|
|
136
|
+
// (__proto__ / constructor / prototype) and non-object entries in one pass.
|
|
137
|
+
const clean: Record<string, StateEntry> = {}
|
|
138
|
+
for (const [k, v] of Object.entries(all)) {
|
|
139
|
+
if (isSafeKey(k) && v && typeof v === "object") clean[k] = v
|
|
140
|
+
}
|
|
141
|
+
return clean
|
|
119
142
|
} catch {
|
|
120
|
-
return
|
|
143
|
+
return {}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// True when an entry carries no measured tokens at all (a registered session
|
|
148
|
+
// that never served a step). Mirrors isEmptyStateEntry() in lib/commands.js:
|
|
149
|
+
// `categories` is always persisted, so the emptiness test SUMS its fields —
|
|
150
|
+
// a missing categories object is not the only trigger. `system` / `toolSchemas`
|
|
151
|
+
// may legitimately be null, hence the num() coercion.
|
|
152
|
+
function isEmptyEntry(e: StateEntry | undefined): boolean {
|
|
153
|
+
const c = e?.categories
|
|
154
|
+
const sum =
|
|
155
|
+
num(c?.user) +
|
|
156
|
+
num(c?.assistant) +
|
|
157
|
+
num(c?.reasoning) +
|
|
158
|
+
num(c?.toolArgs) +
|
|
159
|
+
num(c?.system) +
|
|
160
|
+
num(c?.toolSchemas) +
|
|
161
|
+
num(c?.other)
|
|
162
|
+
return sum === 0 && num(e?.ctx) === 0 && num(e?.input) === 0
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// The panel row set: the root entry (panel.sessionID) plus every entry whose
|
|
166
|
+
// parentID chain reaches it, breadth-first. A small local walk over the snapshot
|
|
167
|
+
// — the server's collectDescendantSessionIDs lives in lib/commands.js and is NOT
|
|
168
|
+
// imported here (tui.tsx stays independent of the server code by design). A
|
|
169
|
+
// phantom node (no tokens, see isEmptyEntry) is not emitted, but traversal still
|
|
170
|
+
// descends through it so real grandchildren behind it are found. `seen` guards
|
|
171
|
+
// against cycles (parentID loops) and double-listing.
|
|
172
|
+
function collectFamily(
|
|
173
|
+
snapshot: Record<string, StateEntry>,
|
|
174
|
+
rootID: string,
|
|
175
|
+
): StateEntry[] {
|
|
176
|
+
const out: StateEntry[] = []
|
|
177
|
+
const root = snapshot[rootID]
|
|
178
|
+
if (root && typeof root === "object") out.push(root)
|
|
179
|
+
const seen = new Set<string>([rootID])
|
|
180
|
+
const queue: string[] = [rootID]
|
|
181
|
+
// Index the snapshot by parentID once (O(n)) instead of an O(n) scan per node.
|
|
182
|
+
const byParent = new Map<string, string[]>()
|
|
183
|
+
for (const [id, e] of Object.entries(snapshot)) {
|
|
184
|
+
const pid = e?.parentID
|
|
185
|
+
if (!pid || typeof pid !== "string") continue
|
|
186
|
+
const kids = byParent.get(pid)
|
|
187
|
+
if (kids) kids.push(id)
|
|
188
|
+
else byParent.set(pid, [id])
|
|
189
|
+
}
|
|
190
|
+
while (queue.length > 0) {
|
|
191
|
+
const cur = queue.shift() as string
|
|
192
|
+
const kids = byParent.get(cur)
|
|
193
|
+
if (!kids) continue
|
|
194
|
+
for (const id of kids) {
|
|
195
|
+
if (!id || seen.has(id)) continue
|
|
196
|
+
seen.add(id)
|
|
197
|
+
const e = snapshot[id]
|
|
198
|
+
if (e && !isEmptyEntry(e)) out.push(e)
|
|
199
|
+
queue.push(id) // descend through phantom parents too
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return out
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// One-line sanitisation for untrusted labels (agent / model) rendered in the
|
|
206
|
+
// panel: drop zero-width + bidi control chars, collapse C0/C1 controls (incl.
|
|
207
|
+
// CR/LF/TAB) and the line/paragraph separators to spaces, then cap the result at
|
|
208
|
+
// 32 code points (code-point safe). Only labels are ever rendered — never
|
|
209
|
+
// message content.
|
|
210
|
+
function safeText(v: unknown): string {
|
|
211
|
+
try {
|
|
212
|
+
const s = v == null ? "" : String(v)
|
|
213
|
+
const cleaned = s
|
|
214
|
+
.replace(/[\u200B-\u200F\u202A-\u202E\u2066-\u2069]/g, "")
|
|
215
|
+
.replace(/[\u0000-\u001F\u007F-\u009F\u2028\u2029]/g, " ")
|
|
216
|
+
const cps = Array.from(cleaned)
|
|
217
|
+
return cps.length > 32 ? `${cps.slice(0, 31).join("")}~` : cleaned
|
|
218
|
+
} catch {
|
|
219
|
+
return ""
|
|
121
220
|
}
|
|
122
221
|
}
|
|
123
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
|
+
type Contributor = {
|
|
254
|
+
kind: string
|
|
255
|
+
ref: string
|
|
256
|
+
chars: number
|
|
257
|
+
tokensEstimate: number
|
|
258
|
+
// Tool name for the tool-args / tool-result buckets (e.g. "bash"), else "".
|
|
259
|
+
tool: string
|
|
260
|
+
// First characters of THIS bucket's text, control chars stripped. Rendered
|
|
261
|
+
// on the user's own screen only — exportRedacted() never includes it.
|
|
262
|
+
preview: string
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Local copy of the token-estimate core (estimateTokens in lib/estimate.js).
|
|
266
|
+
// tui.tsx is loaded standalone and must NOT import lib/*, so the ~20-line
|
|
267
|
+
// three-density unicode heuristic is duplicated on purpose: Cyrillic ~2.5,
|
|
268
|
+
// CJK ~1.5, latin/ASCII ~4 chars/token. Heuristic, not a tokenizer.
|
|
269
|
+
function estimateTokensLocal(s: string): number {
|
|
270
|
+
let other = 0
|
|
271
|
+
let cyr = 0
|
|
272
|
+
let cjk = 0
|
|
273
|
+
for (let i = 0; i < s.length; i++) {
|
|
274
|
+
const cp = s.codePointAt(i) as number
|
|
275
|
+
if (cp > 0xffff) i++ // astral pair: consume the low surrogate
|
|
276
|
+
if (cp < 0x80) other++
|
|
277
|
+
else if (
|
|
278
|
+
(cp >= 0x0400 && cp <= 0x04ff) || // Cyrillic
|
|
279
|
+
(cp >= 0x0500 && cp <= 0x052f) || // Cyrillic Supplement
|
|
280
|
+
(cp >= 0x2de0 && cp <= 0x2dff) || // Cyrillic Extended-A
|
|
281
|
+
(cp >= 0xa640 && cp <= 0xa69f) || // Cyrillic Extended-B
|
|
282
|
+
(cp >= 0x1c80 && cp <= 0x1c8f) // Cyrillic Extended-C
|
|
283
|
+
)
|
|
284
|
+
cyr++
|
|
285
|
+
else if (
|
|
286
|
+
(cp >= 0x4e00 && cp <= 0x9fff) || // CJK Unified Ideographs
|
|
287
|
+
(cp >= 0x3400 && cp <= 0x4dbf) || // CJK Extension A
|
|
288
|
+
(cp >= 0xf900 && cp <= 0xfaff) || // CJK Compatibility Ideographs
|
|
289
|
+
(cp >= 0x3040 && cp <= 0x30ff) || // Hiragana + Katakana
|
|
290
|
+
(cp >= 0xac00 && cp <= 0xd7af) || // Hangul Syllables
|
|
291
|
+
(cp >= 0x3000 && cp <= 0x303f) || // CJK Symbols and Punctuation
|
|
292
|
+
(cp >= 0xff00 && cp <= 0xffef) // Halfwidth/Fullwidth Forms
|
|
293
|
+
)
|
|
294
|
+
cjk++
|
|
295
|
+
else other++
|
|
296
|
+
}
|
|
297
|
+
return Math.round(other / 4 + cyr / 2.5 + cjk / 1.5)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Live message data is untrusted: coerce every field defensively, never throw.
|
|
301
|
+
function asRecord(v: unknown): Record<string, unknown> | null {
|
|
302
|
+
return v && typeof v === "object" ? (v as Record<string, unknown>) : null
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Char counts per (message, bucket). `asst.text` = assistant text,
|
|
306
|
+
// `asst.rsn` = reasoning, `asst.tool` = tool CALL arguments (streaming raw
|
|
307
|
+
// string or JSON.stringify of the input object), `asst.result` = tool RESULT
|
|
308
|
+
// outputs (text content of completed/error tool states). Sorted descending by
|
|
309
|
+
// estimated tokens.
|
|
310
|
+
function measureContributors(messages: readonly unknown[]): Contributor[] {
|
|
311
|
+
const out: Contributor[] = []
|
|
312
|
+
// Single-line, bounded preview of the bucket text: control chars (incl.
|
|
313
|
+
// CR/LF/TAB and line separators) collapse to spaces, bidi/zero-width stripped,
|
|
314
|
+
// capped at 26 code points with a "~" tail. Local rendering only. An explicit
|
|
315
|
+
// override (e.g. "" for raw tool-args JSON) suppresses the preview.
|
|
316
|
+
const previewOf = (sample: string): string => {
|
|
317
|
+
const cleaned = sample
|
|
318
|
+
.replace(/[\u200B-\u200F\u202A-\u202E\u2066-\u2069]/g, "")
|
|
319
|
+
.replace(/[\u0000-\u001F\u007F-\u009F\u2028\u2029]+/g, " ")
|
|
320
|
+
.trim()
|
|
321
|
+
const cps = Array.from(cleaned)
|
|
322
|
+
return cps.length > 26 ? `${cps.slice(0, 25).join("")}~` : cleaned
|
|
323
|
+
}
|
|
324
|
+
const add = (
|
|
325
|
+
kind: string,
|
|
326
|
+
ref: string,
|
|
327
|
+
sample: string,
|
|
328
|
+
tool = "",
|
|
329
|
+
previewOverride?: string,
|
|
330
|
+
) => {
|
|
331
|
+
if (sample.length === 0) return
|
|
332
|
+
out.push({
|
|
333
|
+
kind,
|
|
334
|
+
ref,
|
|
335
|
+
chars: sample.length,
|
|
336
|
+
tokensEstimate: estimateTokensLocal(sample),
|
|
337
|
+
tool,
|
|
338
|
+
preview: previewOverride ?? previewOf(sample),
|
|
339
|
+
})
|
|
340
|
+
}
|
|
341
|
+
// Human clock ref: "21:04" from the message's own creation time — far more
|
|
342
|
+
// actionable than an array ordinal. Falls back to "#N" when time is absent.
|
|
343
|
+
const hhmm = (ms: unknown): string => {
|
|
344
|
+
if (typeof ms !== "number" || !Number.isFinite(ms) || ms <= 0) return ""
|
|
345
|
+
try {
|
|
346
|
+
const d = new Date(ms)
|
|
347
|
+
if (Number.isNaN(d.getTime())) return ""
|
|
348
|
+
const pad = (x: number) => String(x).padStart(2, "0")
|
|
349
|
+
return `${pad(d.getHours())}:${pad(d.getMinutes())}`
|
|
350
|
+
} catch {
|
|
351
|
+
return ""
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
for (let i = 0; i < messages.length; i++) {
|
|
355
|
+
const m = asRecord(messages[i])
|
|
356
|
+
if (!m) continue
|
|
357
|
+
const timeRec = asRecord(m.time)
|
|
358
|
+
const ref = hhmm(timeRec?.created) || `#${i + 1}`
|
|
359
|
+
const type = typeof m.type === "string" ? m.type : ""
|
|
360
|
+
if (type === "user") {
|
|
361
|
+
if (typeof m.text === "string") add("user.text", ref, m.text)
|
|
362
|
+
continue
|
|
363
|
+
}
|
|
364
|
+
if (type !== "assistant") continue
|
|
365
|
+
const content = Array.isArray(m.content) ? m.content : []
|
|
366
|
+
let text = ""
|
|
367
|
+
let rsn = ""
|
|
368
|
+
for (const rawPart of content) {
|
|
369
|
+
const part = asRecord(rawPart)
|
|
370
|
+
if (!part) continue
|
|
371
|
+
const pt = typeof part.type === "string" ? part.type : ""
|
|
372
|
+
if (pt === "text") {
|
|
373
|
+
if (typeof part.text === "string") text += part.text
|
|
374
|
+
} else if (pt === "reasoning") {
|
|
375
|
+
if (typeof part.text === "string") rsn += part.text
|
|
376
|
+
} else if (pt === "tool") {
|
|
377
|
+
const state = asRecord(part.state)
|
|
378
|
+
if (!state) continue
|
|
379
|
+
// Per-tool-part contributors: each tool call carries its own name and
|
|
380
|
+
// measured size (one message can hold several tool calls). The raw
|
|
381
|
+
// args JSON is measured but NOT previewed — the tool name is the
|
|
382
|
+
// useful identifier; result text previews fine.
|
|
383
|
+
const toolName =
|
|
384
|
+
typeof part.name === "string" && part.name ? safeText(part.name) : ""
|
|
385
|
+
let tool = ""
|
|
386
|
+
let result = ""
|
|
387
|
+
if (typeof state.input === "string") {
|
|
388
|
+
tool += state.input
|
|
389
|
+
} else if (state.input != null) {
|
|
390
|
+
try {
|
|
391
|
+
tool += JSON.stringify(state.input) ?? ""
|
|
392
|
+
} catch {
|
|
393
|
+
/* unstringifiable input: skip its chars */
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
const c = state.content
|
|
397
|
+
if (Array.isArray(c)) {
|
|
398
|
+
for (const rawItem of c) {
|
|
399
|
+
const item = asRecord(rawItem)
|
|
400
|
+
if (item && item.type === "text" && typeof item.text === "string")
|
|
401
|
+
result += item.text
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
add("asst.tool", ref, tool, toolName, "") // raw args JSON: no preview
|
|
405
|
+
add("asst.result", ref, result, toolName)
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
add("asst.text", ref, text)
|
|
409
|
+
add("asst.rsn", ref, rsn)
|
|
410
|
+
}
|
|
411
|
+
return out.sort((a, b) => b.tokensEstimate - a.tokensEstimate)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Human line for one measured contributor: role in full words, the message's
|
|
415
|
+
// own clock time, the bucket (with the tool name for tool buckets), the token
|
|
416
|
+
// estimate, and a bounded single-line preview of THIS bucket only. Never
|
|
417
|
+
// contains a full session id or file paths.
|
|
418
|
+
function contributorLine(c: Contributor, rank: number): string {
|
|
419
|
+
const [who, bucket] = c.kind.split(".")
|
|
420
|
+
const role = who === "asst" ? "assistant" : who
|
|
421
|
+
const what =
|
|
422
|
+
bucket === "rsn"
|
|
423
|
+
? "reasoning"
|
|
424
|
+
: bucket === "tool"
|
|
425
|
+
? "tool args"
|
|
426
|
+
: bucket === "result"
|
|
427
|
+
? "tool result"
|
|
428
|
+
: ""
|
|
429
|
+
const tool = c.tool ? ` (${c.tool})` : ""
|
|
430
|
+
const whatFull = what ? `${what}${tool}` : ""
|
|
431
|
+
const head = `${rank}. ${role} ${c.ref}${whatFull ? ` ${whatFull}` : ""} \u2014 ${fmt(c.tokensEstimate)}`
|
|
432
|
+
return c.preview ? `${head} "${c.preview}"` : head
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// The TUI message data API is optional across host versions. Log a single hint
|
|
436
|
+
// (never once per poll) when it is unavailable and hide the whole section.
|
|
437
|
+
let warnedMessagesUnavailable = false
|
|
438
|
+
|
|
133
439
|
function Sidebar(props: { entry: () => StateEntry | null }) {
|
|
134
440
|
const context = usePlugin()
|
|
135
441
|
const base = () => token(context?.theme?.text?.base, "white")
|
|
@@ -187,32 +493,360 @@ function Sidebar(props: { entry: () => StateEntry | null }) {
|
|
|
187
493
|
)
|
|
188
494
|
}
|
|
189
495
|
|
|
496
|
+
// Host-owned session.panel input ({ name, sessionID, width, presentation,
|
|
497
|
+
// focused, focus(), close(), toggleFullscreen() }). Typed structurally instead
|
|
498
|
+
// of imported so this file stays independent of the host type package; the host
|
|
499
|
+
// resolves the panel API at runtime.
|
|
500
|
+
type PanelInput = {
|
|
501
|
+
readonly name: string
|
|
502
|
+
readonly sessionID: string
|
|
503
|
+
readonly width: number
|
|
504
|
+
readonly presentation: "panel" | "fullscreen"
|
|
505
|
+
readonly focused: boolean
|
|
506
|
+
readonly focus: () => void
|
|
507
|
+
readonly close: () => void
|
|
508
|
+
readonly toggleFullscreen: () => void
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Panel column layout (monospace), derived from the host-provided panel width
|
|
512
|
+
// so cells never collide on narrow terminals. Gaps between columns keep a
|
|
513
|
+
// truncated cell visually separated from its neighbour.
|
|
514
|
+
const GUTTER = 2
|
|
515
|
+
const CTX_WIDTH = 12
|
|
516
|
+
|
|
517
|
+
function panelLayout(panelWidth: number): {
|
|
518
|
+
roleW: number
|
|
519
|
+
modelW: number
|
|
520
|
+
ctxW: number
|
|
521
|
+
updW: number
|
|
522
|
+
gutter: string
|
|
523
|
+
} {
|
|
524
|
+
const w = num(panelWidth) > 0 ? num(panelWidth) : 60
|
|
525
|
+
const ctxW = CTX_WIDTH
|
|
526
|
+
const updW = Math.max(5, Math.min(8, w - ctxW - 3 * GUTTER - 24))
|
|
527
|
+
const roleW = Math.max(12, Math.min(20, Math.floor(w * 0.3)))
|
|
528
|
+
const modelW = Math.max(10, w - roleW - ctxW - updW - 3 * GUTTER)
|
|
529
|
+
return { roleW, modelW, ctxW, updW, gutter: " ".repeat(GUTTER) }
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Per-row denominator with the same fallback cascade as the server table:
|
|
533
|
+
// usable, else limit, else none.
|
|
534
|
+
function panelDenom(e: StateEntry | undefined): number {
|
|
535
|
+
const u = num(e?.usable)
|
|
536
|
+
if (u > 0) return u
|
|
537
|
+
const l = num(e?.limit)
|
|
538
|
+
return l > 0 ? l : 0
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function panelRole(e: StateEntry, isMain: boolean): string {
|
|
542
|
+
if (isMain) return "main"
|
|
543
|
+
return e.agent ? `sub:${safeText(e.agent)}` : "sub"
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Full session panel: header + one row per session in the family (root plus
|
|
547
|
+
// subagent descendants). It reads the same state.json bridge as the sidebar via
|
|
548
|
+
// the shared 1 s tick, so both surfaces refresh on ONE poll. `panel.sessionID`
|
|
549
|
+
// selects the root; `cx` opens the panel (registered from the "app" slot below).
|
|
550
|
+
function BreakdownPanel(props: {
|
|
551
|
+
panel: PanelInput
|
|
552
|
+
snapshot: () => Record<string, StateEntry>
|
|
553
|
+
refresh: () => void
|
|
554
|
+
}) {
|
|
555
|
+
const context = usePlugin()
|
|
556
|
+
const base = () => token(context?.theme?.text?.base, "white")
|
|
557
|
+
const muted = () => token(context?.theme?.text?.muted, base())
|
|
558
|
+
|
|
559
|
+
// Panel-local keys. The layer is owned by this component and disposed with it.
|
|
560
|
+
// mode "global" + an explicit focused check is the portable fallback when no
|
|
561
|
+
// target renderable is available: the keys act only while the panel owns input.
|
|
562
|
+
// The capability guard keeps an older host (no keymap API) from throwing.
|
|
563
|
+
const keymap = context?.keymap
|
|
564
|
+
if (keymap && typeof keymap.layer === "function") {
|
|
565
|
+
keymap.layer(() => ({
|
|
566
|
+
mode: "global",
|
|
567
|
+
priority: -1,
|
|
568
|
+
commands: [
|
|
569
|
+
{
|
|
570
|
+
id: "context-indicator.breakdown.close",
|
|
571
|
+
title: "Close the context breakdown panel",
|
|
572
|
+
bind: "escape",
|
|
573
|
+
enabled: () => props.panel.focused,
|
|
574
|
+
run: () => {
|
|
575
|
+
if (props.panel.focused) props.panel.close()
|
|
576
|
+
},
|
|
577
|
+
},
|
|
578
|
+
{
|
|
579
|
+
id: "context-indicator.breakdown.refresh",
|
|
580
|
+
title: "Refresh the context breakdown panel",
|
|
581
|
+
bind: "r",
|
|
582
|
+
enabled: () => props.panel.focused,
|
|
583
|
+
run: () => {
|
|
584
|
+
if (props.panel.focused) props.refresh()
|
|
585
|
+
},
|
|
586
|
+
},
|
|
587
|
+
{
|
|
588
|
+
id: "context-indicator.breakdown.export",
|
|
589
|
+
title: "Export a redacted breakdown snapshot",
|
|
590
|
+
bind: "e",
|
|
591
|
+
enabled: () => props.panel.focused,
|
|
592
|
+
run: () => {
|
|
593
|
+
if (props.panel.focused) exportRedacted()
|
|
594
|
+
},
|
|
595
|
+
},
|
|
596
|
+
],
|
|
597
|
+
}))
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// One snapshot read per poll tick (props.snapshot tracks the shared tick), so
|
|
601
|
+
// the memo recomputes together with the sidebar instead of polling separately.
|
|
602
|
+
const data = createMemo(() => {
|
|
603
|
+
const snap = props.snapshot()
|
|
604
|
+
return {
|
|
605
|
+
root: snap[props.panel.sessionID],
|
|
606
|
+
rows: collectFamily(snap, props.panel.sessionID),
|
|
607
|
+
}
|
|
608
|
+
})
|
|
609
|
+
|
|
610
|
+
// Live message list from the TUI data layer (in-process, no LLM call). The
|
|
611
|
+
// shared `props.snapshot()` tick is read FIRST so this re-reads every poll.
|
|
612
|
+
// `available: false` means the API is absent/errored -> the WHOLE measured
|
|
613
|
+
// section is hidden (and a one-time hint was logged).
|
|
614
|
+
const messages = createMemo<{ available: boolean; list: unknown[] }>(() => {
|
|
615
|
+
props.snapshot()
|
|
616
|
+
try {
|
|
617
|
+
const api = context?.data?.session?.message
|
|
618
|
+
if (!api || typeof api.list !== "function") {
|
|
619
|
+
if (!warnedMessagesUnavailable) {
|
|
620
|
+
warnedMessagesUnavailable = true
|
|
621
|
+
console.error(
|
|
622
|
+
"[context-indicator] TUI message data API unavailable; " +
|
|
623
|
+
"measured contributors section hidden",
|
|
624
|
+
)
|
|
625
|
+
}
|
|
626
|
+
return { available: false, list: [] }
|
|
627
|
+
}
|
|
628
|
+
const v = api.list(props.panel.sessionID)
|
|
629
|
+
return { available: true, list: Array.isArray(v) ? v : [] }
|
|
630
|
+
} catch {
|
|
631
|
+
if (!warnedMessagesUnavailable) {
|
|
632
|
+
warnedMessagesUnavailable = true
|
|
633
|
+
console.error(
|
|
634
|
+
"[context-indicator] TUI message read failed; " +
|
|
635
|
+
"measured contributors section hidden",
|
|
636
|
+
)
|
|
637
|
+
}
|
|
638
|
+
return { available: false, list: [] }
|
|
639
|
+
}
|
|
640
|
+
})
|
|
641
|
+
|
|
642
|
+
const contributors = createMemo<Contributor[]>(() => {
|
|
643
|
+
const m = messages()
|
|
644
|
+
if (!m.available) return []
|
|
645
|
+
try {
|
|
646
|
+
return measureContributors(m.list)
|
|
647
|
+
} catch {
|
|
648
|
+
return []
|
|
649
|
+
}
|
|
650
|
+
})
|
|
651
|
+
|
|
652
|
+
// Redacted export (key `e`). No message text, no full session id, no tool
|
|
653
|
+
// output content ever leaves this function. Never throws: a failure logs and
|
|
654
|
+
// shows an error toast instead.
|
|
655
|
+
function exportRedacted(): void {
|
|
656
|
+
let exportedName: string | null = null
|
|
657
|
+
try {
|
|
658
|
+
const r = data().root
|
|
659
|
+
const id8 = String(props.panel.sessionID).slice(0, 8)
|
|
660
|
+
const now = new Date()
|
|
661
|
+
const pad = (n: number) => String(n).padStart(2, "0")
|
|
662
|
+
const stamp =
|
|
663
|
+
`${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` +
|
|
664
|
+
`-${pad(now.getHours())}${pad(now.getMinutes())}` +
|
|
665
|
+
`${pad(now.getSeconds())}${String(now.getMilliseconds()).padStart(3, "0")}`
|
|
666
|
+
const payload = {
|
|
667
|
+
generatedAt: now.toISOString(),
|
|
668
|
+
session: {
|
|
669
|
+
id: `${id8}...`,
|
|
670
|
+
model: safeText(r?.model ?? ""),
|
|
671
|
+
providerID: safeText(r?.providerID ?? ""),
|
|
672
|
+
},
|
|
673
|
+
family: data().rows.map((e, i) => {
|
|
674
|
+
const d = panelDenom(e)
|
|
675
|
+
return {
|
|
676
|
+
// Positional main-row detection (index 0), same as the render —
|
|
677
|
+
// state.json is untrusted input and a legacy entry may lack
|
|
678
|
+
// `sessionID`, which would mislabel main as sub.
|
|
679
|
+
role: panelRole(e, i === 0),
|
|
680
|
+
model: safeText(e.model ?? ""),
|
|
681
|
+
ctx: num(e.ctx),
|
|
682
|
+
usable: e.usable == null ? null : num(e.usable),
|
|
683
|
+
limit: e.limit == null ? null : num(e.limit),
|
|
684
|
+
pct: d > 0 ? Math.round((num(e.ctx) / d) * 100) : null,
|
|
685
|
+
updated: safeText(e.updatedAt ?? ""),
|
|
686
|
+
}
|
|
687
|
+
}),
|
|
688
|
+
contributors: contributors().map((c) => ({
|
|
689
|
+
kind: c.kind,
|
|
690
|
+
tool: c.tool || null,
|
|
691
|
+
ref: c.ref,
|
|
692
|
+
chars: c.chars,
|
|
693
|
+
tokensEstimate: c.tokensEstimate,
|
|
694
|
+
})),
|
|
695
|
+
}
|
|
696
|
+
const dir = join(tmpdir(), "opencode-context-indicator", "exports")
|
|
697
|
+
const file = join(dir, `context-${id8}-${stamp}.json`)
|
|
698
|
+
mkdirSync(dir, { recursive: true })
|
|
699
|
+
writeFileSync(file, JSON.stringify(payload, null, 2), "utf8")
|
|
700
|
+
exportedName = basename(file)
|
|
701
|
+
} catch (err) {
|
|
702
|
+
console.error("[context-indicator] redacted export failed:", err)
|
|
703
|
+
try {
|
|
704
|
+
context?.ui?.toast?.show({
|
|
705
|
+
message: "redacted export failed",
|
|
706
|
+
variant: "error",
|
|
707
|
+
})
|
|
708
|
+
} catch {
|
|
709
|
+
/* toast is best-effort */
|
|
710
|
+
}
|
|
711
|
+
return
|
|
712
|
+
}
|
|
713
|
+
// Outside the write try: a toast failure must not report the export as
|
|
714
|
+
// failed when the file is already on disk.
|
|
715
|
+
try {
|
|
716
|
+
context?.ui?.toast?.show({
|
|
717
|
+
message: `exported ${exportedName}`,
|
|
718
|
+
variant: "success",
|
|
719
|
+
})
|
|
720
|
+
} catch {
|
|
721
|
+
/* toast is best-effort */
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const header = (): string => {
|
|
726
|
+
const r = data().root
|
|
727
|
+
if (!r) return "Context breakdown"
|
|
728
|
+
const model = safeText(r.model && r.model !== "unknown" ? r.model : "model?")
|
|
729
|
+
const ctx = fmt(num(r.ctx))
|
|
730
|
+
const d = panelDenom(r)
|
|
731
|
+
return d > 0
|
|
732
|
+
? `Context breakdown \u2014 ${model} ${ctx}/${fmt(d)} (${Math.round(
|
|
733
|
+
(num(r.ctx) / d) * 100,
|
|
734
|
+
)}%)`
|
|
735
|
+
: `Context breakdown \u2014 ${model} ${ctx}`
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const layout = (): {
|
|
739
|
+
roleW: number
|
|
740
|
+
modelW: number
|
|
741
|
+
ctxW: number
|
|
742
|
+
updW: number
|
|
743
|
+
gutter: string
|
|
744
|
+
} => panelLayout(props.panel.width)
|
|
745
|
+
|
|
746
|
+
const columns = (): string => {
|
|
747
|
+
const L = layout()
|
|
748
|
+
return (
|
|
749
|
+
fit("role", L.roleW) +
|
|
750
|
+
L.gutter +
|
|
751
|
+
fit("model", L.modelW) +
|
|
752
|
+
L.gutter +
|
|
753
|
+
fit("ctx", L.ctxW) +
|
|
754
|
+
L.gutter +
|
|
755
|
+
"updated"
|
|
756
|
+
)
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const row = (e: StateEntry, isMain: boolean): string => {
|
|
760
|
+
const model = safeText(e.model && e.model !== "unknown" ? e.model : "model?")
|
|
761
|
+
const d = panelDenom(e)
|
|
762
|
+
const ctx =
|
|
763
|
+
d > 0
|
|
764
|
+
? `${fmt(num(e.ctx))} (${Math.round((num(e.ctx) / d) * 100)}%)`
|
|
765
|
+
: fmt(num(e.ctx))
|
|
766
|
+
const L = layout()
|
|
767
|
+
return (
|
|
768
|
+
fit(panelRole(e, isMain), L.roleW) +
|
|
769
|
+
L.gutter +
|
|
770
|
+
fit(model, L.modelW) +
|
|
771
|
+
L.gutter +
|
|
772
|
+
fit(ctx, L.ctxW) +
|
|
773
|
+
L.gutter +
|
|
774
|
+
updatedAt(e.updatedAt)
|
|
775
|
+
)
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
return (
|
|
779
|
+
<box flexDirection="column">
|
|
780
|
+
<text fg={base()}>{header()}</text>
|
|
781
|
+
<Show
|
|
782
|
+
when={data().root}
|
|
783
|
+
fallback={<text fg={muted()}>{" no data yet"}</text>}
|
|
784
|
+
>
|
|
785
|
+
<text fg={muted()}>{columns()}</text>
|
|
786
|
+
{data().rows.map((e, i) => (
|
|
787
|
+
<text fg={muted()}>{row(e, i === 0)}</text>
|
|
788
|
+
))}
|
|
789
|
+
</Show>
|
|
790
|
+
<Show when={messages().available}>
|
|
791
|
+
<text fg={base()}>{"largest contributors (measured)"}</text>
|
|
792
|
+
<Show
|
|
793
|
+
when={contributors().length > 0}
|
|
794
|
+
fallback={<text fg={muted()}>{" no messages yet"}</text>}
|
|
795
|
+
>
|
|
796
|
+
{contributors()
|
|
797
|
+
.slice(0, 5)
|
|
798
|
+
.map((c, i) => (
|
|
799
|
+
<text fg={muted()}>{contributorLine(c, i + 1)}</text>
|
|
800
|
+
))}
|
|
801
|
+
</Show>
|
|
802
|
+
<text fg={muted()}>
|
|
803
|
+
{
|
|
804
|
+
"measured != server categories (tool outputs counted here; server categories exclude them)"
|
|
805
|
+
}
|
|
806
|
+
</text>
|
|
807
|
+
</Show>
|
|
808
|
+
</box>
|
|
809
|
+
)
|
|
810
|
+
}
|
|
811
|
+
|
|
190
812
|
export default Plugin.define({
|
|
191
813
|
id: "context-indicator.tui",
|
|
192
814
|
setup(context) {
|
|
193
815
|
// npm installs (path under node_modules) cannot get live slot updates on
|
|
194
816
|
// this OpenCode version. Stay inert and log a single hint instead of
|
|
195
|
-
// registering
|
|
817
|
+
// registering slots that would freeze on "no data yet".
|
|
196
818
|
if (isNodeModulesInstall()) {
|
|
197
819
|
console.log(
|
|
198
|
-
"[context-indicator] TUI sidebar
|
|
199
|
-
"live updates on this OpenCode
|
|
200
|
-
|
|
201
|
-
"
|
|
820
|
+
"[context-indicator] TUI sidebar and breakdown panel disabled: " +
|
|
821
|
+
"npm-installed plugins cannot get live updates on this OpenCode " +
|
|
822
|
+
"version (anomalyco/opencode#33884). Install via file:// (see README " +
|
|
823
|
+
'→ "Live sidebar") for the live sidebar and panel. The /context slash ' +
|
|
824
|
+
"commands still work everywhere.",
|
|
202
825
|
)
|
|
203
826
|
return
|
|
204
827
|
}
|
|
205
828
|
|
|
206
|
-
// Re-read the bridge file once per second and re-render reactively.
|
|
829
|
+
// Re-read the bridge file once per second and re-render reactively. ONE
|
|
830
|
+
// ticker drives BOTH surfaces, and the snapshot is cached per tick: every
|
|
831
|
+
// consumer (sidebar, panel data, messages) shares a single readFileSync.
|
|
207
832
|
const [tick, setTick] = createSignal(0)
|
|
208
833
|
let currentSession: string | undefined
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
834
|
+
let snapCache: Record<string, StateEntry> | null = null
|
|
835
|
+
let snapAt = -1
|
|
836
|
+
const snapshot = (): Record<string, StateEntry> => {
|
|
837
|
+
const t = tick() // track the ticker so reads re-run every poll
|
|
838
|
+
if (snapCache === null || snapAt !== t) {
|
|
839
|
+
snapCache = readSnapshot()
|
|
840
|
+
snapAt = t
|
|
841
|
+
}
|
|
842
|
+
return snapCache
|
|
212
843
|
}
|
|
844
|
+
const entry = (): StateEntry | null =>
|
|
845
|
+
snapshot()[currentSession ?? ""] ?? null
|
|
846
|
+
const refresh = () => setTick((n) => n + 1)
|
|
213
847
|
const timer = setInterval(() => setTick((n) => n + 1), POLL_MS)
|
|
214
848
|
|
|
215
|
-
const
|
|
849
|
+
const unregisterSidebar = context.ui.slot({
|
|
216
850
|
append: "sidebar.content",
|
|
217
851
|
render: ({ sessionID }) => {
|
|
218
852
|
currentSession = sessionID
|
|
@@ -220,12 +854,59 @@ export default Plugin.define({
|
|
|
220
854
|
},
|
|
221
855
|
})
|
|
222
856
|
|
|
857
|
+
// A `session.panel` contribution is independently selectable: it renders
|
|
858
|
+
// only while its name is the selected one, and the root session comes from
|
|
859
|
+
// panel.sessionID.
|
|
860
|
+
const unregisterPanel = context.ui.slot({
|
|
861
|
+
append: "session.panel",
|
|
862
|
+
render: (panel) => (
|
|
863
|
+
<Show when={panel.name === PANEL_NAME}>
|
|
864
|
+
<BreakdownPanel panel={panel} snapshot={snapshot} refresh={refresh} />
|
|
865
|
+
</Show>
|
|
866
|
+
),
|
|
867
|
+
})
|
|
868
|
+
|
|
869
|
+
// The `app` slot renders nothing itself; it exists to register the global
|
|
870
|
+
// keymap layer (slash command `cx` -> open the panel). The layer is owned by
|
|
871
|
+
// this slot and disposed when the slot unregisters.
|
|
872
|
+
const unregisterCommands = context.ui.slot({
|
|
873
|
+
append: "app",
|
|
874
|
+
render: () => {
|
|
875
|
+
const keymap = context?.keymap
|
|
876
|
+
if (keymap && typeof keymap.layer === "function") {
|
|
877
|
+
keymap.layer(() => ({
|
|
878
|
+
mode: "global",
|
|
879
|
+
commands: [
|
|
880
|
+
{
|
|
881
|
+
id: "context-indicator.breakdown",
|
|
882
|
+
title: "Context breakdown panel",
|
|
883
|
+
slash: { name: "cx" },
|
|
884
|
+
run: () => {
|
|
885
|
+
const panelApi = context?.ui?.panel
|
|
886
|
+
if (panelApi && typeof panelApi.open === "function") {
|
|
887
|
+
panelApi.open(PANEL_NAME)
|
|
888
|
+
}
|
|
889
|
+
},
|
|
890
|
+
},
|
|
891
|
+
],
|
|
892
|
+
}))
|
|
893
|
+
}
|
|
894
|
+
return null
|
|
895
|
+
},
|
|
896
|
+
})
|
|
897
|
+
|
|
223
898
|
return () => {
|
|
224
899
|
clearInterval(timer)
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
900
|
+
for (const unregister of [
|
|
901
|
+
unregisterSidebar,
|
|
902
|
+
unregisterPanel,
|
|
903
|
+
unregisterCommands,
|
|
904
|
+
]) {
|
|
905
|
+
try {
|
|
906
|
+
unregister?.()
|
|
907
|
+
} catch {
|
|
908
|
+
/* ignore */
|
|
909
|
+
}
|
|
229
910
|
}
|
|
230
911
|
}
|
|
231
912
|
},
|