opencode-context-indicator 1.7.1 → 1.8.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.
@@ -0,0 +1,153 @@
1
+ // lib/tui-sidebar.ts — the `sidebar.content` slot component.
2
+ //
3
+ // No JSX and no `@jsxImportSource` pragma anywhere in this file: see the long
4
+ // explanation at the top of lib/tui-h.ts. Every dynamic prop is a getter, which
5
+ // is exactly what the Solid compiler would have emitted for `prop={expr}`.
6
+ //
7
+ // This file lives in lib/ rather than in the tui.ts entry because the sidebar
8
+ // is the surface that renders the per-category breakdown; the entry stays a
9
+ // thin setup shell.
10
+
11
+ import { usePlugin } from "@opencode/plugin/tui"
12
+ import { Show } from "solid-js"
13
+ import type { StateEntry } from "./tui-data.js"
14
+ import { fmt, num, updatedAt } from "./tui-data.js"
15
+ import { token } from "./tui-theme.js"
16
+ import { c, h } from "./tui-h.ts"
17
+
18
+ // Sidebar category-label column width (monospace), used by the `line()` helper.
19
+ const LABEL_WIDTH = 12
20
+
21
+ export function Sidebar(props: { entry: () => StateEntry | null }) {
22
+ const context = usePlugin()
23
+ const base = () => token(context?.theme?.text?.base, "white")
24
+ const muted = () => token(context?.theme?.text?.muted, base())
25
+
26
+ const e = () => props.entry()
27
+
28
+ const denom = (): number | null => {
29
+ const x = e()
30
+ const d = x?.usable ?? x?.limit
31
+ return typeof d === "number" && d > 0 ? d : null
32
+ }
33
+ const pct = (v: number): string => {
34
+ const d = denom()
35
+ return d ? `${Math.round((v / d) * 100)}%` : ""
36
+ }
37
+ const line = (label: string, v: unknown): string => {
38
+ const n = num(v)
39
+ const l = (label + " ".repeat(LABEL_WIDTH)).slice(0, LABEL_WIDTH)
40
+ const p = pct(n)
41
+ return `${l}${fmt(n).padStart(7)}${p ? ` ${p.padStart(4)}` : ""}`
42
+ }
43
+ const header = (): string => {
44
+ const x = e()
45
+ if (!x) return ""
46
+ const d = denom()
47
+ return `${x.model ?? "?"} · ${fmt(num(x.ctx))}${
48
+ d ? ` / ${fmt(d)}` : ""
49
+ }${d ? ` (${pct(num(x.ctx))})` : ""}`
50
+ }
51
+
52
+ // One muted category row. The accessor is called INSIDE the getter: the row
53
+ // element is created once and then updates in place on every poll, exactly
54
+ // like `<text fg={muted()}>{line(...)}</text>` in JSX.
55
+ const row = (label: string, value: () => unknown) =>
56
+ h("text", {
57
+ get fg() {
58
+ return muted()
59
+ },
60
+ get children() {
61
+ return line(label, value())
62
+ },
63
+ })
64
+
65
+ // `system` and `tool schemas` are absent from state files written by older
66
+ // plugin versions (null) and render as "n/a" instead of a zero row.
67
+ const optionalRow = (label: string, value: () => number | null | undefined) =>
68
+ h("text", {
69
+ get fg() {
70
+ return muted()
71
+ },
72
+ get children() {
73
+ const v = value()
74
+ return v == null
75
+ ? `${label.padEnd(LABEL_WIDTH)} n/a`
76
+ : line(label, v)
77
+ },
78
+ })
79
+
80
+ return h("box", {
81
+ flexDirection: "column",
82
+ get children() {
83
+ return [
84
+ h("text", {
85
+ get fg() {
86
+ return base()
87
+ },
88
+ get children() {
89
+ return "Context"
90
+ },
91
+ }),
92
+ c(Show, {
93
+ get when() {
94
+ return e()
95
+ },
96
+ get fallback() {
97
+ return h("text", {
98
+ get fg() {
99
+ return muted()
100
+ },
101
+ get children() {
102
+ return " no data yet"
103
+ },
104
+ })
105
+ },
106
+ get children() {
107
+ return [
108
+ h("text", {
109
+ get fg() {
110
+ return base()
111
+ },
112
+ get children() {
113
+ return header()
114
+ },
115
+ }),
116
+ row("user", () => e()?.categories?.user),
117
+ row("assistant", () => e()?.categories?.assistant),
118
+ row("reasoning", () => e()?.categories?.reasoning),
119
+ row("tool args", () => e()?.categories?.toolArgs),
120
+ row("tool out", () => e()?.categories?.toolOut),
121
+ optionalRow("system", () => e()?.categories?.system),
122
+ optionalRow("tool schemas", () => e()?.categories?.toolSchemas),
123
+ row("other", () => e()?.categories?.other),
124
+ c(Show, {
125
+ get when() {
126
+ return e()?.compactStale === true
127
+ },
128
+ get children() {
129
+ return h("text", {
130
+ get fg() {
131
+ return muted()
132
+ },
133
+ get children() {
134
+ return " stale — compacted"
135
+ },
136
+ })
137
+ },
138
+ }),
139
+ h("text", {
140
+ get fg() {
141
+ return muted()
142
+ },
143
+ get children() {
144
+ return `updated ${updatedAt(e()?.updatedAt)}`
145
+ },
146
+ }),
147
+ ]
148
+ },
149
+ }),
150
+ ]
151
+ },
152
+ })
153
+ }
package/lib/v2.js CHANGED
@@ -61,6 +61,7 @@ import {
61
61
  getModelLimit,
62
62
  getUsableContext,
63
63
  } from "./limits.js";
