opencode-context-indicator 1.7.1 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,63 @@ Notable user-facing changes per release. Dates are commit days (UTC).
4
4
  The package also ships a dual V1/V2 plugin — see the README for the surface
5
5
  matrix; everything below concerns the V2 path unless stated otherwise.
6
6
 
7
+ ## 1.8.0 — 2026-10-31
8
+
9
+ - **The TUI sidebar and the `/cx` panel are live for npm installs too**
10
+ (workaround for upstream anomalyco/opencode#33884). OpenCode skips its
11
+ Solid/Babel JSX transform for any file inside a `node_modules` directory, so a
12
+ JSX-built slot mounted from an npm install rendered once and then froze on
13
+ "no data yet"; 1.7.x avoided that by disabling the surfaces entirely. The TUI
14
+ tree is now built with `jsx()` plus getter props (`lib/tui-h.ts`), which needs
15
+ no transform at all: the host rewrites the literal
16
+ `@opentui/solid/jsx-runtime` import to its own in-process module, so the
17
+ surfaces stay reactive from any install path. The npm-path auto-disable and its
18
+ console hint are gone; `file://` installs behave exactly as before.
19
+ - Relative imports of `.ts` files now carry the extension (`./lib/tui-h.ts`):
20
+ the host decides which files get the runtime rewrite by walking the import
21
+ graph with `import.meta.resolve`, which never probes extensions. An
22
+ extensionless import silently kept the literal `@opentui/solid` specifier
23
+ and failed with `Cannot find package '@opentui/solid'` - gate (b) now
24
+ rejects it.
25
+ - **No runtime dependencies at all.** `@opentui/solid` moved from
26
+ `dependencies` to `devDependencies` (types + local typecheck only, still
27
+ pinned to an exact version). A fresh install drops from ~28 MB / ~800 files to
28
+ a 147 KB tarball (27 files, ~419 KB unpacked), and the duplicate
29
+ `@opentui/core`
30
+ / Babel toolchain
31
+ (`@babel/core`, `babel-plugin-module-resolver`, `babel-preset-solid`, `s-js`,
32
+ `entities`) no longer lands in the opencode cache next to the host's own copy.
33
+ - **Baseline: opencode 2.2.5 / OpenTUI 0.8.** Dev dependencies, the peer floor
34
+ and the typecheck now target `@opentui/solid` + `@opentui/core` `0.8.0` and
35
+ `@opencode/plugin` `2.2.5` (opencode 2.2.3 made OpenTUI 0.8 mandatory for TUI
36
+ plugins; 2.2.5 is the latest release at the time of writing and still ships
37
+ 0.8.0). `jsx()`/getter semantics are unchanged in 0.8 - `spreadExpression`
38
+ still reads every prop inside a `createRenderEffect` - so the JSX-free tree
39
+ behaves exactly as before; the whole gate set (types, fast gate, Check C) was
40
+ re-run on a real 2.2.5 host. A host that still ships OpenTUI 0.7 (opencode
41
+ 2.2.0-2.2.2) may not load the TUI surfaces.
42
+ - **Denominator follows opencode 2.2.5.** The context window used for every
43
+ percentage is now `input && context ? min(input, context) : input || context`,
44
+ mirroring core's `inputWindow()` after #60660: a provider that reports an
45
+ input limit above the context window cannot use it in full, because the
46
+ context also holds the reply. Before this release an input limit above the
47
+ context window was used as-is, which overstated the window (and printed a
48
+ compact-at point the host never reaches). Covered by unit test T37. opencode
49
+ 2.2.5 also accepts a percentage `compaction.buffer`; that setting is not
50
+ visible to plugins, so a percentage buffer still shows the default reserve.
51
+ - **Gates cover the failure mode end to end.** Fast gate (b) is rewritten: the
52
+ TUI surface must contain no `.tsx`/`.jsx` file and no `@jsx*` pragma, every
53
+ TUI source must parse with esbuild `loader:"ts"` (JSX syntax fails), and
54
+ imports must stay inside the host-bridged allowlist. Gate (c) now walks the
55
+ real import graph and fails if a host-bridged module is a production
56
+ dependency. New e2e **Check C**: `npm pack` → extract into a throwaway
57
+ `node_modules/opencode-context-indicator` → isolated `--standalone` TUI →
58
+ assert an env-gated getter-driven probe keeps re-rendering (a frozen tree
59
+ writes the same value forever). `node test/e2e-tui.mjs --check-c` runs it
60
+ alone, without an LLM call.
61
+ - **Docs:** the README no longer describes a `file://`-only live sidebar;
62
+ "Live sidebar" documents the JSX-free design and the size win.
63
+
7
64
  ## 1.7.1 — 2026-10-29
8
65
 
9
66
  - **Fixed: the live log no longer prints `ctx=0` above real categories.** A hook
package/README.md CHANGED
@@ -15,8 +15,9 @@ generations) plus an optional TUI module (`./tui` entry) that renders a **live
15
15
  sidebar** and a **zero-LLM `/cx` breakdown panel** with measured contributors
16
16
  and a redacted export, and an **experimental Desktop GUI part**
17
17
  (`./gui-experimental`: a side-panel tab and a composer pill). The V2 path is
18
- **tested against opencode 2.2.0** (the isolated load + state-write e2e was also
19
- re-run against the 2.2.1 and 2.2.2 Desktop CLI); the V1 path against 1.18.29+.
18
+ **tested against opencode 2.2.5 / OpenTUI 0.8** (the isolated load + state-write
19
+ e2e was also re-run against the 2.2.0-2.2.4 Desktop CLI); the V1 path against
20
+ 1.18.29+.
20
21
 
