opencode-context-indicator 1.6.0 → 1.7.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/CHANGELOG.md CHANGED
@@ -4,6 +4,120 @@ 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.7.1 — 2026-10-29
8
+
9
+ - **Fixed: the live log no longer prints `ctx=0` above real categories.** A hook
10
+ breakdown whose instance has no request counters (a mirror that only saw the
11
+ compaction, so `input`/`ctx` are still 0) used to be printed as-is: the header
12
+ contradicted the category lines below it. Such an entry now takes the same
13
+ path as a missing/fallback one and borrows the hook-measured `state.json`
14
+ snapshot whole (categories, counters, residual and a `snapshot=` stamp; a
15
+ snapshot without a timestamp is still marked `snapshot=unknown`). A counter is
16
+ knowledge only when it is positive: a borrowed entry whose own counters are 0,
17
+ or a ctx-only snapshot with no input, prints the unknown counter as `?`, and
18
+ the residual as `n/a (input unknown on this instance)` instead of a fabricated
19
+ `0` (`other = input - sum` needs an input). The same rule now applies to a
20
+ local hook entry that knows `ctx` but not `input`. T36 covers all four cases:
21
+ whole-entry borrow, no snapshot, zero-counter snapshot, ctx-only snapshot
22
+ (86 checks).
23
+ - **Fixed: the compaction reserve is 15 % again (1.7.0 regression).** 1.7.0
24
+ switched the denominator to a 10 % share on the strength of an unreleased
25
+ branch head. Every RELEASED host from 2.1.4 through 2.2.2 (2.1.4, 2.2.0,
26
+ 2.2.1, 2.2.2) still has `RESERVE_SHARE = 0.15` in
27
+ `packages/core/src/session/compaction.ts` (verified tag by tag), so on those
28
+ hosts 1.7.0 overstated the ceiling and the `compact at` line by ~5 % of the
29
+ window (~26 k tokens at 512 k) and therefore under-reported every `%`. The
30
+ plugin now mirrors `max(floor(window * 0.15), window >= 32_000 ? 16_000 : 0)`
31
+ again; the formula, the 16 k floor and the `2 * RESERVE_MIN` gate are
32
+ unchanged. Docs, the T16/T26 tables and the seed expectations are back on the
33
+ 15 % numbers.
34
+ - Dev: the isolated e2e (plugin load + state write) was re-run against the
35
+ **2.2.2** Desktop CLI (`opencode v2.2.2`); the Desktop 2.2.2 `app.asar` still
36
+ carries the `@opencode/plugin/gui` / `@opencode/plugin/rpc` bridge and the
37
+ `ComposerPill` / `Rpc.define` / `session.panel` symbols the experimental panel
38
+ uses, and `opencode-cli.exe` still exposes `opentui:runtime-module:` plus the
39
+ `node_modules` exclusion filter.
40
+
41
+ ## 1.7.0 — 2026-10-29
42
+
43
+ Desktop GUI release (experimental): the plugin now has a surface in the
44
+ OpenCode Desktop app, not just in the terminal.
45
+
46
+ - **Desktop side-panel tab.** A new `./gui-experimental` part contributes a
47
+ "Context" tab to the session side region: model, `ctx / usable (percent)`, the
48
+ eight categories as value + bar + share, the measurement time and source tag,
49
+ a stale-after-compaction marker, the compaction reserve, and one line per
50
+ subagent with its own window and percentage. Opened from the `+` menu of the
51
+ side region, the command palette, or the `/cx` slash command.
52
+ - **Composer pill.** A small always-on `NN% ctx` capsule above the composer,
53
+ amber from 65 % and red from 85 % of the usable window; clicking it opens the
54
+ tab. It hides itself while the server part is unavailable instead of showing a
55
+ stale number.
56
+ - **New public RPC contract (`./rpc`).** A Desktop window has no filesystem
57
+ access, so the panel cannot read `state.json`; the server part now registers
58
+ an RPC contract (id `context-indicator`, method `family`) that returns the root
59
+ session plus every descendant as flat rows, and the GUI polls it through
60
+ `ctx.client.rpc()`. The contract is exported so other plugins and clients can
61
+ read the same data.
62
+ - **One family walk instead of two.** `/context-breakdown` and the RPC now share
63
+ `collectFamilyReports()`: the descendant BFS, the shared wall-clock budget and
64
+ the per-session collection exist once. The RPC uses a 2 s budget (a poll runs
65
+ far more often than a command).
66
+ - **Experimental warning.** The host GUI API (`@opencode/plugin/gui`) is marked
67
+ experimental upstream and may change between releases; if it does, the GUI
68
+ part stops loading while the server part, the slash commands and the TUI
69
+ surfaces keep working. The Desktop part is not covered by CI (it needs a real
70
+ window); CI typechecks it, builds the bundle and validates the artifact.
71
+ - **Fixed: hook-measured categories are authoritative in `state.json`.** The
72
+ file is shared by every plugin instance, and the Desktop window's instance
73
+ never serves a model request, so its breakdown is always the client-context
74
+ fallback (no system prompt, no tool schemas). It used to overwrite the
75
+ hook-measured categories milliseconds after the hook wrote them, which is what
76
+ made the panel show `system 0` / `tool schemas 0` / a grossly inflated `other`
77
+ and could blank `user`/`assistant`. Entries now carry their `source`, and only
78
+ a hook breakdown may replace hook numbers (a fallback still fills a session
79
+ that no hook has ever measured). The same rule now applies to the command
80
+ report and to the live log, so all three surfaces agree.
81
+ - **Fixed: the stale-after-compaction mark survives a fallback write.** A
82
+ conversation rebuilt from the persisted messages is not a measurement of the
83
+ compacted context; only a fresh hook breakdown clears the mark now, so the
84
+ panel keeps saying "stale after compaction" instead of silently dropping it.
85
+ The compaction hook also marks the entry as "already handled" only when it
86
+ actually persisted a measurement, so a hook instance with a cold cache no
87
+ longer suppresses the fallback and makes a compaction leave no trace at all.
88
+ - **Fixed: compaction notes could vanish from the log.** `final summaries` lived
89
+ in per-process memory while the log file is shared, so the next snapshot
90
+ rewrite from another instance wiped a `COMPACTION`/`COMPACTED` note that had
91
+ just been appended. The writer now merges the section already on disk
92
+ (block-level, de-duplicated, bounded) under its own lock, and the live snapshot
93
+ prints a hook-measured `state.json` entry — categories, residual `other` and
94
+ its `ctx`/`input` counters, tagged `snapshot=<updatedAt>` — instead of this
95
+ instance's fallback-shaped view. Repeated compactions of one session are also
96
+ no longer collapsed into a single note when the host omits the event id.
97
+ - **Reserve share (claim in 1.7.0 was wrong; corrected in 1.7.1).** This
98
+ release switched the denominator to a 10 % share; no host in the verified
99
+ range 2.1.4 through 2.2.2 used it. `calculateCeiling()` in those tags keeps
100
+ `RESERVE_SHARE = 0.15`, so 1.7.0 overstated the ceiling (and the `compact at`
101
+ line) by ~5 % of the window (~26 k tokens at 512 k) on them and therefore
102
+ under-reported every `%`. The 1.6.0 note that the ceiling was unchanged in
103
+ 2.2.0 was right (the formula and the `2 * RESERVE_MIN` gate did stay); the
104
+ share line here was the wrong part.
105
+ - Dev: `lib/format.js` (pure `fmt`/`num`) extracted so the GUI bundle has no
106
+ `node:*` import; gate (a) now expands `dist/`, gate (e)/(f) scan `gui/` and the
107
+ GUI build config, new gate (g) validates the built CJS bundle (app-provided
108
+ modules kept external, no React/OpenTUI/bare-`opencode` import, no Node
109
+ built-in, no ESM or dynamic import left), new unit checks T30 (schema vs row
110
+ builder), T31 (RPC registration + handler), T32 (fallback vs hook priority in
111
+ the report), T33 (fallback vs hook priority in the shared state file, stale
112
+ mark), T34 (summary merge across instances), T35 (hook-family authority for
113
+ the compaction hook, the V1 transform hooks and legacy untagged entries, plus
114
+ the live-log substitution) and T25c/T25d (a cold-cache or lock-refused
115
+ compaction hook must not arm the trailing-event guard), 85 checks total; CI
116
+ builds the GUI and typechecks it before the gate. The isolated e2e (plugin
117
+ load + state write) passes on both the 2.2.0 CLI and the 2.2.1 Desktop CLI;
118
+ the dev dependency stays pinned to `@opencode/plugin@2.2.0` for CI
119
+ reproducibility.
120
+
7
121
  ## 1.6.0 — 2026-10-27
