opencode-context-indicator 1.5.4 → 1.7.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,110 @@ 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.0 — 2026-10-29
8
+
9
+ Desktop GUI release (experimental): the plugin now has a surface in the
10
+ OpenCode Desktop app, not just in the terminal.
11
+
12
+ - **Desktop side-panel tab.** A new `./gui-experimental` part contributes a
13
+ "Context" tab to the session side region: model, `ctx / usable (percent)`, the
14
+ eight categories as value + bar + share, the measurement time and source tag,
15
+ a stale-after-compaction marker, the compaction reserve, and one line per
16
+ subagent with its own window and percentage. Opened from the `+` menu of the
17
+ side region, the command palette, or the `/cx` slash command.
18
+ - **Composer pill.** A small always-on `NN% ctx` capsule above the composer,
19
+ amber from 65 % and red from 85 % of the usable window; clicking it opens the
20
+ tab. It hides itself while the server part is unavailable instead of showing a
21
+ stale number.
22
+ - **New public RPC contract (`./rpc`).** A Desktop window has no filesystem
23
+ access, so the panel cannot read `state.json`; the server part now registers
24
+ an RPC contract (id `context-indicator`, method `family`) that returns the root
25
+ session plus every descendant as flat rows, and the GUI polls it through
26
+ `ctx.client.rpc()`. The contract is exported so other plugins and clients can
27
+ read the same data.
28
+ - **One family walk instead of two.** `/context-breakdown` and the RPC now share
29
+ `collectFamilyReports()`: the descendant BFS, the shared wall-clock budget and
30
+ the per-session collection exist once. The RPC uses a 2 s budget (a poll runs
31
+ far more often than a command).
32
+ - **Experimental warning.** The host GUI API (`@opencode/plugin/gui`) is marked
33
+ experimental upstream and may change between releases; if it does, the GUI
34
+ part stops loading while the server part, the slash commands and the TUI
35
+ surfaces keep working. The Desktop part is not covered by CI (it needs a real
36
+ window); CI typechecks it, builds the bundle and validates the artifact.
37
+ - **Fixed: hook-measured categories are authoritative in `state.json`.** The
38
+ file is shared by every plugin instance, and the Desktop window's instance
39
+ never serves a model request, so its breakdown is always the client-context
40
+ fallback (no system prompt, no tool schemas). It used to overwrite the
41
+ hook-measured categories milliseconds after the hook wrote them, which is what
42
+ made the panel show `system 0` / `tool schemas 0` / a grossly inflated `other`
43
+ and could blank `user`/`assistant`. Entries now carry their `source`, and only
44
+ a hook breakdown may replace hook numbers (a fallback still fills a session
45
+ that no hook has ever measured). The same rule now applies to the command
46
+ report and to the live log, so all three surfaces agree.
47
+ - **Fixed: the stale-after-compaction mark survives a fallback write.** A
48
+ conversation rebuilt from the persisted messages is not a measurement of the
49
+ compacted context; only a fresh hook breakdown clears the mark now, so the
50
+ panel keeps saying "stale after compaction" instead of silently dropping it.
51
+ The compaction hook also marks the entry as "already handled" only when it
52
+ actually persisted a measurement, so a hook instance with a cold cache no
53
+ longer suppresses the fallback and makes a compaction leave no trace at all.
54
+ - **Fixed: compaction notes could vanish from the log.** `final summaries` lived
55
+ in per-process memory while the log file is shared, so the next snapshot
56
+ rewrite from another instance wiped a `COMPACTION`/`COMPACTED` note that had
57
+ just been appended. The writer now merges the section already on disk
58
+ (block-level, de-duplicated, bounded) under its own lock, and the live snapshot
59
+ prints a hook-measured `state.json` entry — categories, residual `other` and
60
+ its `ctx`/`input` counters, tagged `snapshot=<updatedAt>` — instead of this
61
+ instance's fallback-shaped view. Repeated compactions of one session are also
62
+ no longer collapsed into a single note when the host omits the event id.
63
+ - **Reserve share corrected for opencode 2.2.x.** `calculateCeiling()` keeps the
64
+ same shape but the reserve share dropped from 15 % to 10 % in 2.2.0 (the
65
+ "fit output limits to the context window" change, PR #51271), so the plugin
66
+ now mirrors `max(floor(window * 0.10), window >= 32_000 ? 16_000 : 0)`. Every
67
+ `%` and the `compact at` line are now computed against the host's actual
68
+ trigger; on a large window the old formula understated the ceiling by ~5 % of
69
+ the window (~26 k tokens at 512 k). The 1.6.0 note that the ceiling was
70
+ unchanged in 2.2.0 was wrong — the `2 * RESERVE_MIN` gate did stay.
71
+ - Dev: `lib/format.js` (pure `fmt`/`num`) extracted so the GUI bundle has no
72
+ `node:*` import; gate (a) now expands `dist/`, gate (e)/(f) scan `gui/` and the
73
+ GUI build config, new gate (g) validates the built CJS bundle (app-provided
74
+ modules kept external, no React/OpenTUI/bare-`opencode` import, no Node
75
+ built-in, no ESM or dynamic import left), new unit checks T30 (schema vs row
76
+ builder), T31 (RPC registration + handler), T32 (fallback vs hook priority in
77
+ the report), T33 (fallback vs hook priority in the shared state file, stale
78
+ mark), T34 (summary merge across instances), T35 (hook-family authority for
79
+ the compaction hook, the V1 transform hooks and legacy untagged entries, plus
80
+ the live-log substitution) and T25c/T25d (a cold-cache or lock-refused
81
+ compaction hook must not arm the trailing-event guard), 85 checks total; CI
82
+ builds the GUI and typechecks it before the gate. The isolated e2e (plugin
83
+ load + state write) passes on both the 2.2.0 CLI and the 2.2.1 Desktop CLI;
84
+ the dev dependency stays pinned to `@opencode/plugin@2.2.0` for CI
85
+ reproducibility.
86
+
87
+ ## 1.6.0 — 2026-10-27
88
+
89
+ Host alignment release: opencode 2.2.0 moved TUI plugins to OpenTUI 0.7.
90
+
91
+ - **OpenTUI 0.7.0.** `@opencode/plugin` 2.2.0 declares `@opentui/core` and
92
+ `@opentui/solid` **0.7.0** as peers, and the host release notes state that a
93
+ plugin left on OpenTUI 0.6 no longer installs against it (upstream wording;
94
+ this repo's gates only ever run against the current host). The pinned direct
95
+ dependency is now `@opentui/solid@0.7.0` (dev pins `@opentui/core@0.7.0` and
96
+ `@opencode/plugin@2.2.0`). None of the 0.7 breaking changes apply to this
97
+ plugin: it renders intrinsic OpenTUI elements and registers slots, and defines
98
+ no custom renderables, native integrations or `nativeSceneWorkBudget` options.
99
+ - **Host support.** The server-only V2 path is unchanged and still targets
100
+ opencode 2.0.16+. The TUI module now ships compiled against OpenTUI 0.7, so
101
+ the sidebar and the `/cx` panel require **opencode ≥ 2.2.0** and cannot load on
102
+ an older host. Older-host installation was not re-verified for this release.
103
+ - **Compaction ceiling unchanged (superseded — see 1.7.0).**
104
+ `calculateCeiling()` in 2.2.0 is identical
105
+ to 2.1.x, so the `0.15 / 16k / 32k` reserve formula and every `%` denominator
106
+ are unaffected. (2.2.0 changed how a *compaction request* is sized - measured
107
+ prompt instead of characters - not the trigger.)
108
+ - Dev: `drift-watch` is green again (host 2.2.0, OpenTUI 0.7.0); `tsc`,
109
+ `prepublish` and the isolated e2e pass against the 2.2.0 CLI.
110
+
7
111
  ## 1.5.4 — 2026-10-27
8
112
 
9
113
  Fix-and-hygiene release after a full-project review. No new commands.
@@ -44,9 +148,10 @@ Fix-and-hygiene release after a full-project review. No new commands.
44
148
 
45
149
  ## 1.5.3 — 2026-10-26
46
150
 
47
- - **Compaction ceiling aligned with opencode 2.1.4 (#51271).** `lib/limits.js`
151
+ - **Compaction ceiling aligned with opencode 2.1.4.** `lib/limits.js`
48
152
  now mirrors `calculateCeiling()` exactly: the reserve is
49
- `max(floor(window * 0.15), window >= 2 * 16_000 ? 16_000 : 0)` (was 10%),
153
+ `max(floor(window * 0.15), window >= 2 * 16_000 ? 16_000 : 0)` (was 10%;
154
+ the share moved again in 2.2.0 — see 1.7.0),
50
155
  and an override buffer is honored **only while it is smaller than the
51
156
  window** — a buffer configured for a larger model is ignored instead of
52
157
  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.1.4**; 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 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,10 +72,11 @@ 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.1.4 compaction ceiling**: `window =
74
- limit.input || limit.context`; `reserve = max(floor(window * 0.15), window >=
75
+ * The denominator is the **opencode 2.2.x compaction ceiling**: `window =
76
+ limit.input || limit.context`; `reserve = max(floor(window * 0.10), window >=
75
77
  2 * 16_000 ? 16_000 : 0)`; the ceiling is `window - reserve`. This mirrors
76
- `calculateCeiling()` in `packages/core/src/session/compaction.ts`. When opencode
78
+ `calculateCeiling()` in `packages/core/src/session/compaction.ts` (the reserve
79
+ share is 10% since 2.2.0 / PR #51271; it was 15% up to 2.1.x). When opencode
77
80
  carries a `compaction.buffer` configured in its own settings, the plugin
78
81
  cannot read that value (it is not exposed to plugins), so the default
79
82
  reserve formula is used — set `OPENCODE_CONTEXT_INDICATOR_COMPACT_BUFFER`
@@ -371,14 +374,95 @@ an error toast.
371
374
 
372
375
  ---
373
376
 
377
+ ## Desktop panel (experimental)
378
+
379
+ OpenCode Desktop has no TUI, so the sidebar above cannot exist there. The
380
+ package therefore ships a **GUI part** (`./gui-experimental`) that renders the
381
+ same numbers as a Desktop **side-panel tab**, plus a **composer pill** with the
382
+ current percentage.
383
+
384
+ > **Experimental.** The host API it uses (`@opencode/plugin/gui`, opencode
385
+ > 2.2.0+) is explicitly marked experimental upstream and may change between
386
+ > releases; the export name `./gui-experimental` says so. If the host changes
387
+ > the API, the GUI part fails to load while the server part, the slash commands
388
+ > and the TUI surfaces keep working.
389
+
390
+ ### What it shows
391
+
392
+ **Context tab** (side region): the model, `ctx / usable (percent)`, the eight
393
+ categories as value + bar + share of the context, the measurement time, the
394
+ `source` tag (`live` / `snapshot` / `client-context` / `no-data`), a
395
+ `stale after compaction` marker, the compaction reserve, and one line per
396
+ subagent with its own window and percentage.
397
+
398
+ **Composer pill**: `38% ctx` above the composer, amber from 65 % and red from
399
+ 85 % of the usable window; clicking it opens the panel.
400
+
401
+ ### Opening it
402
+
403
+ | Way | Where |
404
+ | --- | --- |
405
+ | `+` menu of the side region | "Context" |
406
+ | Command palette / slash command `/cx` | "Open context panel" |
407
+ | Clicking the composer pill | opens the tab for the current session |
408
+
409
+ ### Where the numbers come from
410
+
411
+ A Desktop window has **no filesystem access**, so it cannot read `state.json`
412
+ the way the TUI does. The server part registers an **RPC contract**
413
+ (`lib/rpc.js`, id `context-indicator`, method `family`) that answers with the
414
+ root session plus every descendant, and the GUI calls it through
415
+ `ctx.client.rpc()`. The panel **polls** every 1.5 s (the pill every 5 s) instead
416
+ of subscribing to events: the contract stays one method, a poll costs no provider
417
+ call (the server answers from `state.json` plus its in-memory caches once the
418
+ session has been measured; a session this instance never observed live costs at
419
+ most one `session.get()` per instance, not per poll), and polling recovers by
420
+ itself when the desktop app's background server restarts.
421
+
422
+ The contract is published as the `./rpc` export, so other plugins and clients
423
+ can read the same data:
424
+
425
+ ```js
426
+ import { DesktopContextRpc } from "opencode-context-indicator/rpc"
427
+ const { rows } = await client.rpc(DesktopContextRpc).family({ sessionID })
428
+ ```
429
+
430
+ ### Install
431
+
432
+ The Desktop part is loaded from the same plugin entry, nothing extra to
433
+ configure:
434
+
435
+ ```jsonc
436
+ {
437
+ "$schema": "https://opencode.ai/config.json",
438
+ "plugins": ["opencode-context-indicator"]
439
+ }
440
+ ```
441
+
442
+ After installing, restart the app and check **Settings > Extensions**; the
443
+ plugin must be listed with its GUI part loaded. The `dist/gui-experimental.cjs`
444
+ bundle in the package is prebuilt, so no build step is needed on install.
445
+
446
+ ### Limits
447
+
448
+ * **Desktop only.** The TUI has its own sidebar and `/cx` panel, and loading a
449
+ GUI bundle there does nothing.
450
+ * **Polling, not push.** A change can take up to 1.5 s to appear (5 s for the
451
+ pill); the numbers themselves are the same snapshot the sidebar reads.
452
+ * **Not exercised in CI.** The GUI needs a real Desktop window: CI typechecks
453
+ `gui/*.tsx`, builds the bundle and validates the artifact (gate `g`), and the
454
+ panel and the pill are verified manually in the app before a release.
455
+
456
+ ---
457
+
374
458
  ## Surface matrix
375
459
 
376
- | Surface | Toast | TUI sidebar | TUI panel (/cx) | Breakdown log file | Slash commands |
377
- | --- | :---: | :---: | :---: | :---: | :---: |
378
- | OpenCode **1.x** — Desktop | ✅ | — | — | ✅ | — |
379
- | OpenCode **1.x** — TUI | ✅ | — | — | ✅ | — |
380
- | OpenCode **2.x** — TUI | — | ✅ | ✅ | ✅ | ✅ |
381
- | OpenCode **2.x** — Desktop | — | — | — | ✅ | ✅ |
460
+ | Surface | Toast | TUI sidebar | TUI panel (/cx) | Desktop panel | Desktop pill | Breakdown log file | Slash commands |
461
+ | --- | :---: | :---: | :---: | :---: | :---: | :---: | :---: |
462
+ | OpenCode **1.x** — Desktop | ✅ | — | — | — | — | ✅ | — |
463
+ | OpenCode **1.x** — TUI | ✅ | — | — | — | — | ✅ | — |
464
+ | OpenCode **2.x** — TUI | — | ✅ | ✅ | — | — | ✅ | ✅ |
465
+ | OpenCode **2.x** — Desktop | — | — | — | ✅ (exp) | ✅ (exp) | ✅ | ✅ |
382
466
 
383
467
  Notes — these reflect what the plugin API actually exposes today:
384
468
 
@@ -393,8 +477,13 @@ Notes — these reflect what the plugin API actually exposes today:
393
477
  * **V1 has no sidebar and no slash command.** The command/slot API (and the
394
478
  `./tui` entry) is a V2 feature; OpenCode 1.x has no equivalent, so V1 is
395
479
  toast + log only.
396
- * The Desktop app is TUI-less; its V2 surfaces are the log file and the
397
- `/context` / `/context-breakdown` / `/context-diff` slash commands.
480
+ * The Desktop app is TUI-less. Its V2 surfaces are the log file, the
481
+ `/context` / `/context-breakdown` / `/context-diff` slash commands, and the
482
+ **experimental** GUI part: a side-panel tab and a composer pill fed by the
483
+ plugin over RPC (see
484
+ [Desktop panel (experimental)](#desktop-panel-experimental)). That part needs
485
+ a host that loads `./gui-experimental` (opencode 2.2.0+); without it the log
486
+ file and the slash commands still work.
398
487
 
399
488
  ---
400
489
 
@@ -406,24 +495,22 @@ and `$TMPDIR` (usually `/tmp`) elsewhere:
406
495
  | File | Purpose |
407
496
  | --- | --- |
408
497
  | `context-breakdown.log` | Human-readable live snapshot + bounded final summaries |
409
- | `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`. |
498
+ | `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`. |
410
499
  | `context-events.log` | Raw event tap — **only** when `DEBUG_EVENTS` is flipped to `true` in the source (off by default) |
411
500
 
412
501
  The whole `context-breakdown.log` is **rewritten atomically** (temp file +
413
502
  rename, like the state file): the live snapshot is always regenerated and the
414
503
  final summaries (on `session.idle`) plus compaction notes are re-rendered from
415
- an in-memory buffer (bounded, last 50 per log), so the file never grows and
416
- concurrent plugin instances cannot observe a half-written file. A summary-only
417
- write (idle, compaction, command payload) re-renders the last live snapshot, so
418
- adding a note never blanks that section. That snapshot may predate a compaction
419
- or a deleted session - the leading `[<timestamp>]` line is the freshness cue.
420
- Both parts are **process-local**: they
421
- survive every rewrite and failed command delivery within the lifetime of the
422
- running instance, but they are dropped when the plugin instance is re-created
423
- (opencode restarts, or about 24 h of idling). A different instance that rewrites
424
- the file keeps its own view of the summary buffer; cross-instance duplicate
425
- summaries are prevented separately by an atomic claim marker (see
426
- `lib/dedup.js`).
504
+ an in-memory buffer (bounded, last 50 blocks per instance), so the file never
505
+ grows and concurrent plugin instances cannot observe a half-written file. A
506
+ summary-only write (idle, compaction, command payload) re-renders the last live
507
+ snapshot, so adding a note never blanks that section. That snapshot may predate a
508
+ compaction or a deleted session - the leading `[<timestamp>]` line is the
509
+ freshness cue. Each instance keeps its own buffer, so every write **merges the
510
+ summary section already on disk** (de-duplicated by line, bounded to 400 lines):
511
+ a note appended by the instance that handled the event survives the next
512
+ snapshot rewrite from another instance. Cross-instance duplicate summaries are
513
+ prevented separately by an atomic claim marker (see `lib/dedup.js`).
427
514
 
428
515
  ---
429
516
 
@@ -546,14 +633,18 @@ the sidebar instead.
546
633
  indicator degrades
547
634
  gracefully (the breakdown falls back to throttled `session.messages` fetches
548
635
  and toasts keep working).
549
- * **OpenCode ≥ 2.0.16** for the V2 path (built and verified against
550
- **2.1.4**).
636
+ * **OpenCode ≥ 2.2.0** for the V2 path with the TUI surfaces (built and verified
637
+ against **2.2.0**; the isolated load + state-write e2e also passes on the
638
+ **2.2.1** Desktop CLI). The server-only V2 path keeps working on
639
+ 2.0.16+; the TUI module pins the OpenTUI generation it was compiled against,
640
+ so a host on OpenTUI 0.6 (opencode ≤ 2.1.4) will not load the TUI surfaces -
641
+ neither the sidebar nor the `/cx` panel - from a 1.6.x install.
551
642
  * **TUI sidebar**: terminal TUI only, requires the OpenTUI rendering stack that
552
643
  ships with OpenCode. `@opentui/core` and `solid-js` are **optional** peer
553
644
  dependencies resolved by OpenCode at runtime; the main plugin installs and runs
554
645
  fine without them (the sidebar simply is not available). `@opentui/solid` is
555
- instead shipped as a **pinned direct dependency** (exact `0.6.8`, matching
556
- the OpenTUI stack OpenCode 2.1.4 ships): OpenCode's
646
+ instead shipped as a **pinned direct dependency** (exact `0.7.0`, matching
647
+ the OpenTUI stack OpenCode 2.2.0 ships): OpenCode's
557
648
  TUI loader does not expose a host instance of it, so for npm-installed plugins
558
649
  the JSX pragma would otherwise fail to resolve `@opentui/solid/jsx-runtime`
559
650
  (upstream: opencode issue #33884 — `node_modules` plugins are excluded from the
@@ -561,6 +652,12 @@ the sidebar instead.
561
652
  upstream limitation, on the **npm** path the sidebar is **auto-disabled**
562
653
  (the plugin logs one hint and registers no slot) — use a `file://` install for
563
654
  the live sidebar, see [Live sidebar: npm vs file:// install](#live-sidebar-npm-vs-file-install).
655
+ * **Desktop GUI part** (optional, experimental): needs an OpenCode Desktop
656
+ release that loads the `./gui-experimental` export (opencode 2.2.0+, where the
657
+ GUI plugin API exists). The bundle is prebuilt and ships in the package, so it
658
+ adds **no** runtime dependency; `solid-js`, the GUI SDK and the client are
659
+ provided by the app itself. On hosts without that API the export is simply not
660
+ loaded and everything else keeps working.
564
661
 
565
662
  ---
566
663
 
@@ -626,15 +723,17 @@ caller.
626
723
  ### Running tests
627
724
 
628
725
  ```bash
629
- npm run test:prepublish # fast gate: tarball + tui.tsx + resolve + T1-T29 unit checks
726
+ npm run build:gui # build dist/gui-experimental.cjs (required by the gate)
727
+ npm run test:prepublish # fast gate: tarball + tui.tsx + resolve + T1-T35 unit checks + GUI bundle
728
+ npm run test:types # tsc for the TUI entry AND the Desktop GUI part
630
729
  npm run test:e2e # full E2E: isolated plugin load + one "Say OK" round-trip
631
730
  npm run test:e2e:quick # E2E load-only, no LLM call
632
731
  npm run test:drift # advisory: warns on non-breaking host drift (also in CI)
633
732
  npm run test:drift:strict # release check: fails on ANY host drift
634
733
  ```
635
734
 
636
- `prepublishOnly` runs **both** gates automatically on `npm publish`
637
- (`node test/prepublish.mjs && node test/e2e-tui.mjs --full`) and is skipped with
735
+ `prepublishOnly` runs **all** of it automatically on `npm publish`
736
+ (`npm run build:gui && node test/prepublish.mjs && node test/e2e-tui.mjs --full`) and is skipped with
638
737
  `--ignore-scripts`. The whole chain takes roughly **1 minute** (fast gate ~20 s,
639
738
  isolated E2E ~10 s warm / up to ~90 s cold); the E2E is bounded by INIT 45 s +
640
739
  STEP 80 s, ~3 min total.
@@ -645,12 +744,13 @@ STEP 80 s, ~3 min total.
645
744
 
646
745
  | Step | What | Why |
647
746
  |---|---|---|
648
- | (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 |
747
+ | (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 |
649
748
  | (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 |
650
749
  | (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 |
651
- | (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 |
652
- | (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 |
750
+ | (d) unit harness T1-T35 | 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 (85 total; T30 schema-vs-builder drift, T31 registration + handler, T32-T35 fallback-vs-hook category priority across the report, the shared state file and the live log, 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 |
751
+ | (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 |
653
752
  | (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 |
753
+ | (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 |
654
754
 
655
755
  **Isolated E2E — `test/e2e-tui.mjs`** (requires the OpenCode CLI)
656
756
 
@@ -692,11 +792,13 @@ model.
692
792
  and on a daily schedule (GitHub auto-parks scheduled workflows after 60 days of
693
793
  repo inactivity — a push or manual run re-enables them):
694
794
 
695
- 1. `npx --no-install tsc --noEmit` — typecheck of the TUI entry (`tui.tsx` + its
696
- `lib/*` imports);
697
- 2. `test/prepublish.mjs` — the full fast gate (a)–(f), including the standalone
698
- esbuild transpile check (pragmas, no React);
699
- 3. `test/drift-watch.mjs` — compares the OpenTUI pins and the
795
+ 1. `npx --no-install tsc --noEmit` and `npx --no-install tsc -p tsconfig.gui.json --noEmit`
796
+ - typecheck of the TUI entry (`tui.tsx` + its `lib/*` imports) and of the
797
+ Desktop GUI part (`gui/*.tsx`, Solid JSX);
798
+ 2. `npm run build:gui` - build the Desktop GUI bundle before the gate inspects it;
799
+ 3. `test/prepublish.mjs` - the full fast gate (a)-(g), including the standalone
800
+ esbuild transpile check (pragmas, no React) and the GUI bundle check;
801
+ 4. `test/drift-watch.mjs` - compares the OpenTUI pins and the
700
802
  `@opencode/plugin` types devDependency with the **latest released** opencode.
701
803
  It is **advisory by default**: patch drift prints a `::warning::` annotation
702
804
  and exits 0, so the daily pipeline does not go red on every host patch. Only
@@ -707,7 +809,10 @@ repo inactivity — a push or manual run re-enables them):
707
809
  `npm run test:drift:strict` to fail on any drift.
708
810
 
709
811
  The Node-pty E2E is not run in CI (requires the OpenCode binary) and is skipped
710
- gracefully when the CLI is absent (`[SKIP] opencode CLI not found`).
812
+ gracefully when the CLI is absent (`[SKIP] opencode CLI not found`). The Desktop
813
+ GUI part cannot be exercised headless at all: CI typechecks it, builds the
814
+ bundle and checks the artifact, and the panel/pill are verified manually in a
815
+ real Desktop window before a release.
711
816
 
712
817
  ---
713
818
 
@@ -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,9 +127,10 @@
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 2.1.4 calculateCeiling): window =
131
- * limit.input || limit.context, reserve = max(15% of window, 16k when
132
- * window >= 32k), usable = window - reserve. Percentages are NOT
130
+ * model's compaction ceiling (opencode calculateCeiling, core 2.2.x):
131
+ * window = limit.input || limit.context, reserve = max(10% of window, 16k
132
+ * when window >= 32k), usable = window - reserve (the share was 15% up to
133
+ * 2.1.x). Percentages are NOT
133
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,
@@ -176,7 +177,8 @@ import {
176
177
  modelLimits,
177
178
  reserveFor,
178
179
  } from "./lib/limits.js";
179
- import { lastKnownCacheRead, lastKnownCacheWrite, lastKnownCtxCache, lastKnownInputCache, lastKnownReasoningCache, sessionAgentCache, sessionParentCache } from "./lib/cache.js";
180
+ import { breakdownCache, lastKnownCacheRead, lastKnownCacheWrite, lastKnownCtxCache, lastKnownInputCache, lastKnownReasoningCache, sessionAgentCache, sessionParentCache } from "./lib/cache.js";
181
+ import { registerDesktopRpc, toDesktopRow } from "./lib/desktop.js";
180
182
  import {
181
183
  logErr,
182
184
  readStateSnapshot,
@@ -228,9 +230,12 @@ export {
228
230
  lastKnownCacheRead,
229
231
  lastKnownCacheWrite,
230
232
  lastKnownCtxCache,
233
+ breakdownCache,
231
234
  lastKnownInputCache,
232
235
  lastKnownReasoningCache,
233
236
  handleV2Event,
237
+ registerDesktopRpc,
238
+ toDesktopRow,
234
239
  };
235
240
 
236
241
  // ---------------------------------------------------------------------------
@@ -336,6 +341,13 @@ export default {
336
341
  logErr("v2 command transform registration failed", err);
337
342
  }
338
343
 
344
+ // Desktop GUI data channel (experimental GUI plugins, opencode 2.2.0+).
345
+ // The GUI part ships as ./gui-experimental and calls this contract through
346
+ // ctx.client.rpc(); without it the panel has no data, so the registration is
347
+ // non-fatal and the TUI / slash commands keep working either way.
348
+ const desktopRpc = await registerDesktopRpc(ctx);
349
+ if (desktopRpc) registrations.push(desktopRpc);
350
+
339
351
  const controller = new AbortController();
340
352
  void (async () => {
341
353
  try {