opencode-context-indicator 1.1.6 → 1.2.1

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