8
122
 
9
123
  Host alignment release: opencode 2.2.0 moved TUI plugins to OpenTUI 0.7.
@@ -20,10 +134,13 @@ Host alignment release: opencode 2.2.0 moved TUI plugins to OpenTUI 0.7.
20
134
  opencode 2.0.16+. The TUI module now ships compiled against OpenTUI 0.7, so
21
135
  the sidebar and the `/cx` panel require **opencode ≥ 2.2.0** and cannot load on
22
136
  an older host. Older-host installation was not re-verified for this release.
23
- - **Compaction ceiling unchanged.** `calculateCeiling()` in 2.2.0 is identical
137
+ - **Compaction ceiling unchanged (this note was right; 1.7.0 briefly wrong).**
138
+ `calculateCeiling()` in 2.2.0 is identical
24
139
  to 2.1.x, so the `0.15 / 16k / 32k` reserve formula and every `%` denominator
25
140
  are unaffected. (2.2.0 changed how a *compaction request* is sized - measured
26
- prompt instead of characters - not the trigger.)
141
+ prompt instead of characters - not the trigger. 1.7.0 later claimed the share
142
+ had dropped to 10 %; see 1.7.1 - no host in the verified range
143
+ 2.1.4-2.2.2 uses it.)
27
144
  - Dev: `drift-watch` is green again (host 2.2.0, OpenTUI 0.7.0); `tsc`,
28
145
  `prepublish` and the isolated e2e pass against the 2.2.0 CLI.
29
146
 
@@ -67,9 +184,11 @@ Fix-and-hygiene release after a full-project review. No new commands.
67
184
 
68
185
  ## 1.5.3 — 2026-10-26
69
186
 