21
22
  > **V1 is feature-frozen.** It is kept working for compatibility (bug fixes
22
23
  > only); all new features land in the V2 path.
@@ -32,7 +33,7 @@ re-run against the 2.2.1 and 2.2.2 Desktop CLI); the V1 path against 1.18.29+.
32
33
  For every served request the plugin estimates how the context window is split:
33
34
 
34
35
  ```
35
- [2026-…] session=ses_abc model=… ctx=45.2k/120k (38%) … input=44.1k
36
+ [2026-…] session=ses_abc model=… ctx=45.2k/120k (37.7%) count=45.2k usable=… limit=… maxOutput=… reserved=… input=44.1k
36
37
  user 4.2k (est tokens)
37
38
  assistant 3.1k (est tokens)
38
39
  reasoning 0.8k (est tokens) (exact 1.2k)
@@ -42,7 +43,7 @@ For every served request the plugin estimates how the context window is split:
42
43
  tool schemas 1.4k (est tokens)
43
44
  other 5.6k (= input - sum of estimates)
44
45
  source context-hook
45
- prompt cache read=12.0k (27% of input) write=1.1k (provider-reported)
46
+ prompt cache read=12.0k write=1.1k (provider-reported; subset of input)
46
47
  reclaim ~9.8k redundant tool output (3 duplicate(s) — estimate)
47
48
  subagents 2 session(s): in=8.1k out=2.3k r=1.0k worst=12%
48
49
  ```
@@ -61,7 +62,9 @@ For every served request the plugin estimates how the context window is split:
61
62
  `input + output + cache.read + cache.write`. Percentages are **not clamped**
62
63
  at 100% — exceeding the window stays visible.
63
64
  * `prompt cache` is **provider-reported and exact** (`tokens.cache.read` /
64
- `tokens.cache.write`), shown as a share of `input`. It is a *subset* of input,
65
+ `tokens.cache.write`). The live snapshot prints the raw read/write counts;
66
+ `/context` and `/context-breakdown` also append the read share of `input`
67
+ when the provider reported it. It is a *subset* of input,
65
68
  so it is deliberately **not** added to the category sum: a cache read means the
66
69
  provider served those prompt tokens from its prompt cache, not that they are
67
70
  absent from the window. `n/a` when the provider reports nothing.
@@ -73,10 +76,11 @@ For every served request the plugin estimates how the context window is split:
73
76
  — never as "the model wasted effort". Findings are per-session and **path-free
74
77
  in the persisted/exported form**.
75
78
  * The denominator is the **opencode 2.x compaction ceiling**: `window =
76
- limit.input || limit.context`; `reserve = max(floor(window * 0.15), window >=
79
+ limit.input && limit.context ? min(limit.input, limit.context) : limit.input
80
+ || limit.context`; `reserve = max(floor(window * 0.15), window >=
77
81
  2 * 16_000 ? 16_000 : 0)`; the ceiling is `window - reserve`. This mirrors
78
82
  `calculateCeiling()` in `packages/core/src/session/compaction.ts`, whose
