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.
Files changed (3) hide show
  1. package/README.md +86 -38
  2. package/package.json +1 -1
  3. 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 sidebar module exposed as the `./tui` entry.
12
- The V2 path is **tested against opencode v2.0.21**; the V1 path against 1.18.29+.
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
  ![TUI sidebar — per-category context breakdown](docs/sidebar.png)
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; only tool **call arguments**
42
- (results are not part of the sent prompt).
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 and the
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 registers no
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/dedup.js` into `~/.config/opencode/plugins/`, keeping
354
- the `lib/` subdirectory next to the plugin file so the plugin's `import` of the
355
- helper resolves. The `lib/` folder is support code, not a separate plugin entry:
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. ⚠️ The V1 path relies on
376
- `experimental.chat.*` hooks, which are **experimental** and may change or stop
377
- firing in future OpenCode 1.x releases; if they do, the indicator degrades
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.21**).
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.21 ships): OpenCode's
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`. Cross-instance
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[]` field expands to exactly 6 members | no test/node_modules/tsconfig in the tarball |
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.1.6",
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. Install via `file://` from a path outside any
38
- * `node_modules` for the live sidebar (see README → "Live sidebar").
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
- // Read the snapshot entry for one session. Never throws: a missing, empty or
110
- // corrupt state file simply yields null ("no data yet").
111
- function readEntry(sessionID: string | undefined): StateEntry | null {
112
- if (!sessionID) return null
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 null
117
- const entry = all[sessionID]
118
- return entry && typeof entry === "object" ? entry : null
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 null
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
- return new Date(iso).toLocaleTimeString()
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 a slot that would freeze on "no data yet".
817
+ // registering slots that would freeze on "no data yet".
196
818
  if (isNodeModulesInstall()) {
197
819
  console.log(
198
- "[context-indicator] TUI sidebar disabled: npm-installed plugins cannot get " +
199
- "live updates on this OpenCode version (anomalyco/opencode#33884). " +
200
- 'Install via file:// (see README → "Live sidebar") for the live sidebar. ' +
201
- "Slash commands still work everywhere.",
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
- const entry = (): StateEntry | null => {
210
- tick() // track the ticker so reads re-run every poll
211
- return readEntry(currentSession)
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 unregister = context.ui.slot({
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
- try {
226
- unregister?.()
227
- } catch {
228
- /* ignore */
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
  },