70
- - **Compaction ceiling aligned with opencode 2.1.4 (#51271).** `lib/limits.js`
187
+ - **Compaction ceiling aligned with opencode 2.1.4.** `lib/limits.js`
71
188
  now mirrors `calculateCeiling()` exactly: the reserve is
72
- `max(floor(window * 0.15), window >= 2 * 16_000 ? 16_000 : 0)` (was 10%),
189
+ `max(floor(window * 0.15), window >= 2 * 16_000 ? 16_000 : 0)` (the plugin's
190
+ older builds used 10 %; no host in the verified range uses it - the share
191
+ stayed 0.15 there),
73
192
  and an override buffer is honored **only while it is smaller than the
74
193
  window** — a buffer configured for a larger model is ignored instead of
75
194
  leaving zero room. The `compact at` line and the `%` denominator now match the
package/README.md CHANGED
@@ -13,8 +13,10 @@ arguments, tool results (the outputs fed back into context) and the residual
13
13
  The package ships a **dual V1 + V2 plugin** (one entry works in both OpenCode
14
14
  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
- and a redacted export. The V2 path is **tested against opencode 2.2.0**; the
17
- V1 path against 1.18.29+.
16
+ and a redacted export, and an **experimental Desktop GUI part**
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
20
 
19
21
  > **V1 is feature-frozen.** It is kept working for compatibility (bug fixes
20
22
  > only); all new features land in the V2 path.
@@ -70,11 +72,13 @@ For every served request the plugin estimates how the context window is split:
70
72
  duplicate group), reported honestly as *"N redundant tool outputs ≈ X tokens"*
71
73
  — never as "the model wasted effort". Findings are per-session and **path-free
72
74
  in the persisted/exported form**.
73
- * The denominator is the **opencode 2.2.0 compaction ceiling** (unchanged since
74
- 2.1.4): `window =
75
+ * The denominator is the **opencode 2.x compaction ceiling**: `window =
75
76
  limit.input || limit.context`; `reserve = max(floor(window * 0.15), window >=
76
77
  2 * 16_000 ? 16_000 : 0)`; the ceiling is `window - reserve`. This mirrors
77
- `calculateCeiling()` in `packages/core/src/session/compaction.ts`. When opencode
78
+ `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
80
+ unreleased `v2` branch has a different share; the plugin follows the release,
81
+ not the branch). When opencode
78
82
  carries a `compaction.buffer` configured in its own settings, the plugin
79
83
  cannot read that value (it is not exposed to plugins), so the default
80
84
  reserve formula is used — set `OPENCODE_CONTEXT_INDICATOR_COMPACT_BUFFER`
@@ -372,14 +376,95 @@ an error toast.
372
376
 
373
377
  ---
374
378
 
379
+ ## Desktop panel (experimental)
380
+
381
+ OpenCode Desktop has no TUI, so the sidebar above cannot exist there. The
382
+ package therefore ships a **GUI part** (`./gui-experimental`) that renders the
383
+ same numbers as a Desktop **side-panel tab**, plus a **composer pill** with the
384
+ current percentage.
385
+
386
+ > **Experimental.** The host API it uses (`@opencode/plugin/gui`, opencode
387
+ > 2.2.0+) is explicitly marked experimental upstream and may change between
388
+ > releases; the export name `./gui-experimental` says so. If the host changes
389
+ > the API, the GUI part fails to load while the server part, the slash commands
390
+ > and the TUI surfaces keep working.
391
+
392
+ ### What it shows
393
+
394
+ **Context tab** (side region): the model, `ctx / usable (percent)`, the eight
395
+ categories as value + bar + share of the context, the measurement time, the
396
+ `source` tag (`live` / `snapshot` / `client-context` / `no-data`), a
397
+ `stale after compaction` marker, the compaction reserve, and one line per
398
+ subagent with its own window and percentage.
399
+
400
+ **Composer pill**: `38% ctx` above the composer, amber from 65 % and red from
401
+ 85 % of the usable window; clicking it opens the panel.
402
+
403
+ ### Opening it
404
+
405
+ | Way | Where |
406
+ | --- | --- |
407
+ | `+` menu of the side region | "Context" |
408
+ | Command palette / slash command `/cx` | "Open context panel" |
409
+ | Clicking the composer pill | opens the tab for the current session |
410
+
411
+ ### Where the numbers come from
412
+
413
+ A Desktop window has **no filesystem access**, so it cannot read `state.json`
414
+ the way the TUI does. The server part registers an **RPC contract**
415
+ (`lib/rpc.js`, id `context-indicator`, method `family`) that answers with the
416
+ root session plus every descendant, and the GUI calls it through
417
+ `ctx.client.rpc()`. The panel **polls** every 1.5 s (the pill every 5 s) instead
418
+ of subscribing to events: the contract stays one method, a poll costs no provider
419
+ call (the server answers from `state.json` plus its in-memory caches once the
420
+ session has been measured; a session this instance never observed live costs at
421
+ most one `session.get()` per instance, not per poll), and polling recovers by
422
+ itself when the desktop app's background server restarts.
423
+
424
+ The contract is published as the `./rpc` export, so other plugins and clients
425
+ can read the same data:
426
+
427
+ ```js
428
+ import { DesktopContextRpc } from "opencode-context-indicator/rpc"
429
+ const { rows } = await client.rpc(DesktopContextRpc).family({ sessionID })
430
+ ```
431
+
432
+ ### Install
433
+
434
+ The Desktop part is loaded from the same plugin entry, nothing extra to
435
+ configure:
436
+
437
+ ```jsonc
438
+ {
439
+ "$schema": "https://opencode.ai/config.json",
440
+ "plugins": ["opencode-context-indicator"]
441
+ }
442
+ ```
443
+
444
+ After installing, restart the app and check **Settings > Extensions**; the
445
+ plugin must be listed with its GUI part loaded. The `dist/gui-experimental.cjs`
446
+ bundle in the package is prebuilt, so no build step is needed on install.
447
+
448
+ ### Limits
449
+
450
+ * **Desktop only.** The TUI has its own sidebar and `/cx` panel, and loading a
451
+ GUI bundle there does nothing.
452
+ * **Polling, not push.** A change can take up to 1.5 s to appear (5 s for the
453
+ pill); the numbers themselves are the same snapshot the sidebar reads.
454
+ * **Not exercised in CI.** The GUI needs a real Desktop window: CI typechecks
455
+ `gui/*.tsx`, builds the bundle and validates the artifact (gate `g`), and the
456
+ panel and the pill are verified manually in the app before a release.
457
+
458
+ ---
459
+
375
460
  ## Surface matrix