64
+ import { locationDirectory } from "./config.js";
64
65
  import {
65
66
  debugEvent,
66
67
  logErr,
@@ -364,7 +365,11 @@ function estimateBreakdownFromContext(context, sessionID) {
364
365
 
365
366
  // V2 context hook handler: capture the final messages / system prompt / tool
366
367
  // schemas + the request model. READ-ONLY — the context object is never mutated.
367
- function onV2Context(context) {
368
+ //
369
+ // `ctx` (the plugin context) is optional and only used to remember which project
370
+ // the hook belongs to: the limits path resolves the user's configured
371
+ // compaction.buffer per directory, and a process can host several instances.
372
+ function onV2Context(context, ctx) {
368
373
  try {
369
374
  if (!context || typeof context !== "object") return;
370
375
  const sid = context.sessionID;
@@ -404,11 +409,12 @@ function onV2Context(context) {
404
409
  // compact yet") on a session with no completed request — there is no measured
405
410
  // context to mark, so we only cache and skip the state write (no fake stale
406
411
  // badge).
407
- function onV2Compaction(context) {
412
+ function onV2Compaction(context, ctx) {
408
413
  try {
409
414
  if (!context || typeof context !== "object") return;
410
415
  const sid = context.sessionID;
411
416
  if (!sid) return;
417
+ const directory = locationDirectory(ctx);
412
418
 
413
419
  const parts = estimateBreakdownFromContext(context, sid);
414
420
  breakdownCache.set(sid, {
@@ -437,6 +443,7 @@ function onV2Compaction(context) {
437
443
  lastKnownInput(sid),
438
444
  ctxTokens,
439
445
  lastKnownReasoning(sid),
446
+ directory,
440
447
  );
441
448
  const wrote = writeStateFile(
442
449
  sid,
@@ -449,6 +456,7 @@ function onV2Compaction(context) {
449
456
  compacted: true,
450
457
  cacheRead: lastKnownCacheRead(sid),
451
458
  cacheWrite: lastKnownCacheWrite(sid),
459
+ directory,
452
460
  },
453
461
  );
454
462
  // Arm the fallback guard for the trailing `session.compacted` event ONLY
@@ -719,11 +727,11 @@ function stateEntryToBreakdown(entry) {
719
727
 
720
728
  // V2: assemble the live breakdown lines (no client, no fallback fetch —
721
729
  // the context hook fills breakdownCache on every request).
722
- function buildLiveLinesV2(sessionID, modelID, providerID, inputTokens, ctxTokens, reasoningTokens) {
730
+ function buildLiveLinesV2(sessionID, modelID, providerID, inputTokens, ctxTokens, reasoningTokens, directory) {
723
731
  const lines = [];
724
732
  const ts = new Date().toISOString();
725
733
  const limit = getModelLimit(providerID, modelID);
726
- const usableInfo = getUsableContext(providerID, modelID);
734
+ const usableInfo = getUsableContext(providerID, modelID, undefined, directory);
727
735
 
728
736
  // A client-context fallback cannot see the system prompt or the tool schemas:
729
737
  // its `other` swallows both and, right after a compaction, it can even read a
@@ -906,6 +914,9 @@ function buildLiveLinesV2(sessionID, modelID, providerID, inputTokens, ctxTokens
906
914
  // V2 event handler. `event` is a V2Event ({ type, data, ... }).
907
915
  async function handleV2Event(ctx, event) {
908
916
  if (!event || typeof event.type !== "string") return;
917
+ // The instance's project directory scopes the configured compaction.buffer
918
+ // lookup (one process can host several plugin instances, one per location).
919
+ const directory = locationDirectory(ctx);
909
920
 
910
921
  // DEBUG tap (fault-tolerant, off via flag).
911
922
  debugEvent(event);
@@ -981,6 +992,7 @@ async function handleV2Event(ctx, event) {
981
992
  lastKnownInput(sid),
982
993
  lastKnownCtx(sid),
983
994
  lastKnownReasoning(sid),
995
+ directory,
984
996
  );
985
997
  // The `compaction` HOOK already captured + persisted the exact
986
998
  // pre-compaction snapshot and marked the entry stale. This event is now a
@@ -1003,6 +1015,7 @@ async function handleV2Event(ctx, event) {
1003
1015
  compacted: true,
1004
1016
  cacheRead: lastKnownCacheRead(sid),
1005
1017
  cacheWrite: lastKnownCacheWrite(sid),
1018
+ directory,
1006
1019
  },
1007
1020
  );
1008
1021
  }
@@ -1073,6 +1086,7 @@ async function handleV2Event(ctx, event) {
1073
1086
  lastKnownInput(sid),
1074
1087
  lastKnownCtx(sid),
1075
1088
  lastKnownReasoning(sid),
1089
+ directory,
1076
1090
  );
1077
1091
  writeStateFile(
1078
1092
  sid,
@@ -1084,6 +1098,7 @@ async function handleV2Event(ctx, event) {
1084
1098
  {
1085
1099
  cacheRead: lastKnownCacheRead(sid),
1086
1100
  cacheWrite: lastKnownCacheWrite(sid),
1101
+ directory,
1087
1102
  },
1088
1103
  );
1089
1104
  pushFinalSummary(`final:${event.id ?? sid}`, [
@@ -1185,6 +1200,7 @@ async function handleV2Event(ctx, event) {
1185
1200
  input,
1186
1201
  contextTokens,
1187
1202
  reasoning,
1203
+ directory,
1188
1204
  );
1189
1205
  writeStateFile(
1190
1206
  sid,
@@ -1196,6 +1212,7 @@ async function handleV2Event(ctx, event) {
1196
1212
  {
1197
1213
  cacheRead: lastKnownCacheRead(sid),
1198
1214
  cacheWrite: lastKnownCacheWrite(sid),
1215
+ directory,
1199
1216
  },
1200
1217
  );
1201
1218
  writeLog(lines);
package/package.json CHANGED
@@ -1,19 +1,19 @@
1
1
  {
2
2
  "name": "opencode-context-indicator",
3
- "version": "1.7.1",
3
+ "version": "1.8.1",
4
4
  "description": "Real-time context-window usage for OpenCode: per-category token breakdown, /context-diff, a zero-LLM /cx panel with per-session cost and a searchable family-session picker, a TUI sidebar, and an experimental Desktop panel. Dual V1 + V2 plugin entry.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "exports": {
8
8
  ".": "./index.js",
9
- "./tui": "./tui.tsx",
9
+ "./tui": "./tui.ts",
10
10
  "./rpc": "./lib/rpc.js",
11
11
  "./gui-experimental": "./dist/gui-experimental.cjs"
12
12
  },
13
13
  "files": [
14
14
  "index.js",
15
15
  "lib/",
16
- "tui.tsx",
16
+ "tui.ts",
17
17
  "dist/",
18
18
  "docs/",
19
19
  "README.md",
@@ -52,7 +52,7 @@
52
52
  },
53
53
  "peerDependencies": {
54
54
  "@opencode/plugin": ">=2.0.16",
55
- "@opentui/core": ">=0.5.14",
55
+ "@opentui/core": ">=0.8.0",
56
56
  "solid-js": ">=1.9.0"
57
57
  },
58
58
  "peerDependenciesMeta": {
@@ -66,12 +66,10 @@
66
66
  "optional": true
67
67
  }
68
68
  },
69
- "dependencies": {
70
- "@opentui/solid": "0.7.0"
71
- },
72
69
  "devDependencies": {
73
- "@opencode/plugin": "2.2.0",
74
- "@opentui/core": "0.7.0",
70
+ "@opencode/plugin": "2.2.5",
71
+ "@opentui/core": "0.8.0",
72
+ "@opentui/solid": "0.8.0",
75
73
  "@types/node": "26.6.3",
76
74
  "esbuild": "^0.28.2",
77
75
  "node-pty": "^1.1.0",
@@ -87,7 +85,7 @@
87
85
  "test:e2e": "node test/e2e-tui.mjs --full",
88
86
  "test:e2e:quick": "node test/e2e-tui.mjs --quick",
89
87
  "test:drift": "node test/drift-watch.mjs",
90
- "test:drift:strict": "node test/drift-watch.mjs --strict"
91
- },
92
- "prepublishOnly": "npm run build:gui && node test/prepublish.mjs && node test/e2e-tui.mjs --full"
88
+ "test:drift:strict": "node test/drift-watch.mjs --strict",
89
+ "prepublishOnly": "npm run build:gui && node test/prepublish.mjs && node test/e2e-tui.mjs --full"
90
+ }
93
91
  }
package/tui.ts ADDED
@@ -0,0 +1,340 @@
1
+ // tui.ts — optional OpenCode TUI surfaces for `opencode-context-indicator`.
2
+ //
3
+ // NO JSX AND NO `@jsxImportSource` PRAGMA IN THIS PACKAGE. Read the long
4
+ // explanation at the top of lib/tui-h.ts before touching any TUI file:
5
+ // OpenCode installs npm plugins inside a `node_modules` tree, where the host
6
+ // SKIPS the Solid/Babel JSX transform (upstream anomalyco/opencode#33884).
7
+ // Building the tree with jsx() and getter props keeps the surfaces in sync with
8
+ // the 1 s poll from BOTH install layouts (npm and file://), and lets the
9
+ // package declare no runtime dependencies at all.
10
+ //
11
+ // This file is the ENTRY + setup wiring only. The components and the data layer
12
+ // live in lib/:
13
+ // - lib/tui-sidebar.ts the `sidebar.content` slot component
14
+ // - lib/tui-composer.ts the `prompt.footer.status` usage capsule
15
+ // - lib/tui-panel.ts the `session.panel` component (BreakdownPanel)
16
+ // - lib/tui-h.ts the jsx()/component() builders (only jsx-runtime user)
17
+ // - lib/tui-data.js state.json bridge, family walk, formatting helpers
18
+ // (+ lib/tui-data.d.ts for the shared types)
19
+ // - lib/tui-theme.js theme-token helper
20
+ // - lib/contributors.js measured-contributor computation + redacted export
21
+ //
22
+ // Exposed as the `./tui` package entry ("beside the main plugin for automatic
23
+ // loading"). OpenCode loads it only in the terminal TUI; it is NOT loaded in
24
+ // the Desktop app (Desktop exposes no TUI slot API). It renders the
25
+ // per-category context breakdown produced by the server plugin (index.js) into
26
+ // the session sidebar via `append: "sidebar.content"` and into a full session
27
+ // panel (`append: "session.panel"`) opened by the `cx` slash command / keymap
28
+ // layer registered from an `append: "app"` slot.
29
+ //
30
+ // Data bridge: the server plugin rewrites
31
+ // %TEMP%/opencode-context-indicator-state.json
32
+ // (see writeStateFile() in index.js) on every breakdown update. This module
33
+ // ONLY reads that file and re-polls it once per second; a missing or corrupt
34
+ // file renders "no data yet". The main plugin never imports the TUI entry, so
35
+ // the absence of the OpenTUI peers cannot break the server/V1 path.
36
+ //
37
+ // Runtime requirements: @opentui/core, @opentui/solid and solid-js are resolved
38
+ // by OpenCode at runtime — it rewrites these specifiers to in-process modules
39
+ // (`nodeModulesRuntimeSpecifiers: true` in @opentui/core's
40
+ // DEFAULT_RUNTIME_PLUGIN_REWRITE_OPTIONS) for any file under node_modules, so
41
+ // @opentui/solid is a DEV dependency here (types + local tooling only), never a
42
+ // production one. Shipping it dragged in a duplicate @opentui/core and the whole
43
+ // Babel toolchain for nothing.
44
+
45
+ import { Plugin } from "@opencode/plugin/tui"
46
+ import { createSignal, Show } from "solid-js"
47
+ import { appendFileSync, mkdirSync, writeFileSync } from "node:fs"
48
+ import { tmpdir } from "node:os"
49
+ import { dirname, join } from "node:path"
50
+ import type { StateEntry } from "./lib/tui-data.js"
51
+ import { POLL_MS, readSnapshot } from "./lib/tui-data.js"
52
+ import { c as comp, h } from "./lib/tui-h.ts"
53
+ import { Sidebar } from "./lib/tui-sidebar.ts"
54
+ import { BreakdownPanel } from "./lib/tui-panel.ts"
55
+ import { ComposerStatus } from "./lib/tui-composer.ts"
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
+
62
+ export default Plugin.define({
63
+ id: "context-indicator.tui",
64
+ setup(context) {
65
+ // Re-read the bridge file once per second and re-render reactively. ONE
66
+ // ticker drives BOTH surfaces, and the snapshot is cached per tick: every
67
+ // consumer (sidebar, panel data, messages) shares a single readFileSync.
68
+ const [tick, setTick] = createSignal(0)
69
+ let currentSession: string | undefined
70
+ let snapCache: Record<string, StateEntry> | null = null
71
+ let snapAt = -1
72
+ const snapshot = (): Record<string, StateEntry> => {
73
+ const t = tick() // track the ticker so reads re-run every poll
74
+ if (snapCache === null || snapAt !== t) {
75
+ snapCache = readSnapshot()
76
+ snapAt = t
77
+ }
78
+ return snapCache
79
+ }
80
+ const entry = (): StateEntry | null =>
81
+ snapshot()[currentSession ?? ""] ?? null
82
+ const refresh = () => setTick((n) => n + 1)
83
+ const timer = setInterval(() => setTick((n) => n + 1), POLL_MS)
84
+
85
+ // E2E liveness probe (test/e2e-tui.mjs Check C only; inert in production).
86
+ //
87
+ // Check A proves the ./tui entry LOADED; this probe proves the tree stays
88
+ // REACTIVE when the package is loaded from a `node_modules` path — the exact
89
+ // shape of an npm install, where the host skips its Solid JSX transform
90
+ // (#33884). The node is built by the same h()/getter builders as the real
91
+ // sidebar and is driven by the same 1 s poll tick; its `ref` is sampled into
92
+ // a file because pty screen text is not a reliable liveness oracle
93
+ // (differential redraws).
94
+ const livenessFile = process.env.OPENCODE_CONTEXT_INDICATOR_TUI_LIVENESS
95
+ let probeNode: unknown = null
96
+ const setProbeNode = (el: unknown) => {
97
+ probeNode = el
98
+ }
99
+ // `ref` + getter-driven props: the text content and the foreground colour
100
+ // both come from the poll tick, so a frozen (untransformed) tree leaves the
101
+ // sampled values constant. `plainText` is the renderable's own rendered text
102
+ // — a throwaway pty probe showed it tracks updates reliably while screen
103
+ // scraping does not (hence Check C reads this file, not the pty screen).
104
+ const livenessProbe = livenessFile
105
+ ? () =>
106
+ h("box", {
107
+ flexDirection: "column",
108
+ get children() {
109
+ return [
110
+ h("text", {
111
+ ref: setProbeNode,
112
+ get fg() {
113
+ return tick() % 2 ? "#ff0000" : "#0000ff"
114
+ },
115
+ get children() {
116
+ return `probe ${tick()}`
117
+ },
118
+ }),
119
+ ]
120
+ },
121
+ })
122
+ : null
123
+ const noteLive = livenessFile
124
+ ? (line: string) => {
125
+ try {
126
+ appendFileSync(livenessFile, line + "\n")
127
+ } catch {
128
+ /* probe is best-effort test instrumentation */
129
+ }
130
+ }
131
+ : null
132
+ let probeTimer: ReturnType<typeof setInterval> | undefined
133
+ if (livenessFile) {
134
+ try {
135
+ mkdirSync(dirname(livenessFile), { recursive: true })
136
+ writeFileSync(livenessFile, "")
137
+ } catch {
138
+ /* probe is best-effort test instrumentation */
139
+ }
140
+ probeTimer = setInterval(() => {
141
+ let text = "?"
142
+ let fg = "?"
143
+ try {
144
+ const node = probeNode as Record<string, unknown> | null
145
+ text = String(node?.plainText ?? node?.text ?? "?")
146
+ fg = String(node?.fg ?? node?.foregroundColor ?? "?")
147
+ } catch {
148
+ /* node not mounted yet */
149
+ }
150
+ try {
151
+ appendFileSync(livenessFile, `tick=${tick()} fg=${fg} text=${text}\n`)
152
+ } catch {
153
+ /* probe is best-effort test instrumentation */
154
+ }
155
+ }, 250)
156
+ }
157
+
158
+ // Registering a surface must never throw out of setup(): the peer floor is
159
+ // @opencode/plugin >=2.0.16, and a host without the slot API would otherwise
160
+ // take the whole TUI entry down — losing every other surface with it. A
161
+ // missing surface is logged once and the rest keeps working.
162
+ const registerSlot = (
163
+ name: string,
164
+ register: () => (() => void) | undefined,
165
+ ): (() => void) => {
166
+ try {
167
+ const unregister = register()
168
+ return () => {
169
+ try {
170
+ unregister?.()
171
+ } catch {
172
+ /* ignore */
173
+ }
174
+ }
175
+ } catch (error) {
176
+ console.error(
177
+ `[context-indicator] TUI slot "${name}" unavailable: ${String(error)}`,
178
+ )
179
+ return () => {}
180
+ }
181
+ }
182
+
183
+ const unregisterSidebar = registerSlot("sidebar.content", () =>
184
+ context.ui.slot({
185
+ append: "sidebar.content",
186
+ render: ({ sessionID }) => {
187
+ currentSession = sessionID
188
+ noteLive?.("slot-render sidebar.content")
189
+ const sidebar = comp(Sidebar, { entry })
190
+ if (!livenessProbe) return sidebar
191
+ // The probe must live in a real container: a bare array returned from a
192
+ // slot render is not mounted by every host version.
193
+ return h("box", {
194
+ flexDirection: "column",
195
+ get children() {
196
+ return [sidebar, livenessProbe()]
197
+ },
198
+ })
199
+ },
200
+ }),
201
+ )
202
+
203
+ // Always-on usage capsule inside the composer (`prompt.footer.status`,
204
+ // opencode 2.2.x): the percentage stays visible while the sidebar is
205
+ // collapsed and without opening the full-screen `/cx` panel. The slot input
206
+ // also carries `mode` (the pill is hidden in a shell prompt) and
207
+ // `showDetails` (the host's own compact/detailed toggle).
208
+ const unregisterComposer = registerSlot("prompt.footer.status", () =>
209
+ context.ui.slot({
210
+ append: "prompt.footer.status",
211
+ render: (input) => {
212
+ if (input?.sessionID) currentSession = input.sessionID
213
+ noteLive?.("slot-render prompt.footer.status")
214
+ return comp(ComposerStatus, {
215
+ entry,
216
+ mode: () => input?.mode ?? "normal",
217
+ showDetails: () => Boolean(input?.showDetails),
218
+ })
219
+ },
220
+ }),
221
+ )
222
+
223
+ // The probe is ALSO mounted in the `app` slot, which the host renders even
224
+ // when no session/sidebar is on screen (a standalone instance with no prompt
225
+ // has no sidebar.content contribution to mount). Whichever slot mounts
226
+ // first fills the sample file; both share the same poll tick.
227
+ const unregisterProbe = livenessProbe
228
+ ? registerSlot("app", () =>
229
+ context.ui.slot({
230
+ append: "app",
231
+ render: () => {
232
+ noteLive?.("slot-render app")
233
+ return livenessProbe()
234
+ },
235
+ }),
236
+ )
237
+ : undefined
238
+
239
+ // A `session.panel` contribution is independently selectable: it renders
240
+ // only while its name is the selected one, and the root session comes from
241
+ // panel.sessionID.
242
+ const unregisterPanel = registerSlot("session.panel", () =>
243
+ context.ui.slot({
244
+ append: "session.panel",
245
+ render: (panel) =>
246
+ comp(Show, {
247
+ get when() {
248
+ return panel.name === PANEL_NAME
249
+ },
250
+ get children() {
251
+ return comp(BreakdownPanel, { panel, snapshot, refresh })
252
+ },
253
+ }),
254
+ }),
255
+ )
256
+
257
+ // The `app` slot renders nothing itself; it exists to register the global
258
+ // keymap layer (slash command `cx` -> open the panel). The layer is owned by
259
+ // this slot and disposed when the slot unregisters.
260
+ const unregisterCommands = registerSlot("app (keymap)", () =>
261
+ context.ui.slot({
262
+ append: "app",
263
+ render: () => {
264
+ const keymap = context?.keymap
265
+ if (keymap && typeof keymap.layer === "function") {
266
+ keymap.layer(() => ({
267
+ mode: "global",
268
+ commands: [
269
+ {
270
+ id: "context-indicator.breakdown",
271
+ title: "Context breakdown panel",
272
+ slash: { name: "cx" },
273
+ // Palette-visible AND directly bound: subagent session views
274
+ // force the composer into its "subagents" tab, which pushes
275
+ // keymap mode "composer" — the base-mode palette binding
276
+ // (ctrl+p) is inert there and there is no prompt to type /cx.
277
+ // A bind on this mode:"global" layer fires in every mode
278
+ // (leader matches <leader>b "toggle sidebar"; f4 as a direct
279
+ // single-chord fallback).
280
+ palette: true,
281
+ bind: "<leader>c,f4",
282
+ run: () => {
283
+ const panelApi = context?.ui?.panel
284
+ if (panelApi && typeof panelApi.open === "function") {
285
+ panelApi.open(PANEL_NAME)
286
+ }
287
+ },
288
+ },
289
+ ],
290
+ }))
291
+ }
292
+ return null
293
+ },
294
+ }),
295
+ )
296
+
297
+ // One-time live-surface marker. The e2e Check A asserts this line from the
298
+ // isolated instance's private log: it proves the ./tui entry not only
299
+ // loaded but REGISTERED its surfaces — a host-transform failure of this
300
+ // file would otherwise die silently between "loading plugin" and here.
301
+ console.log(
302
+ "[context-indicator] live TUI surfaces registered (sidebar + composer + session.panel + keymap)",
303
+ )
304
+
305
+ // E2E instrumentation (only when test/e2e-tui.mjs sets the marker env):
306
+ // same proof as the console line above, but via a FILE — the TUI captures
307
+ // console output, so the log channel cannot carry it. Production runs
308
+ // never set the env and never write the stamp.
309
+ if (process.env.OPENCODE_CONTEXT_INDICATOR_TUI_MARKER) {
310
+ try {
311
+ mkdirSync(join(tmpdir(), "opencode-context-indicator"), { recursive: true })
312
+ writeFileSync(
313
+ join(tmpdir(), "opencode-context-indicator", "tui-live.stamp"),
314
+ new Date().toISOString(),
315
+ "utf8",
316
+ )
317
+ } catch {
318
+ /* marker is best-effort test instrumentation */
319
+ }
320
+ }
321
+
322
+ return () => {
323
+ clearInterval(timer)
324
+ if (probeTimer) clearInterval(probeTimer)
325
+ for (const unregister of [
326
+ unregisterSidebar,
327
+ unregisterComposer,
328
+ unregisterProbe,
329
+ unregisterPanel,
330
+ unregisterCommands,
331
+ ]) {
332
+ try {
333
+ unregister?.()
334
+ } catch {
335
+ /* ignore */
336
+ }
337
+ }
338
+ }
339
+ },
340
+ })