79
- `RESERVE_SHARE` is 0.15 in every released host from 2.1.4 through 2.2.2 (the
83
+ `RESERVE_SHARE` is 0.15 in every released host from 2.1.4 through 2.2.5 (the
80
84
  unreleased `v2` branch has a different share; the plugin follows the release,
81
85
  not the branch). When opencode
82
86
  carries a `compaction.buffer` configured in its own settings, the plugin
@@ -85,6 +89,9 @@ For every served request the plugin estimates how the context window is split:
85
89
  to override the reserve for testing or custom setups (non-negative integer <=
86
90
  1e9; invalid values are silently ignored; a buffer that is **not smaller than
87
91
  the window** is ignored, exactly like core, so it can never leave zero room).
92
+ Since opencode 2.2.5 that setting also accepts a percentage (`"buffer": "20%"`),
93
+ which the plugin cannot read either, so a percentage buffer is **not**
94
+ reflected in the "compact at" line until it is mirrored from core.
88
95
 
89
96
  ### Toast (V1 only)
90
97
 
@@ -116,9 +123,9 @@ other 5.6k 12%
116
123
  updated 12:30:01
117
124
  ```
118
125
 
119
- > Requires a `file://` install from a path outside `node_modules`; on the npm
120
- > path the sidebar is auto-disabled. See
121
- > [Live sidebar: npm vs file:// install](#live-sidebar-npm-vs-file-install).
126
+ > Works on **every** install path, including the npm one — the TUI surfaces are
127
+ > built without JSX precisely so `node_modules` installs stay live. See
128
+ > [Live sidebar](#live-sidebar).
122
129
 
123
130
  ### Slash commands (V2)
124
131
 
@@ -127,13 +134,13 @@ commands:
127
134
 
128
135
  | Command | Scope | Delivery | Agent turn? |
129
136
  | --- | --- | --- | :---: |
130
- | `/context` | **this session only** (no subagents); cache + `state.json` only | `resume:true` — the model echoes the 3-line summary into the transcript | yes |
137
+ | `/context` | **this session only** (no subagents); cache + `state.json` only | `resume:true` - the model echoes the short summary into the transcript | yes |
131
138
  | `/context-breakdown` | this session **+ all subagent descendants** | `resume:true` — the model echoes the table into the transcript | yes |
132
139
  | `/context-diff` | **this session only**; previous measurement vs current | `resume:true` — the model echoes the delta table into the transcript | yes |
133
140
 
134
141
  #### `/context` — quick summary (main session only)
135
142
 
136
- A 3-line summary of the current session, built purely from the in-memory cache
143
+ A short summary of the current session, built purely from the in-memory cache
137
144
  and the `state.json` snapshot: **no subagent traversal and no live RPC**, so
138
145
  collection is instant (delivery itself still runs an agent turn — see below).
139
146
  The synthetic notice chip shows a self-contained one-liner (label =
@@ -157,6 +164,7 @@ instructions to follow.
157
164
  <context-summary>
158
165
  **Context** — your-model · 41.5k / 168k (25%)
159
166
  usr 2.3k · asst 285 · rsn 9.6k · tool 2.1k · out 24.7k · sys 8.5k · schm 3.8k · oth 4.1k
167
+ compact at 143k (25.0k reserve)
160
168
  updated 12:36:16 (snap)
161
169
  </context-summary>
162
170
  ```
@@ -266,7 +274,8 @@ the content is visible in the Desktop (the chip only shows `description`).
266
274
  `CONTEXT_BREAKDOWN_RESUME` controls `/context-breakdown` back to a plain
267
275
  notice if needed.
268
276
 
269
- > **E2E note (Desktop 2.0.19, local model `llama3.2:latest`, `build` agent).**
277
+ > **E2E note (historical run on Desktop 2.0.19, local model `llama3.2:latest`,
278
+ > `build` agent).**
270
279
  > `resume:true` behaved as designed: the synthetic item was drained into
271
280
  > `/message` and a turn ran; the assistant message contained the table verbatim
272
281
  > (the trailing `_…_` note line was dropped by the model). Because `resume:true`
@@ -362,9 +371,8 @@ carries its tool name); tool **result outputs** are counted here but not by the
362
371
  server's categories — which is why the residual `other` can look large. Top 5
363
372
  are shown. Empty state: `no messages yet`.
364
373
 
365
- Live only for a `file://` install from a path outside `node_modules`; the npm
366
- path auto-disables it (upstream `#33884`). The Desktop has no TUI, so it uses
367
- `/context`, `/context-breakdown` and `/context-diff` instead.
374
+ Live on every install path (npm and `file://`). The Desktop has no TUI, so it
375
+ uses `/context`, `/context-breakdown` and `/context-diff` instead.
368
376
 
369
377
  #### Export (redacted)
370
378
 
@@ -473,9 +481,8 @@ Notes — these reflect what the plugin API actually exposes today:
473
481
  therefore delivers everything through the log file, the TUI sidebar, the
474
482
  `/cx` session panel and the `/context` / `/context-breakdown` / `/context-diff` slash commands. The sidebar is a TUI-process
475
483
  slot and does not exist in the Desktop app; the slash commands work in **both**.
476
- The TUI sidebar is **live only for a `file://` install** from a path outside
477
- `node_modules`; on the npm path it is auto-disabled (upstream `#33884`) — see
478
- [Live sidebar: npm vs file:// install](#live-sidebar-npm-vs-file-install).
484
+ The TUI sidebar is live on **every** install path, npm included — see
485
+ [Live sidebar](#live-sidebar).
479
486
  * **V1 has no sidebar and no slash command.** The command/slot API (and the
480
487
  `./tui` entry) is a V2 feature; OpenCode 1.x has no equivalent, so V1 is
481
488
  toast + log only.
@@ -537,8 +544,8 @@ or add it to `opencode.jsonc`:
537
544
  ```
538
545
 
539
546
  The `./tui` sidebar entry is loaded automatically alongside the main plugin in
540
- the terminal TUI — but its live behaviour depends on the install method, see
541
- [Live sidebar: npm vs file:// install](#live-sidebar-npm-vs-file-install) below.
547
+ the terminal TUI, on every install method — see [Live sidebar](#live-sidebar)
548
+ below.
542
549
  For a CLI-only setup against remote servers, the package can also be listed in
543
550
  [`cli.json`](https://opencode.ai/v2/docs/cli/plugins):
544
551
 
@@ -546,45 +553,38 @@ For a CLI-only setup against remote servers, the package can also be listed in
546
553
  { "plugins": ["opencode-context-indicator"] }
547
554
  ```
548
555
 
549
- ### Live sidebar: npm vs file:// install
556
+ ### Live sidebar
550
557
 
551
- The TUI sidebar is a **TUI-process slot**. On OpenCode 2.x it behaves
552
- differently depending on **how the plugin was installed** — an upstream OpenCode
553
- limitation (`anomalyco/opencode#33884`), not a plugin bug: OpenCode's TUI loader
554
- skips the host Solid transform for any path inside a `node_modules` directory, so
555
- a slot mounted from an npm install renders once and then never live-updates.
558
+ The TUI sidebar and the `/cx` panel are **TUI-process slots**, live on every
559
+ install path — npm (`opencode plugin add opencode-context-indicator`), `file://`
560
+ and the local `plugins/` copy alike.
556
561
 
557
- | Install | Desktop | TUI commands | TUI live sidebar | TUI panel (/cx) |
558
- | --- | :---: | :---: | :---: | :---: |
559
- | npm (`opencode plugin add opencode-context-indicator`) | ✅ | ✅ | ❌ auto-disabled (upstream #33884) | ❌ auto-disabled |
560
- | `file://` (path **outside** any `node_modules`) | ✅ | ✅ | ✅ live | ✅ live |
561
-
562
- On the **npm** path the plugin detects that it lives under `node_modules` and
563
- **disables the sidebar and the panel gracefully** — it logs a single hint and
564
- registers no slot, so you never see a frozen "no data yet" panel. The `/context`
565
- / `/context-breakdown` / `/context-diff` slash commands and the breakdown log file keep working on
566
- every install.
567
-
568
- #### Getting the live sidebar (`file://` install)
562
+ That used to be false: OpenCode skips its Solid/JSX transform for any file inside
563
+ a `node_modules` directory (upstream `anomalyco/opencode#33884`), so a JSX-built
564
+ slot mounted from an npm install rendered once and then froze on
565
+ "no data yet". Since **1.8.0** the plugin ships **no JSX at all**: the tree is
566
+ built with `jsx()` and getter props (`lib/tui-h.ts`), which needs no transform,
567
+ and the host rewrites the literal `@opentui/solid/jsx-runtime` import to its own
568
+ in-process module. The package therefore also declares **no runtime
569
+ dependencies** — nothing is installed next to it, so there is no second
570
+ Solid/OpenTUI instance to fight with.
569
571
 
570
- 1. Put the package **outside** any `node_modules`: clone the repository, or
571
- unpack the npm tarball (`npm pack opencode-context-indicator`, then extract
572
- the `.tgz`) — e.g. into `C:\Users\you\plugins\opencode-context-indicator`.
573
- Double-check that the chosen path contains **no `node_modules` segment**
574
- (e.g. `C:\Users\you\plugins\...`, not `.../node_modules/...`).
575
- 2. Point OpenCode at that directory in `~/.config/opencode/opencode.jsonc`:
572
+ | Install | Desktop | TUI commands | TUI live sidebar | TUI panel (`/cx`) |
573
+ | --- | :---: | :---: | :---: | :---: |
574
+ | npm | ✅ | ✅ | ✅ live | ✅ live |
575
+ | `file://` (any path) | ✅ | ✅ | ✅ live | ✅ live |
576
576
 
577
- ```jsonc
578
- {
579
- "plugins": ["file:///C:/Users/you/plugins/opencode-context-indicator"]
580
- }
581
- ```
577
+ `/context`, `/context-breakdown`, `/context-diff` and the breakdown log file work
578
+ on every install regardless.
582
579
 
583
- 3. Restart OpenCode. The sidebar is now live and updates as context grows.
580
+ > If a sidebar shows "no data yet" forever on 1.8.0+, that is a bug — please
581
+ > report it with the host version. On 1.7.x and older, the npm path was disabled
582
+ > by design; upgrading is the fix.
584
583
 
585
- > ⚠️ The `file://` path must **not** contain a `node_modules` segment. If it
586
- > does, OpenCode skips the Solid transform and the sidebar will not update; the
587
- > plugin auto-disables it and logs the hint above.
584
+ `test/e2e-tui.mjs` Check C proves this end to end: it runs `npm pack`, extracts
585
+ the tarball into a throwaway `node_modules/opencode-context-indicator`, boots an
586
+ isolated `--standalone` TUI from there and asserts that a getter-driven probe
587
+ node actually re-renders (a frozen tree writes the same value forever).
588
588
 
589
589
  ### OpenCode 1.x (V1)
590
590
 
@@ -601,25 +601,28 @@ Add the package name to the `plugin` array in your `opencode.json`:
601
601
  Copy `index.js` and the whole `lib/` directory into
602
602
  `~/.config/opencode/plugins/`, keeping the `lib/` subdirectory next to the
603
603
  plugin file so the plugin's relative imports resolve. The `lib/` folder is
604
- support code, not a separate plugin entry:
604
+ support code, not a separate plugin entry; copy **every** module in it
605
+ (`cache.js`, `commands.js`, `contributors.js`, `dedup.js`, `desktop.js`,
606
+ `estimate.js`, `format.js`, `limits.js`, `rpc.js`, `state.js`, `tui-data.js`,
607
+ `tui-data.d.ts`, `tui-h.ts`, `tui-panel.ts`, `tui-sidebar.ts`, `tui-theme.js`,
608
+ `v1.js`, `v2.js`) — a
609
+ missing module only disables the surface that imports it, but the server part
610
+ stops loading if one of its own imports is absent:
605
611
 
606
612
  ```
607
- ~/.config/opencode/plugins/context-indicator.js
608
- ~/.config/opencode/plugins/lib/cache.js
609
- ~/.config/opencode/plugins/lib/commands.js
610
- ~/.config/opencode/plugins/lib/dedup.js
611
- ~/.config/opencode/plugins/lib/estimate.js
612
- ~/.config/opencode/plugins/lib/limits.js
613
- ~/.config/opencode/plugins/lib/state.js
614
- ~/.config/opencode/plugins/lib/v1.js
615
- ~/.config/opencode/plugins/lib/v2.js
613
+ ~/.config/opencode/plugins/context-indicator.js (this package's index.js)
614
+ ~/.config/opencode/plugins/lib/*.js
615
+ ~/.config/opencode/plugins/lib/*.ts
616
616
  ```
617
617
 
618
+ `tui.ts` (the `./tui` entry, at the package root) is only needed for the
619
+ terminal TUI surfaces; it imports `lib/tui-h.ts`, `lib/tui-sidebar.ts` and
620
+ `lib/tui-panel.ts` by their `.ts` file names, so copy those alongside the `.js`
621
+ modules.
622
+
618
623
  To use the sidebar locally as well, point OpenCode at a `file://` copy of the
619
- package that lives **outside any `node_modules`** (see
620
- [Live sidebar: npm vs file:// install](#live-sidebar-npm-vs-file-install)) — that
621
- is the only install path that keeps the sidebar live. An npm install auto-disables
622
- the sidebar instead.
624
+ package — the sidebar is live there too, exactly as with an npm install (see
625
+ [Live sidebar](#live-sidebar)).
623
626
 
624
627
  ---
625
628
 
@@ -635,26 +638,22 @@ the sidebar instead.
635
638
  indicator degrades
636
639
  gracefully (the breakdown falls back to throttled `session.messages` fetches
637
640
  and toasts keep working).
638
- * **OpenCode ≥ 2.2.0** for the V2 path with the TUI surfaces (built and verified
639
- against **2.2.0**; the isolated load + state-write e2e also passes on the
640
- **2.2.2** Desktop CLI, the latest at the time of writing). The server-only V2
641
- path keeps working on
642
- 2.0.16+; the TUI module pins the OpenTUI generation it was compiled against,
643
- so a host on OpenTUI 0.6 (opencode ≤ 2.1.4) will not load the TUI surfaces -
644
- neither the sidebar nor the `/cx` panel - from a 1.6.x install.
641
+ * **OpenCode ≥ 2.2.3** for the V2 path with the TUI surfaces (built and verified
642
+ against **2.2.5**, the latest at the time of writing, which still ships OpenTUI
643
+ **0.8**; the isolated load + state-write e2e also passes on the 2.2.0-2.2.4
644
+ Desktop CLI). The server-only V2 path keeps working on
645
+ 2.0.16+. The TUI module is typed against the OpenTUI generation the host ships,
646
+ so a host on an older OpenTUI (0.7 for opencode 2.2.0-2.2.2, 0.6 for
647
+ opencode ≤ 2.1.4) may not load the TUI surfaces - neither the sidebar nor the
648
+ `/cx` panel - from a 1.8.x install.
645
649
  * **TUI sidebar**: terminal TUI only, requires the OpenTUI rendering stack that
646
- ships with OpenCode. `@opentui/core` and `solid-js` are **optional** peer
647
- dependencies resolved by OpenCode at runtime; the main plugin installs and runs
648
- fine without them (the sidebar simply is not available). `@opentui/solid` is
649
- instead shipped as a **pinned direct dependency** (exact `0.7.0`, matching
650
- the OpenTUI stack OpenCode 2.2.0 ships): OpenCode's
651
- TUI loader does not expose a host instance of it, so for npm-installed plugins
652
- the JSX pragma would otherwise fail to resolve `@opentui/solid/jsx-runtime`
653
- (upstream: opencode issue #33884 — `node_modules` plugins are excluded from the
654
- host Solid transform and get an isolated OpenTUI copy). Because of that same
655
- upstream limitation, on the **npm** path the sidebar is **auto-disabled**
656
- (the plugin logs one hint and registers no slot) — use a `file://` install for
657
- the live sidebar, see [Live sidebar: npm vs file:// install](#live-sidebar-npm-vs-file-install).
650
+ ships with OpenCode. `@opentui/core`, `@opentui/solid` and `solid-js` are
651
+ resolved by OpenCode at runtime from its **own** modules (the TUI loader
652
+ rewrites those imports to in-process instances), so the package declares them
653
+ as optional peers only and installs **no runtime dependencies at all**. This is
654
+ what makes the sidebar live for npm installs as well
655
+ (see [Live sidebar](#live-sidebar)); `@opentui/solid` sits in
656
+ `devDependencies` purely for local typechecking.
658
657
  * **Desktop GUI part** (optional, experimental): needs an OpenCode Desktop
659
658
  release that loads the `./gui-experimental` export (opencode 2.2.0+, where the
660
659
  GUI plugin API exists). The bundle is prebuilt and ships in the package, so it
@@ -727,10 +726,11 @@ caller.
727
726
 
728
727
  ```bash
729
728
  npm run build:gui # build dist/gui-experimental.cjs (required by the gate)
730
- npm run test:prepublish # fast gate: tarball + tui.tsx + resolve + T1-T36 unit checks + GUI bundle
729
+ npm run test:prepublish # fast gate: tarball + JSX-free TUI + resolve + T1-T36 unit checks + GUI bundle
731
730
  npm run test:types # tsc for the TUI entry AND the Desktop GUI part
732
- npm run test:e2e # full E2E: isolated plugin load + one "Say OK" round-trip
731
+ npm run test:e2e # full E2E: plugin load + one "Say OK" round-trip + npm-layout liveness
733
732
  npm run test:e2e:quick # E2E load-only, no LLM call
733
+ npm run test:e2e -- --check-c # E2E npm-layout liveness only, no LLM call
734
734
  npm run test:drift # advisory: warns on non-breaking host drift (also in CI)
735
735
  npm run test:drift:strict # release check: fails on ANY host drift
736
736
  ```
@@ -739,7 +739,7 @@ npm run test:drift:strict # release check: fails on ANY host drift
739
739
  (`npm run build:gui && node test/prepublish.mjs && node test/e2e-tui.mjs --full`) and is skipped with
740
740
  `--ignore-scripts`. The whole chain takes roughly **1 minute** (fast gate ~20 s,
741
741
  isolated E2E ~10 s warm / up to ~90 s cold); the E2E is bounded by INIT 45 s +
742
- STEP 80 s, ~3 min total.
742
+ STEP 80 s + PROBE 45 s, ~4 min total.
743
743
 
744
744
  ### What the gates check
745
745
 
@@ -748,10 +748,10 @@ STEP 80 s, ~3 min total.
748
748
  | Step | What | Why |
749
749
  |---|---|---|
750
750
  | (a) tarball composition | `package.json` `files[]` expands to the base files plus every `lib/*` module (count asserted dynamically); `docs/` may contain images only; `dist/` may contain only the built GUI bundle | no test/node_modules/tsconfig in the tarball, and no published export pointing at nothing |
751
- | (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 |
752
- | (c) resolve chain | replica install: host-bridged + peer packages resolve; local tui.tsx relative imports resolve via node from the entry (resolve-bridge.cjs) | broken peer/optional dep or a bad local import silently breaks the sidebar |
751
+ | (b) JSX-free TUI | no `.tsx`/`.jsx` in the TUI surface (root + `lib/`), every TUI source parses with esbuild `loader:"ts"` (JSX syntax fails), no `@jsx*` pragma, imports stay inside the host-bridgeable allowlist, relative imports carry a file extension | OpenCode skips its Solid transform under `node_modules` (#33884): one JSX file freezes the npm-installed sidebar |
752
+ | (c) resolve chain | walks the real TUI import graph (`resolve-bridge.cjs`): every bare specifier is host-bridged, every relative one resolves to a shipped file, and **no host-bridged module is a production dependency** (a second Solid/OpenTUI instance freezes the surfaces) | an accidental runtime dependency, or a bad local import, silently breaks the sidebar |
753
753
  | (d) unit harness T1-T36 | limit/agent/state fix assertions plus measureContributors, redacted-export, shared category-helper, sanitizer/unicode, prompt-cache, waste-detector, compaction-reserve, top-consumer, diff, subagent-rollup and Desktop-RPC checks (86 total; T30 schema-vs-builder drift, T31 registration + handler, T32-T36 fallback-vs-hook category priority across the report, the shared state file and the live log (T36: a counter-less hook entry borrows the snapshot or prints `?`), T25c/T25d compaction-hook arming, T34 cross-instance summary merge) | regressions in modelLimits, writeStateFile, hydrate, contributors, export, subagent lifetime, and a GUI/server wire mismatch |
754
- | (e) corporate-identifier scan | no forbidden identifiers in the published surface (`index.js`, `tui.tsx`, `lib/*`, `gui/*`, `README.md`, `CHANGELOG.md`, `package.json`, the GUI build config) or the test tree | compliance |
754
+ | (e) corporate-identifier scan | no forbidden identifiers in the published surface (`index.js`, `tui.ts`, `lib/*`, `gui/*`, `README.md`, `CHANGELOG.md`, `package.json`, the GUI build config) or the test tree | compliance |
755
755
  | (f) non-English scan | no non-ASCII letters (`\p{L}` above U+007F) in the same published surface and test tree (`docs/` is image-only and is not text-scanned) | English-only source rule |
756
756
  | (g) Desktop GUI bundle | `dist/gui-experimental.cjs` exists and is a non-trivial single-file CJS bundle: no ESM `import`, no dynamic `import()`, no Node built-in, and every app-provided module (`solid-js`, `solid-js/web`, `@opencode/plugin/gui`, `@opencode/plugin/rpc`) still a `require()` - no bundled second copy, no React/OpenTUI/bare-`opencode` import | a stale or hand-edited bundle, or a build that inlines the app instance, breaks the Desktop panel at load time |
757
757
 
@@ -761,6 +761,7 @@ STEP 80 s, ~3 min total.
761
761
  |---|---|---|
762
762
  | A — plugin load | spins a private `opencode --standalone` PTY against an **isolated global config** (`OPENCODE_CONFIG_DIR` → `%TEMP%/oci-e2e-config`, sole plugin entry = this checkout's `file://` URL), then asserts **from the host opencode log** (atomic lines, immune to PTY interleaving) that the plugin loaded and its `entrypoint` is **this checkout's `index.js`** — both fail-closed | catches a plugin that no longer imports cleanly, or an isolated config the host did not honor |
763
763
  | B — live update | one `opencode run "Say OK" --standalone` round-trip; asserts the LLM answered **and** the isolated `state.json` gained a session with `ctx > 0` | catches a regression in the `session.idle` → `writeStateFile` path |
764
+ | C — npm-layout liveness | `npm pack` → extract into a throwaway `node_modules/opencode-context-indicator` → isolated `--standalone` TUI from there; an env-gated probe node (getter-driven `ref`) must produce three different rendered texts | proves the #33884 workaround: the packaged install stays **reactive** from a `node_modules` path, where the host skips the Solid transform |
764
765
 
765
766
  The E2E is fully **non-invasive** to your running OpenCode Desktop:
766
767
 
@@ -777,6 +778,8 @@ The E2E is fully **non-invasive** to your running OpenCode Desktop:
777
778
  Check B uses your real global config read-only (the round-trip needs the
778
779
  provider/model defined there). Your real config is never modified;
779
780
  - `OPENCODE_DISABLE_PROJECT_CONFIG=1` avoids project-level config;
781
+ - Check C uses its own private config, XDG roots, DB and state file under
782
+ `%TEMP%/oci-check-c` (nothing is shared with Check A or with your install);
780
783
  - any `opencode-cli` instance the test itself spawned is force-killed after each
781
784
  check (`serve --service` — your Desktop background service — is never touched);
782
785
  - exactly **one** minimal LLM call (`Say OK`) is made, in the isolated session.
@@ -796,7 +799,7 @@ and on a daily schedule (GitHub auto-parks scheduled workflows after 60 days of
796
799
  repo inactivity — a push or manual run re-enables them):
797
800
 
798
801
  1. `npx --no-install tsc --noEmit` and `npx --no-install tsc -p tsconfig.gui.json --noEmit`
799
- - typecheck of the TUI entry (`tui.tsx` + its `lib/*` imports) and of the
802
+ - typecheck of the TUI entry (`tui.ts` + its `lib/*` imports) and of the
800
803
  Desktop GUI part (`gui/*.tsx`, Solid JSX);
801
804
  2. `npm run build:gui` - build the Desktop GUI bundle before the gate inspects it;
802
805
  3. `test/prepublish.mjs` - the full fast gate (a)-(g), including the standalone
package/index.js CHANGED
@@ -117,7 +117,7 @@
117
117
  * %TEMP%\opencode-context-indicator-state.json (os.tmpdir())
118
118
  * — a machine-readable { sessionID: { model, providerID, ctx, usable,
119
119
  * reserve, limit, categories{...}, updatedAt } } snapshot consumed by the
120
- * optional TUI sidebar in ./tui.tsx (package entry "./tui"). Written atomically
120
+ * optional TUI sidebar in ./tui.ts (package entry "./tui"). Written atomically
121
121
  * (temp + rename), keyed by session, bounded to MAX_TRACKED_SESSIONS, and
122
122
  * strictly additive: any failure is swallowed and never affects the log.
123
123
  *
@@ -128,9 +128,10 @@
128
128
  * - Metric: the native opencode overflow count — tokens.total when present,
129
129
  * else input + output + cache.read + cache.write — compared against the
130
130
  * model's compaction ceiling (opencode calculateCeiling, released 2.x):
131
- * window = limit.input || limit.context, reserve = max(15% of window, 16k
132
- * when window >= 32k), usable = window - reserve. Percentages are NOT
133
- * clamped at 100% - exceeding the ceiling must stay visible.
131
+ * window = min(input, context) when both are known, else input || context;
132
+ * reserve = max(15% of window, 16k when window >= 32k);
133
+ * usable = window - reserve. Percentages are NOT
134
+ * clamped at 100% - exceeding the ceiling must stay visible.
134
135
  * - Category estimates use a unicode-aware heuristic (see estimateTokens):
135
136
  * Cyrillic ~2.5 chars/token, CJK ~1.5, latin/ASCII ~4. These are ESTIMATES,
136
137
  * not a tokenizer; "other" is the residual input − sum of estimates.
@@ -1,6 +1,7 @@
1
1
  // lib/contributors.js
2
2
  //
3
- // Pure contributor-measurement helpers extracted from tui.tsx for unit testing.
3
+ // Pure contributor-measurement helpers extracted from the TUI panel (lib/tui-panel.ts)
4
+ // for unit testing.
4
5
  // All functions are stateless transforms of their arguments.
5
6
  //
6
7
  // Functions: asRecord, safeText, measureContributors, contributorLine,
@@ -185,7 +186,7 @@ function exportFileName(sessionID, now) {
185
186
  }
186
187
 
187
188
  // Build the redacted export payload. All IO (mkdirSync/writeFileSync) and
188
- // toasts stay in tui.tsx as the thin exportRedacted() wrapper.
189
+ // toasts stay in lib/tui-panel.ts as the thin exportRedacted() wrapper.
189
190
  //
190
191
  // Fields: generatedAt, session{id8-prefixed id, model, providerID},
191
192
  // family[role/model/ctx/usable/limit/pct/updated], contributors[kind/tool/ref/
@@ -193,7 +194,7 @@ function exportFileName(sessionID, now) {
193
194
  // content, NO contributor preview text.
194
195
  //
195
196
  // panelDenom / panelRole are passed as parameters so this file stays pure and
196
- // tui.tsx controls their source.
197
+ // lib/tui-panel.ts controls their source.
197
198
  function buildRedactedPayload({ sessionID, root, rows, contributors, now, panelDenom, panelRole, num }) {
198
199
  const id8 = String(sessionID).slice(0, 8);
199
200
  return {
@@ -227,7 +228,7 @@ function buildRedactedPayload({ sessionID, root, rows, contributors, now, panelD
227
228
  }
228
229
 
229
230
  // ---------------------------------------------------------------------------
230
- // Panel helpers (pure, shared with tui.tsx)
231
+ // Panel helpers (pure, shared with lib/tui-panel.ts)
231
232
  // ---------------------------------------------------------------------------
232
233
 
233
234
  // Per-row denominator with the same fallback cascade as the server table:
package/lib/limits.js CHANGED
@@ -10,10 +10,12 @@
10
10
  // Compaction ceiling (opencode calculateCeiling): opencode's core
11
11
  // calculateCeiling() in packages/core/src/session/compaction.ts derives the
12
12
  // auto-compaction trigger ceiling as window - reserve, where
13
- // window = limit.input || limit.context (null/zero falls through to the context
14
- // window) and
13
+ // window = input && context ? min(input, context) : input || context
14
+ // (null/zero falls through; since opencode 2.2.5 (#60660) an input limit ABOVE
15
+ // the context window is capped at the context window, because the context also
16
+ // holds the reply) and
15
17
  // reserve = max(floor(window * 0.15), window >= 2 * 16_000 ? 16_000 : 0)
16
- // `RESERVE_SHARE = 0.15` in every RELEASED host from 2.1.4 through 2.2.2
18
+ // `RESERVE_SHARE = 0.15` in every RELEASED host from 2.1.4 through 2.2.5
17
19
  // (verified tag by tag; the `2 * RESERVE_MIN` gate is older and unchanged). The
18
20
  // unreleased `v2` branch HEAD has an inline `0.1` instead - do NOT mirror a
19
21
  // branch head: a wrong reserve makes every displayed percentage wrong.
@@ -21,6 +23,8 @@
21
23
  // plugin (the plugin cannot read opencode's internal compaction config); the
22
24
  // env var OPENCODE_CONTEXT_INDICATOR_COMPACT_BUFFER overrides the reserve for
23
25
  // testing and power users only (non-negative finite integer <= 1e9, ignore invalid).
26
+ // opencode 2.2.5 also accepts a percentage there ("buffer": "20%"), which is
27
+ // likewise invisible here, so a percentage buffer shows the default reserve.
24
28
  // Like core, a buffer that is NOT smaller than the window is ignored: a buffer
25
29
  // set for a larger model must never leave zero room.
26
30
 
@@ -86,7 +90,7 @@ function getModelLimit(providerID, modelID) {
86
90
 
87
91
  // Compute the reserve for a given window, matching opencode's
88
92
  // packages/core/src/session/compaction.ts calculateCeiling() as RELEASED
89
- // (RESERVE_SHARE = 0.15 in 2.1.4 .. 2.2.2; the branch head is not a release).
93
+ // (RESERVE_SHARE = 0.15 in 2.1.4 .. 2.2.5; the branch head is not a release).
90
94
  // Returns null when
91
95
  // window is not a finite positive number.
92
96
  const RESERVE_SHARE = 0.15; // core: RESERVE_SHARE in every released 2.x
@@ -118,7 +122,7 @@ function reserveFor(window, bufferOverride) {
118
122
  }
119
123
 
120
124
  // Native opencode compaction ceiling (calculateCeiling, released 2.x):
121
- // window = limit.input || limit.context (zero/null falls through to context)
125
+ // window = input && context ? min(input, context) : input || context
122
126
  // reserve = reserveFor(window) [or bufferOverride when provided]
123
127
  // usable = window - reserve
124
128
  // Returns { usable, limit: window, maxOutput, reserved } or null when not
@@ -127,6 +131,11 @@ function reserveFor(window, bufferOverride) {
127
131
  // participate in this calculation; opencode's internal buffer is not visible.
128
132
  // The buffer override (env or argument) replaces the whole reserve only while
129
133
  // it is smaller than the window — a buffer >= window falls back to the share.
134
+ //
135
+ // The min() is opencode 2.2.5 (#60660): a provider that reports an input limit
136
+ // ABOVE the context window cannot use it in full, because the context also holds
137
+ // the reply. Using input alone would overstate the denominator (and show a
138
+ // compact-at point the host never reaches).
130
139
  function getUsableContext(providerID, modelID, bufferOverride) {
131
140
  const rec = getModelLimitRecord(providerID, modelID);
132
141
  if (!rec) return null;
@@ -135,8 +144,11 @@ function getUsableContext(providerID, modelID, bufferOverride) {
135
144
  const input =
136
145
  typeof rec.input === "number" && rec.input > 0 ? rec.input : null;
137
146
  if (input == null && context == null) return null;
138
- // Mirror core's limit.input || limit.context (zero/null falls through to context).
139
- const window = input != null && input > 0 ? input : context;
147
+ // Mirror core's inputWindow(): min(input, context) when both are known.
148
+ // The `??` is core's `||` here: a non-positive input was normalized to null
149
+ // above, so there is no 0 to fall through.
150
+ const window =
151
+ input != null && context != null ? Math.min(input, context) : input ?? context;
140
152
  if (window == null || !(window > 0)) return null;
141
153
 
142
154
  // When bufferOverride is omitted, read it from the env var once per call.
package/lib/state.js CHANGED
@@ -60,7 +60,7 @@ const LOG_FILE =
60
60
  process.env.OPENCODE_CONTEXT_INDICATOR_LOG_FILE ||
61
61
  join(tmpdir(), "context-breakdown.log");
62
62
 
63
- // Machine-readable snapshot consumed by the optional TUI sidebar (./tui.tsx,
63
+ // Machine-readable snapshot consumed by the optional TUI sidebar (./tui.ts,
64
64
  // package entry "./tui"). Written next to each log update — same moment, same
65
65
  // data. See writeStateFile() below.
66
66
  // State file path — overridable via env for test isolation.
@@ -417,7 +417,7 @@ function renameWithRetry(from, to, attempts = 3, delayMs = 50) {
417
417
  // TUI state bridge (opencode-context-indicator)
418
418
  // ---------------------------------------------------------------------------
419
419
  // Persist a tiny machine-readable snapshot of the CURRENT breakdown for one
420
- // session so the optional TUI sidebar (./tui.tsx, package entry "./tui") can
420
+ // session so the optional TUI sidebar (./tui.ts, package entry "./tui") can
421
421
  // render it without talking to the server. The file is one JSON object keyed by
422
422
  // sessionID, rewritten atomically (temp + rename) at every log write — same
423
423
  // moment, same data. Strictly additive: any failure is swallowed and must never
@@ -460,9 +460,10 @@ function writeStateFile(
460
460
  try {
461
461
  const bd = breakdownCache.get(sessionID) || null;
462
462
  const usableInfo = getUsableContext(providerID, modelID);
463
- // Persist the EFFECTIVE window (limit.input || limit.context — the same
464
- // window the ceiling was derived from), not the raw context window: on a
465
- // cold instance hydrateModelLimitsFromState seeds context = limit, so a
463
+ // Persist the EFFECTIVE window (min(input, context) when both are known,
464
+ // else input || context: the same window the ceiling was derived from),
465
+ // not the raw context window: on a cold instance
466
+ // hydrateModelLimitsFromState seeds context = limit, so a
466
467
  // mismatched limit would re-derive a WRONG (too large) ceiling for models
467
468
  // where limit.input < limit.context.
468
469
  const limit =
package/lib/tui-data.d.ts CHANGED
@@ -54,7 +54,6 @@ export interface StateEntry {
54
54
 
55
55
  export declare const STATE_FILE: string
56
56
  export declare const POLL_MS: number
57
- export declare function isNodeModulesInstall(): boolean
58
57
  export declare function readSnapshot(): Record<string, StateEntry>
59
58
  export declare function collectFamily(
60
59
  snapshot: Record<string, StateEntry>,