376
461
 
377
- | Surface | Toast | TUI sidebar | TUI panel (/cx) | Breakdown log file | Slash commands |
378
- | --- | :---: | :---: | :---: | :---: | :---: |
379
- | OpenCode **1.x** — Desktop | ✅ | — | — | ✅ | — |
380
- | OpenCode **1.x** — TUI | ✅ | — | — | ✅ | — |
381
- | OpenCode **2.x** — TUI | — | ✅ | ✅ | ✅ | ✅ |
382
- | OpenCode **2.x** — Desktop | — | — | — | ✅ | ✅ |
462
+ | Surface | Toast | TUI sidebar | TUI panel (/cx) | Desktop panel | Desktop pill | Breakdown log file | Slash commands |
463
+ | --- | :---: | :---: | :---: | :---: | :---: | :---: | :---: |
464
+ | OpenCode **1.x** — Desktop | ✅ | — | — | — | — | ✅ | — |
465
+ | OpenCode **1.x** — TUI | ✅ | — | — | — | — | ✅ | — |
466
+ | OpenCode **2.x** — TUI | — | ✅ | ✅ | — | — | ✅ | ✅ |
467
+ | OpenCode **2.x** — Desktop | — | — | — | ✅ (exp) | ✅ (exp) | ✅ | ✅ |
383
468
 
384
469
  Notes — these reflect what the plugin API actually exposes today:
385
470
 
@@ -394,8 +479,13 @@ Notes — these reflect what the plugin API actually exposes today:
394
479
  * **V1 has no sidebar and no slash command.** The command/slot API (and the
395
480
  `./tui` entry) is a V2 feature; OpenCode 1.x has no equivalent, so V1 is
396
481
  toast + log only.
397
- * The Desktop app is TUI-less; its V2 surfaces are the log file and the
398
- `/context` / `/context-breakdown` / `/context-diff` slash commands.
482
+ * The Desktop app is TUI-less. Its V2 surfaces are the log file, the
483
+ `/context` / `/context-breakdown` / `/context-diff` slash commands, and the
484
+ **experimental** GUI part: a side-panel tab and a composer pill fed by the
485
+ plugin over RPC (see
486
+ [Desktop panel (experimental)](#desktop-panel-experimental)). That part needs
487
+ a host that loads `./gui-experimental` (opencode 2.2.0+); without it the log
488
+ file and the slash commands still work.
399
489
 
400
490
  ---
401
491
 
@@ -407,24 +497,22 @@ and `$TMPDIR` (usually `/tmp`) elsewhere:
407
497
  | File | Purpose |
408
498
  | --- | --- |
409
499
  | `context-breakdown.log` | Human-readable live snapshot + bounded final summaries |
410
- | `opencode-context-indicator-state.json` | Machine-readable snapshot consumed by the TUI sidebar and the `/cx` panel. Fields per entry: `sessionID`, `parentID`, `role`, `agent`, `model`, `providerID`, `ctx`, `input`, `usable` (compaction ceiling), `reserve` (tokens reserved for compaction), `limit` (context window), `reasoning`, `categories{user,assistant,reasoning,toolArgs,toolOut,system,toolSchemas,other}`, `toolOutByTool` (per-tool repeated-output estimate), `reclaim` (duplicate tool output, bounded), `cacheRead`/`cacheWrite` (provider-reported prompt-cache tokens), `compactedAt`/`compactStale`, `top` (top-5 heaviest single items, path-free), `previous` (previous distinct measurement for `/context-diff`), `updatedAt`. |
500
+ | `opencode-context-indicator-state.json` | Machine-readable snapshot consumed by the TUI sidebar and the `/cx` panel. Fields per entry: `sessionID`, `parentID`, `role`, `agent`, `model`, `providerID`, `ctx`, `input`, `usable` (compaction ceiling), `reserve` (tokens reserved for compaction), `limit` (context window), `reasoning`, `categories{user,assistant,reasoning,toolArgs,toolOut,system,toolSchemas,other}`, `source` (provenance of the categories: `context-hook` / `compaction-hook` (V2) / `transform` / `system-transform` (V1) / `client-context` / `snapshot` - any hook-family source wins, a fallback never overwrites it), `toolOutByTool` (per-tool repeated-output estimate), `reclaim` (duplicate tool output, bounded), `cacheRead`/`cacheWrite` (provider-reported prompt-cache tokens), `compactedAt`/`compactStale`, `top` (top-5 heaviest single items, path-free), `previous` (previous distinct measurement for `/context-diff`), `updatedAt`. |
411
501
  | `context-events.log` | Raw event tap — **only** when `DEBUG_EVENTS` is flipped to `true` in the source (off by default) |
412
502
 
413
503
  The whole `context-breakdown.log` is **rewritten atomically** (temp file +
414
504
  rename, like the state file): the live snapshot is always regenerated and the
415
505
  final summaries (on `session.idle`) plus compaction notes are re-rendered from
416
- an in-memory buffer (bounded, last 50 per log), so the file never grows and
417
- concurrent plugin instances cannot observe a half-written file. A summary-only
418
- write (idle, compaction, command payload) re-renders the last live snapshot, so
419
- adding a note never blanks that section. That snapshot may predate a compaction
420
- or a deleted session - the leading `[<timestamp>]` line is the freshness cue.
421
- Both parts are **process-local**: they
422
- survive every rewrite and failed command delivery within the lifetime of the
423
- running instance, but they are dropped when the plugin instance is re-created
424
- (opencode restarts, or about 24 h of idling). A different instance that rewrites
425
- the file keeps its own view of the summary buffer; cross-instance duplicate
426
- summaries are prevented separately by an atomic claim marker (see
427
- `lib/dedup.js`).
506
+ an in-memory buffer (bounded, last 50 blocks per instance), so the file never
507
+ grows and concurrent plugin instances cannot observe a half-written file. A
508
+ summary-only write (idle, compaction, command payload) re-renders the last live
509
+ snapshot, so adding a note never blanks that section. That snapshot may predate a
510
+ compaction or a deleted session - the leading `[<timestamp>]` line is the
511
+ freshness cue. Each instance keeps its own buffer, so every write **merges the
512
+ summary section already on disk** (de-duplicated by line, bounded to 400 lines):
513
+ a note appended by the instance that handled the event survives the next
514
+ snapshot rewrite from another instance. Cross-instance duplicate summaries are
515
+ prevented separately by an atomic claim marker (see `lib/dedup.js`).
428
516
 
429
517
  ---
430
518
 
@@ -548,10 +636,12 @@ the sidebar instead.
548
636
  gracefully (the breakdown falls back to throttled `session.messages` fetches
549
637
  and toasts keep working).
550
638
  * **OpenCode ≥ 2.2.0** for the V2 path with the TUI surfaces (built and verified
551
- against **2.2.0**). The server-only V2 path keeps working on 2.0.16+; the TUI
552
- module pins the OpenTUI generation it was compiled against, so a host on
553
- OpenTUI 0.6 (opencode ≤ 2.1.4) will not load the TUI surfaces - neither the
554
- sidebar nor the `/cx` panel - from a 1.6.x install.
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.
555
645
  * **TUI sidebar**: terminal TUI only, requires the OpenTUI rendering stack that
556
646
  ships with OpenCode. `@opentui/core` and `solid-js` are **optional** peer
557
647
  dependencies resolved by OpenCode at runtime; the main plugin installs and runs
@@ -565,6 +655,12 @@ the sidebar instead.
565
655
  upstream limitation, on the **npm** path the sidebar is **auto-disabled**
566
656
  (the plugin logs one hint and registers no slot) — use a `file://` install for
567
657
  the live sidebar, see [Live sidebar: npm vs file:// install](#live-sidebar-npm-vs-file-install).
658
+ * **Desktop GUI part** (optional, experimental): needs an OpenCode Desktop
659
+ release that loads the `./gui-experimental` export (opencode 2.2.0+, where the
660
+ GUI plugin API exists). The bundle is prebuilt and ships in the package, so it
661
+ adds **no** runtime dependency; `solid-js`, the GUI SDK and the client are
662
+ provided by the app itself. On hosts without that API the export is simply not
663
+ loaded and everything else keeps working.
568
664
 
569
665
  ---
570
666
 
@@ -630,15 +726,17 @@ caller.
630
726
  ### Running tests
631
727
 
632
728
  ```bash
633
- npm run test:prepublish # fast gate: tarball + tui.tsx + resolve + T1-T29 unit checks
729
+ 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
731
+ npm run test:types # tsc for the TUI entry AND the Desktop GUI part
634
732
  npm run test:e2e # full E2E: isolated plugin load + one "Say OK" round-trip
635
733
  npm run test:e2e:quick # E2E load-only, no LLM call
636
734
  npm run test:drift # advisory: warns on non-breaking host drift (also in CI)
637
735
  npm run test:drift:strict # release check: fails on ANY host drift
638
736
  ```
639
737
 
640
- `prepublishOnly` runs **both** gates automatically on `npm publish`
641
- (`node test/prepublish.mjs && node test/e2e-tui.mjs --full`) and is skipped with
738
+ `prepublishOnly` runs **all** of it automatically on `npm publish`
739
+ (`npm run build:gui && node test/prepublish.mjs && node test/e2e-tui.mjs --full`) and is skipped with
642
740
  `--ignore-scripts`. The whole chain takes roughly **1 minute** (fast gate ~20 s,
643
741
  isolated E2E ~10 s warm / up to ~90 s cold); the E2E is bounded by INIT 45 s +
644
742
  STEP 80 s, ~3 min total.
@@ -649,12 +747,13 @@ STEP 80 s, ~3 min total.
649
747
 
650
748
  | Step | What | Why |
651
749
  |---|---|---|
652
- | (a) tarball composition | `package.json` `files[]` expands to the base files plus every `lib/*` module (count asserted dynamically); `docs/` may contain images only | no test/node_modules/tsconfig in the tarball |
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 |
653
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 |
654
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 |
655
- | (d) unit harness T1-T29 | limit/agent/state fix assertions plus measureContributors, redacted-export, shared category-helper, sanitizer/unicode, prompt-cache, waste-detector, compaction-reserve, top-consumer, diff and subagent-rollup checks (75 total) | regressions in modelLimits, writeStateFile, hydrate, contributors, export, subagent lifetime |
656
- | (e) corporate-identifier scan | no forbidden identifiers in the published surface (`index.js`, `tui.tsx`, `lib/*`, `README.md`, `CHANGELOG.md`, `package.json`) or the test tree | compliance |
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 |
657
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
+ | (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 |
658
757
 
659
758
  **Isolated E2E — `test/e2e-tui.mjs`** (requires the OpenCode CLI)
660
759
 
@@ -696,11 +795,13 @@ model.
696
795
  and on a daily schedule (GitHub auto-parks scheduled workflows after 60 days of
697
796
  repo inactivity — a push or manual run re-enables them):
698
797
 
699
- 1. `npx --no-install tsc --noEmit` — typecheck of the TUI entry (`tui.tsx` + its
700
- `lib/*` imports);
701
- 2. `test/prepublish.mjs` — the full fast gate (a)–(f), including the standalone
702
- esbuild transpile check (pragmas, no React);
703
- 3. `test/drift-watch.mjs` — compares the OpenTUI pins and the
798
+ 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
800
+ Desktop GUI part (`gui/*.tsx`, Solid JSX);
801
+ 2. `npm run build:gui` - build the Desktop GUI bundle before the gate inspects it;
802
+ 3. `test/prepublish.mjs` - the full fast gate (a)-(g), including the standalone
803
+ esbuild transpile check (pragmas, no React) and the GUI bundle check;
804
+ 4. `test/drift-watch.mjs` - compares the OpenTUI pins and the
704
805
  `@opencode/plugin` types devDependency with the **latest released** opencode.
705
806
  It is **advisory by default**: patch drift prints a `::warning::` annotation
706
807
  and exits 0, so the daily pipeline does not go red on every host patch. Only
@@ -711,7 +812,10 @@ repo inactivity — a push or manual run re-enables them):
711
812
  `npm run test:drift:strict` to fail on any drift.
712
813
 
713
814
  The Node-pty E2E is not run in CI (requires the OpenCode binary) and is skipped
714
- gracefully when the CLI is absent (`[SKIP] opencode CLI not found`).
815
+ gracefully when the CLI is absent (`[SKIP] opencode CLI not found`). The Desktop
816
+ GUI part cannot be exercised headless at all: CI typechecks it, builds the
817
+ bundle and checks the artifact, and the panel/pill are verified manually in a
818
+ real Desktop window before a release.
715
819
 
716
820
  ---
717
821
 
@@ -0,0 +1 @@
1
+ Object.defineProperties(exports,{__esModule:{value:!0},[Symbol.toStringTag]:{value:`Module`}});let e=require("solid-js/web"),t=require("@opencode/plugin/gui"),n=require("solid-js"),r=require("@opencode/plugin/rpc");var i={sessionID:{type:`string`},parentID:{type:`string`},agent:{type:`string`},modelID:{type:`string`},providerID:{type:`string`},ctx:{type:`number`},input:{type:`number`},usable:{type:`number`},reserve:{type:`number`},user:{type:`number`},assistant:{type:`number`},reasoning:{type:`number`},toolArgs:{type:`number`},toolOut:{type:`number`},system:{type:`number`},toolSchemas:{type:`number`},other:{type:`number`},source:{type:`string`},updatedAt:{type:`string`},stale:{type:`boolean`}},a=Object.keys(i),o=r.Rpc.define({id:`context-indicator`,methods:{family:{input:{type:`object`,properties:{sessionID:{type:`string`}},required:[`sessionID`],additionalProperties:!1},output:{type:`object`,properties:{rows:{type:`array`,items:{type:`object`,properties:i,required:a,additionalProperties:!1}}},required:[`rows`],additionalProperties:!1}}},events:{}});function s(e){return Number.isFinite(e)?e>=1e3?`${(e/1e3).toFixed(1)}k`:String(Math.round(e)):`0`}var c=(0,e.template)(`<div>Server part unavailable: <!> (retrying)`),l=(0,e.template)(`<div>`),u=(0,e.template)(`<div>Loading context...`),d=(0,e.template)(`<div>No measurement yet. Send a prompt in this session and the numbers appear here.`),f=(0,e.template)(`<div><span></span><span>`),p=(0,e.template)(`<span>stale after compaction`),m=(0,e.template)(`<span>reserve `),h=(0,e.template)(`<div>Subagents (<!>)`),g=(0,e.template)(`<div><span></span><span></span><span><span style=display:block></span></span><span>`),_=1500,v=[[`system`,`System`],[`toolSchemas`,`Tool schemas`],[`user`,`User`],[`assistant`,`Assistant`],[`reasoning`,`Reasoning`],[`toolArgs`,`Tool args`],[`toolOut`,`Tool output`],[`other`,`Other`]];function y(e){if(!e)return`?`;try{let t=new Date(e);return Number.isNaN(t.getTime())?`?`:t.toLocaleTimeString()}catch{return`?`}}function b(e,t){return!t||t<=0?``:`${(e/t*100).toFixed(0)}%`}var x={panel:{display:`flex`,"flex-direction":`column`,gap:`10px`,padding:`12px`,"font-size":`12px`,"line-height":`1.45`},hint:{opacity:`0.65`,"font-size":`12px`},header:{display:`flex`,"align-items":`baseline`,"justify-content":`space-between`,gap:`8px`},model:{"font-weight":`600`,overflow:`hidden`,"text-overflow":`ellipsis`,"white-space":`nowrap`},total:{"font-variant-numeric":`tabular-nums`,"white-space":`nowrap`},section:{display:`flex`,"flex-direction":`column`,gap:`6px`},row:{display:`grid`,"grid-template-columns":`1fr auto 64px auto`,"align-items":`center`,gap:`6px`},label:{opacity:`0.8`,overflow:`hidden`,"text-overflow":`ellipsis`,"white-space":`nowrap`},value:{"font-variant-numeric":`tabular-nums`},track:{height:`4px`,"border-radius":`2px`,background:`currentColor`,opacity:`0.15`,overflow:`hidden`},fill:{height:`4px`,"border-radius":`2px`,background:`currentColor`,opacity:`0.9`},share:{opacity:`0.65`,"text-align":`right`,"font-variant-numeric":`tabular-nums`},meta:{display:`flex`,gap:`8px`,opacity:`0.6`,"font-size":`11px`},sub:{display:`flex`,"justify-content":`space-between`,gap:`8px`,"font-size":`11px`},subName:{overflow:`hidden`,"text-overflow":`ellipsis`,"white-space":`nowrap`,opacity:`0.85`},title:{"font-weight":`600`,opacity:`0.85`}};function S(r){let i=(0,t.usePlugin)(),[a,S]=(0,n.createSignal)([]),[C,w]=(0,n.createSignal)(``),[T,E]=(0,n.createSignal)(!1);(0,n.createEffect)(()=>{let e=r.session.id,t=!1,a=!1;E(!1),S([]),w(``);let s=async()=>{if(!(a||t)){t=!0;try{let t=await i.client.rpc(o).family({sessionID:e});if(a)return;S(Array.isArray(t?.rows)?t.rows:[]),w(``)}catch(e){if(a)return;w(e instanceof Error?e.message:String(e))}finally{t=!1,a||E(!0)}}};s();let c=setInterval(()=>void s(),_);(0,n.onCleanup)(()=>{a=!0,clearInterval(c)})});let D=()=>a()[0],O=()=>a().slice(1),k=e=>{let t=D()?.ctx||0;return t<=0||e<=0?`0%`:`${Math.min(100,Math.max(2,Math.round(e/t*100)))}%`};return(()=>{var t=l();return(0,e.insert)(t,(0,e.createComponent)(n.Show,{get when(){return T()},get fallback(){return(()=>{var t=u();return(0,e.effect)(n=>(0,e.style)(t,x.hint,n)),t})()},get children(){return[(0,e.createComponent)(n.Show,{get when(){return C()},get children(){var t=c(),n=t.firstChild.nextSibling;return n.nextSibling,(0,e.insert)(t,C,n),(0,e.effect)(n=>(0,e.style)(t,x.hint,n)),t}}),(0,e.createComponent)(n.Show,{get when(){return D()},get fallback(){return(0,e.createComponent)(n.Show,{get when(){return!C()},get children(){var t=d();return(0,e.effect)(n=>(0,e.style)(t,x.hint,n)),t}})},children:t=>[(()=>{var n=f(),r=n.firstChild,i=r.nextSibling;return(0,e.insert)(r,()=>t().modelID||`unknown model`),(0,e.insert)(i,()=>s(t().ctx),null),(0,e.insert)(i,(()=>{var n=(0,e.memo)(()=>t().usable>0);return()=>n()?` / ${s(t().usable)} (${b(t().ctx,t().usable)})`:``})(),null),(0,e.effect)(a=>{var o=x.header,s=x.model,c=t().modelID,l=x.total;return a.e=(0,e.style)(n,o,a.e),a.t=(0,e.style)(r,s,a.t),c!==a.a&&(0,e.setAttribute)(r,`title`,a.a=c),a.o=(0,e.style)(i,l,a.o),a},{e:void 0,t:void 0,a:void 0,o:void 0}),n})(),(()=>{var r=l();return(0,e.insert)(r,(0,e.createComponent)(n.For,{each:v,children:n=>(()=>{var r=g(),i=r.firstChild,a=i.nextSibling,o=a.nextSibling,c=o.firstChild,l=o.nextSibling;return(0,e.insert)(i,()=>n[1]),(0,e.insert)(a,()=>s(t()[n[0]])),(0,e.insert)(l,()=>b(t()[n[0]],t().ctx)),(0,e.effect)(s=>{var u=x.row,d=x.label,f=x.value,p=x.track,m={...x.fill,width:k(t()[n[0]])},h=x.share;return s.e=(0,e.style)(r,u,s.e),s.t=(0,e.style)(i,d,s.t),s.a=(0,e.style)(a,f,s.a),s.o=(0,e.style)(o,p,s.o),s.i=(0,e.style)(c,m,s.i),s.n=(0,e.style)(l,h,s.n),s},{e:void 0,t:void 0,a:void 0,o:void 0,i:void 0,n:void 0}),r})()})),(0,e.effect)(t=>(0,e.style)(r,x.section,t)),r})(),(()=>{var r=f(),i=r.firstChild,a=i.nextSibling;return(0,e.insert)(i,()=>y(t().updatedAt)),(0,e.insert)(a,()=>t().source||`none`),(0,e.insert)(r,(0,e.createComponent)(n.Show,{get when(){return t().stale},get children(){return p()}}),null),(0,e.insert)(r,(0,e.createComponent)(n.Show,{get when(){return t().reserve>0},get children(){var n=m();return n.firstChild,(0,e.insert)(n,()=>s(t().reserve),null),n}}),null),(0,e.effect)(t=>(0,e.style)(r,x.meta,t)),r})(),(0,e.createComponent)(n.Show,{get when(){return O().length>0},get children(){return[(()=>{var t=h(),n=t.firstChild.nextSibling;return n.nextSibling,(0,e.insert)(t,()=>O().length,n),(0,e.effect)(n=>(0,e.style)(t,x.title,n)),t})(),(()=>{var t=l();return(0,e.insert)(t,(0,e.createComponent)(n.For,{get each(){return O()},children:t=>(()=>{var n=f(),r=n.firstChild,i=r.nextSibling;return(0,e.insert)(r,()=>t.agent||`sub`),(0,e.insert)(i,()=>s(t.ctx),null),(0,e.insert)(i,(()=>{var n=(0,e.memo)(()=>t.usable>0);return()=>n()?` (${b(t.ctx,t.usable)})`:``})(),null),(0,e.effect)(a=>{var o=x.sub,s=x.subName,c=t.modelID,l=x.value;return a.e=(0,e.style)(n,o,a.e),a.t=(0,e.style)(r,s,a.t),c!==a.a&&(0,e.setAttribute)(r,`title`,a.a=c),a.o=(0,e.style)(i,l,a.o),a},{e:void 0,t:void 0,a:void 0,o:void 0}),n})()})),(0,e.effect)(n=>(0,e.style)(t,x.section,n)),t})()]}})]})]}})),(0,e.effect)(n=>(0,e.style)(t,x.panel,n)),t})()}var C=`context`,w=`window-analytics`,T=`Context`,E=5e3,D=65,O=85,k={id:C,title:T},A=t.Plugin.define({setup(r){let i=`${r.id}:${C}`,a=e=>{let t=e??r.sessions.current();t&&r.layout.open(i,t,{tab:`select`})};r.add(t.Panel,()=>({id:C,region:`side`,wide:!0,list:e=>e.open.includes(C)?[k]:[],render:t=>(0,e.createComponent)(S,{get session(){return t.session}}),mobile:{title:T,order:1,kind:`tab`,icon:w}})),r.add(t.MenuItem,()=>({id:C,menu:`session.panel`,title:T,icon:w,description:`Context-window usage`,run:()=>a()})),r.add(t.Command,()=>({id:`open`,title:`Open context panel`,description:`Context-window usage for this session`,keywords:`context tokens window panel`,section:`session`,slash:{name:`cx`},run:()=>a()}));let[s,c]=(0,n.createSignal)(void 0),l=async()=>{let e=r.sessions.current();if(!e)c(void 0);else try{let t=(await r.client.rpc(o).family({sessionID:e.id}))?.rows?.[0];if(!t||t.usable<=0){c(void 0);return}let n=t.ctx/t.usable*100;c({sessionID:e.id,label:`${n.toFixed(0)}% ctx`,tone:n>=O?`danger`:n>=D?`warning`:void 0})}catch{c(void 0)}};l();let u=setInterval(()=>void l(),E);(0,n.onCleanup)(()=>clearInterval(u)),r.add(t.ComposerPill,()=>({id:`usage`,show:({session:e})=>{let t=s();if(t&&t.sessionID===e.id)return{label:t.label,icon:w,tone:t.tone,title:`Context window usage`,run:()=>a(e)}}}))}});exports.default=A;
package/index.js CHANGED
@@ -127,10 +127,9 @@
127
127
  *
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
- * model's compaction ceiling (opencode calculateCeiling, unchanged since
131
- * 2.1.4): window =
132
- * limit.input || limit.context, reserve = max(15% of window, 16k when
133
- * window >= 32k), usable = window - reserve. Percentages are NOT
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
134
133
  * clamped at 100% - exceeding the ceiling must stay visible.
135
134
  * - Category estimates use a unicode-aware heuristic (see estimateTokens):
136
135
  * Cyrillic ~2.5 chars/token, CJK ~1.5, latin/ASCII ~4. These are ESTIMATES,
@@ -177,7 +176,8 @@ import {
177
176
  modelLimits,
178
177
  reserveFor,
179
178
  } from "./lib/limits.js";
180
- import { lastKnownCacheRead, lastKnownCacheWrite, lastKnownCtxCache, lastKnownInputCache, lastKnownReasoningCache, sessionAgentCache, sessionParentCache } from "./lib/cache.js";
179
+ import { breakdownCache, lastKnownCacheRead, lastKnownCacheWrite, lastKnownCtxCache, lastKnownInputCache, lastKnownReasoningCache, sessionAgentCache, sessionParentCache } from "./lib/cache.js";
180
+ import { registerDesktopRpc, toDesktopRow } from "./lib/desktop.js";
181
181
  import {
182
182
  logErr,
183
183
  readStateSnapshot,
@@ -229,9 +229,12 @@ export {
229
229
  lastKnownCacheRead,
230
230
  lastKnownCacheWrite,
231
231
  lastKnownCtxCache,
232
+ breakdownCache,
232
233
  lastKnownInputCache,
233
234
  lastKnownReasoningCache,
234
235
  handleV2Event,
236
+ registerDesktopRpc,
237
+ toDesktopRow,
235
238
  };
236
239
 
237
240
  // ---------------------------------------------------------------------------
@@ -337,6 +340,13 @@ export default {
337
340
  logErr("v2 command transform registration failed", err);
338
341
  }
339
342
 
343
+ // Desktop GUI data channel (experimental GUI plugins, opencode 2.2.0+).
344
+ // The GUI part ships as ./gui-experimental and calls this contract through
345
+ // ctx.client.rpc(); without it the panel has no data, so the registration is
346
+ // non-fatal and the TUI / slash commands keep working either way.
347
+ const desktopRpc = await registerDesktopRpc(ctx);
348
+ if (desktopRpc) registrations.push(desktopRpc);
349
+
340
350
  const controller = new AbortController();
341
351
  void (async () => {
342
352
  try {