opencode-context-indicator 1.0.0 → 1.1.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.
Files changed (4) hide show
  1. package/README.md +157 -15
  2. package/index.js +565 -49
  3. package/package.json +7 -1
  4. package/tui.tsx +14 -2
package/README.md CHANGED
@@ -9,6 +9,9 @@ arguments and the residual "other" bucket.
9
9
 
10
10
  The package ships a **dual V1 + V2 plugin** (one entry works in both OpenCode
11
11
  generations) plus an optional TUI sidebar module exposed as the `./tui` entry.
12
+ The V2 path is **tested against opencode v2.0.19**; the V1 path against 1.18.29+.
13
+
14
+ ![TUI sidebar — per-category context breakdown](docs/sidebar.png)
12
15
 
13
16
  ---
14
17
 
@@ -70,26 +73,163 @@ other 5.6k 12%
70
73
  updated 12:30:01
71
74
  ```
72
75
 
76
+ ### Slash commands (V2)
77
+
78
+ On OpenCode 2.x (Desktop **and** terminal TUI) the plugin registers two slash
79
+ commands:
80
+
81
+ | Command | Scope | Delivery | Agent turn? |
82
+ | --- | --- | --- | :---: |
83
+ | `/context` | **this session only** (no subagents); cache + `state.json` only | `resume:true` — the model echoes the 3-line summary into the transcript | yes |
84
+ | `/context-breakdown` | this session **+ all subagent descendants** | `resume:true` — the model echoes the table into the transcript | yes |
85
+
86
+ #### `/context` — quick summary (main session only)
87
+
88
+ A 3-line summary of the current session, built purely from the in-memory cache
89
+ and the `state.json` snapshot: **no subagent traversal and no live RPC**, so
90
+ collection is instant (delivery itself still runs an agent turn — see below).
91
+ The synthetic notice chip shows a self-contained one-liner (label =
92
+ `description`):
93
+
94
+ ```
95
+ Context: 41.5k (25%) · your-model · 12:36
96
+ ```
97
+
98
+ The payload is delivered with `resume:true` and a verbatim instruction prefix,
99
+ so the model's answer (a normal assistant message) carries the detail:
100
+
101
+ ```
102
+ Reproduce the markdown table below exactly, verbatim, with no changes and no commentary:
103
+
104
+ **Context** — your-model · 41.5k / 168k (25%)
105
+ usr 2.3k · asst 285 · rsn 9.6k · tool 2.1k · sys 8.5k · schm 3.8k · oth 14.9k
106
+ updated 12:36:16 (snap)
107
+ ```
108
+
109
+ The Desktop renders the model's answer in full; the notice chip only shows the
110
+ one-liner description.
111
+
112
+ #### `/context-breakdown` — full table (main + subagents)
113
+
114
+ The full markdown table for the current session **and every subagent (child)
115
+ session**:
116
+
117
+ ```
118
+ ### Context breakdown
119
+
120
+ | role | model | ctx | usr | asst | rsn | tool | sys | schm | oth | updated (src) |
121
+ | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
122
+ | main | gpt-4o | 45.2k (38%) | 4.2k | 3.1k | 0.8k | 0.3k | 2.1k | 1.4k | 5.6k | 12:30:01 live |
123
+ | sub:build | gpt-4o | 8.1k (7%) | 1.2k | 0.9k | 0.3k | 0.1k | 2.1k | 1.4k | 2.1k | 12:29:58 snap |
124
+
125
+ _1 subagent session(s). Tokens are estimates (unicode heuristic). usr=user,
126
+ asst=assistant, rsn=reasoning, tool=tool args, sys=system prompt, schm=tool
127
+ schemas, oth=residual input. src: live=context hook, snap=state.json,
128
+ fallback=session.context (no system/schemas → n/a), no-data=categories
129
+ unavailable._
130
+ ```
131
+
132
+ It is delivered with `resume:true` **and** a verbatim instruction prefix, so the
133
+ agent's answer (a normal assistant message) carries the table and the Desktop
134
+ renders it in full:
135
+
136
+ ```
137
+ Reproduce the markdown table below exactly, verbatim, with no changes and no commentary:
138
+
139
+ ### Context breakdown
140
+ | … |
141
+ ```
142
+
143
+ #### Delivery semantics (`resume`)
144
+
145
+ `ctx.session.synthetic({ sessionID, text, description, resume })` durably *admits*
146
+ the message to the session **inbox**; the `resume` flag only gates whether the
147
+ session is woken (opencode `Session.synthetic`:
148
+ `if (resume !== false) wake(session)`):
149
+
150
+ | `resume` | Effect |
151
+ | --- | --- |
152
+ | `false` | durable inbox notice; **no** LLM request; session context unchanged. A synthetic message is rendered as a compact **Notice chip** whose label is `description ?? text` (the client row-builder maps `synthetic` → Notice) — so the chip shows the description, **not** the table. |
153
+ | `true` | the same inbox item **plus** a wake → a real agent turn runs. The model sees the payload as input and its answer is a **normal assistant message**, rendered in full. |
154
+
155
+ Both commands now use `resume:true` and a verbatim instruction prefix. A
156
+ real agent turn runs and the model renders the payload as a normal assistant
157
+ message — the only way the content is visible in the Desktop (the chip only
158
+ shows `description`). `CONTEXT_BREAKDOWN_RESUME` controls `/context-breakdown`
159
+ back to a plain notice if needed.
160
+
161
+ > **E2E note (Desktop 2.0.19, local model `llama3.2:latest`, `build` agent).**
162
+ > `resume:true` behaved as designed: the synthetic item was drained into
163
+ > `/message` and a turn ran; the assistant message contained the table verbatim
164
+ > (the trailing `_…_` note line was dropped by the model). Because `resume:true`
165
+ > runs a **real agent turn with tools**, the model may additionally emit tool
166
+ > calls — in the E2E it attempted one stray `edit` (harmless: the path did not
167
+ > exist). Treat `/context-breakdown` as *"ask the agent to show the table"*, not
168
+ > as a pure UI action.
169
+
170
+ * **Both commands run an agent turn.** `/context` collects from the in-memory
171
+ cache and `state.json` only (no RPC, no subagent traversal) and
172
+ `/context-breakdown` collects the full tree, but both deliver with
173
+ `resume:true` — so each issues a real LLM request and the session context
174
+ grows by that turn.
175
+ * Subagents are discovered by walking the **parentID chain persisted in
176
+ `state.json`** (breadth-first from the current session, every level — subagents
177
+ of subagents included). `state.json` is the only source: each entry is keyed by
178
+ its own `sessionID` and carries the `parentID` recorded when it was written, so
179
+ the chain is root-consistent and entries of *other* sessions are unreachable.
180
+ A `seen` set guards against cycles and double-listing. Subagents that already
181
+ finished stay listed while their `state.json` entry exists.
182
+ * **Phantom sessions are hidden.** A session registered via `session.created`
183
+ but with no served step (no tokens in any category, `ctx = 0`, `input = 0`) —
184
+ e.g. an E2E/test registration or an agent ping without a turn — is skipped.
185
+ Traversal still descends through such a node so real grandchildren behind it
186
+ are found. The footer's `N subagent session(s)` counts only non-phantom rows.
187
+ * Collection is **parallel** (root + all descendants) under a single wall-clock
188
+ deadline of 3.5 s, with each per-session live lookup capped at 3 s — so the
189
+ command takes roughly the per-session cap (not the sum), rather than N × 3 s
190
+ for N subagents. Per session the data is resolved as: live breakdown cache →
191
+ `state.json` snapshot → a bounded `session.context()` fallback. A session left
192
+ with no category data (no cache/snapshot and the fallback failed or ran out of
193
+ budget) is tagged `no-data`; `ctx` / `model` / `updatedAt` still come from
194
+ `state.json` / the live lookup, while its category cells stay `n/a`.
195
+ * Both commands append their payload to `context-breakdown.log` (final-summaries
196
+ section), so it stays recoverable even if delivery fails.
197
+ * If `ctx.session.synthetic` throws, the command logs an actionable error and the
198
+ payload is preserved in the log — there is no other delivery channel in this
199
+ runtime (no `noReply`/`prompt` option exists).
200
+ * **The model may echo the instruction line.** Because delivery is an agent turn,
201
+ the model can reproduce the verbatim instruction prefix
202
+ (`Reproduce the markdown table below exactly…`) in its answer instead of
203
+ echoing only the payload.
204
+ * **Deleted sessions can linger.** `session.deleted` clears the in-memory caches
205
+ but not the `state.json` entry, so a deleted session that had already served a
206
+ step (has tokens) is not filtered as a phantom and stays listed until its entry
207
+ is evicted by the `state.json` LRU cap (`MAX_TRACKED_SESSIONS`) — it may
208
+ momentarily appear in the table.
209
+
73
210
  ---
74
211
 
75
212
  ## Surface matrix
76
213
 
77
- | Surface | Toast | TUI sidebar | Breakdown log file |
78
- | --- | :---: | :---: | :---: |
79
- | OpenCode **1.x** — Desktop | ✅ | — | ✅ |
80
- | OpenCode **1.x** — TUI | ✅ | — | ✅ |
81
- | OpenCode **2.x** — TUI | — | ✅ | ✅ |
82
- | OpenCode **2.x** — Desktop | — | — | ✅ |
214
+ | Surface | Toast | TUI sidebar | Breakdown log file | Slash commands |
215
+ | --- | :---: | :---: | :---: | :---: |
216
+ | OpenCode **1.x** — Desktop | ✅ | — | ✅ | — |
217
+ | OpenCode **1.x** — TUI | ✅ | — | ✅ | — |
218
+ | OpenCode **2.x** — TUI | — | ✅ | ✅ | ✅ |
219
+ | OpenCode **2.x** — Desktop | — | — | ✅ | ✅ |
83
220
 
84
221
  Notes — these reflect what the plugin API actually exposes today:
85
222
 
86
223
  * **V2 has no toast channel on the server side.** The V2 plugin API
87
224
  (`ctx.*`) exposes no TUI/toast method (opencode issue `#49380`); the V2 path
88
- therefore delivers everything through the log file and the TUI sidebar. The
89
- sidebar is a TUI-process slot and does not exist in the Desktop app.
90
- * **V1 has no sidebar.** The CLI-plugin / slot API (and the `./tui` entry) is a
91
- V2 feature; OpenCode 1.x has no equivalent, so V1 is toast + log only.
92
- * The Desktop app is TUI-less; its V2 surface is the log file only.
225
+ therefore delivers everything through the log file, the TUI sidebar and the
226
+ `/context` / `/context-breakdown` slash commands. The sidebar is a TUI-process
227
+ slot and does not exist in the Desktop app; the slash commands work in **both**.
228
+ * **V1 has no sidebar and no slash command.** The command/slot API (and the
229
+ `./tui` entry) is a V2 feature; OpenCode 1.x has no equivalent, so V1 is
230
+ toast + log only.
231
+ * The Desktop app is TUI-less; its V2 surfaces are the log file and the
232
+ `/context` / `/context-breakdown` slash commands.
93
233
 
94
234
  ---
95
235
 
@@ -180,7 +320,7 @@ recommended path for the sidebar).
180
320
  gracefully (the breakdown falls back to throttled `session.messages` fetches
181
321
  and toasts keep working).
182
322
  * **OpenCode ≥ 2.0.16** for the V2 path (built and verified against
183
- **2.0.18**).
323
+ **2.0.19**).
184
324
  * **TUI sidebar**: terminal TUI only, requires the OpenTUI rendering stack that
185
325
  ships with OpenCode (`@opentui/core`, `@opentui/solid`, `solid-js`). These are
186
326
  declared as **optional** peer dependencies, so the main plugin installs and
@@ -204,9 +344,11 @@ The package default export is a dual plugin:
204
344
  * `setup(ctx)` — V2 entry (OpenCode ≥ 2.0.16). Registers a `session.hook("context", …)`
205
345
  to capture the final messages / system prompt / tool schemas right before each
206
346
  model request, subscribes to the event stream (`session.created`,
207
- `session.step.ended`, `session.idle`, `session.compacted`, failure events) and
208
- records limits from the model registry. Read-only: the context is never
209
- mutated.
347
+ `session.step.ended`, `session.idle`, `session.compacted`, failure events),
348
+ records limits from the model registry and registers the
349
+ `/context` + `/context-breakdown` slash commands (via
350
+ `ctx.command.transform`). Read-only: the context is never mutated (the
351
+ commands wake the session via `resume:true`, which is what renders the table).
210
352
  * `server({ client })` — V1 entry (OpenCode ≥ 1.18.29). Classic event handler +
211
353
  experimental transform hooks + toasts.
212
354
 
package/index.js CHANGED
@@ -66,6 +66,44 @@
66
66
  * session.step.ended (keyed by the child's own sessionID). This is
67
67
  * event-driven and does not need the removed API.
68
68
  *
69
+ * V2 slash commands (added 1.1.0, verified against the live Desktop 2.0.19
70
+ * plugin runtime). Two commands:
71
+ * - /context — a 3-line summary of THIS session only (model, tokens, %,
72
+ * updated + category line). Main only: NO subagent traversal and NO live
73
+ * RPC (cache + state.json only), so it is instant. Delivered as a
74
+ * resume:false notice; the chip label (`description`) is self-contained:
75
+ * "Context: 41.5k (25%) · Auto · 12:36".
76
+ * - /context-breakdown — the full markdown table (main + all subagent
77
+ * descendants), collected in parallel under one budget. Delivered with
78
+ * resume:true and a verbatim instruction prefix, so the agent echoes the
79
+ * table into the transcript as a normal message (rendered in full by the
80
+ * Desktop); resume:false remains a one-constant fallback.
81
+ * - ctx.command.transform(editor => editor.add({ name, description,
82
+ * execute })) — registers a slash command; `execute` receives the
83
+ * invocation { sessionID, prompt:{text,files,agents,skills}, delivery }.
84
+ * - ctx.session.synthetic({ sessionID, text, description?, resume? }) —
85
+ * durably ADMITS a synthetic message to the session INBOX. `resume` only
86
+ * gates whether the session is woken (Session.synthetic:
87
+ * `if (resume !== false) wake(session)`): resume:true starts an agent turn
88
+ * (grows session context), resume:false leaves a durable notice without
89
+ * running the agent loop. A synthetic message is always shown as a compact
90
+ * Notice chip whose label is `description ?? text` (client row-builder:
91
+ * `synthetic` -> Notice), which is exactly why /context-breakdown uses
92
+ * resume:true: the model answer, not the chip, carries the full table.
93
+ * - session list = the parentID chain persisted in state.json (BFS from the
94
+ * root; see collectDescendantSessionIDs). state.json is the ONLY source:
95
+ * every entry is keyed by its own sessionID and carries the parentID
96
+ * recorded at write time, so the chain is root-consistent — entries of
97
+ * OTHER roots are unreachable. Finished / deleted subagents stay listed
98
+ * while their state.json entry exists. Module-scope maps persist across
99
+ * requests for the lifetime of a plugin INSTANCE; PluginSupervisor
100
+ * (re)activates the plugin on start / config or file change / every 24h —
101
+ * NOT per request — so an in-memory index would be cold for sessions
102
+ * created before the current instance loaded, which is exactly why
103
+ * discovery uses the durable state.json instead.
104
+ * - All logic stays in module scope: the handlers need the in-memory maps.
105
+ * The V1 server() path is intentionally untouched (commands are V2-only).
106
+ *
69
107
  * ---------------------------------------------------------------------------
70
108
  * TUI STATE BRIDGE (optional sidebar)
71
109
  * ---------------------------------------------------------------------------
@@ -221,6 +259,10 @@ const sessionParentCache = new Map();
221
259
  // from session.step.ended, used to fold child-session totals into the parent.
222
260
  const sessionTotals = new Map();
223
261
 
262
+ // V2: sessionID -> agent name (session.created data.agent). Used only as the
263
+ // role label ("sub:<agent>") in the /context-breakdown table.
264
+ const sessionAgentCache = new Map();
265
+
224
266
  // Gating for client.config.providers() (V1) fetches:
225
267
  // providersLoadedAt — last SUCCESSFUL fetch (full PROVIDERS_TTL_MS cache);
226
268
  // providersFailAt — last FAILED/timed-out fetch (short fail TTL, so a
@@ -427,6 +469,7 @@ function writeStateFile(
427
469
  all = {}; // missing / empty / corrupt -> start fresh
428
470
  }
429
471
  if (!all || typeof all !== "object" || Array.isArray(all)) all = {};
472
+ const prevEntry = all[sessionID] || null;
430
473
  const pc = all[sessionID]?.categories || null;
431
474
 
432
475
  const catUser = bd ? bd.userChars || 0 : pc?.user ?? 0;
@@ -450,8 +493,18 @@ function writeStateFile(
450
493
  (catSystem || 0) +
451
494
  (catToolSchemas || 0);
452
495
  const other = Math.max(0, (inputTokens || 0) - sumEstimates);
496
+ // Parent link for the /context-breakdown command. Prefer the live cache;
497
+ // fall back to the previously persisted value (backward compatible with
498
+ // snapshots written before parentID existed -> null). role mirrors it.
499
+ const parentID =
500
+ sessionParentCache.has(sessionID)
501
+ ? sessionParentCache.get(sessionID) ?? null
502
+ : prevEntry?.parentID ?? null;
453
503
  const entry = {
454
504
  sessionID,
505
+ parentID,
506
+ role: parentID ? "sub" : "main",
507
+ agent: sessionAgentCache.get(sessionID) || null,
455
508
  model: modelID || "unknown",
456
509
  providerID: providerID || "",
457
510
  ctx: total,
@@ -606,6 +659,16 @@ function getUsableContext(providerID, modelID) {
606
659
  return { usable, limit: context, maxOutput, reserved };
607
660
  }
608
661
 
662
+ // Store a model limit record under `<providerID>:<modelID>`. Written by
663
+ // `ensureModelLimits` (V2: ctx.model.list()) and `ensureProviderLimits` (V1:
664
+ // client.config.providers()), both carrying the effective merged config.
665
+ function setModelLimit(providerID, modelID, rec) {
666
+ const key = modelKey(providerID, modelID);
667
+ if (!key) return false;
668
+ modelLimits.set(key, rec);
669
+ return true;
670
+ }
671
+
609
672
  // ---------------------------------------------------------------------------
610
673
  // V1 breakdown estimation from messages (Array<{info, parts}>).
611
674
  // ---------------------------------------------------------------------------
@@ -736,7 +799,7 @@ async function ensureProviderLimits(client) {
736
799
  typeof lim.output === "number" && lim.output > 0 ? lim.output : null;
737
800
  if (context == null && input == null) continue;
738
801
  const prevRec = modelLimits.get(key);
739
- modelLimits.set(key, {
802
+ setModelLimit(providerID, modelID, {
740
803
  context,
741
804
  input,
742
805
  output,
@@ -1765,6 +1828,22 @@ const V2_CONTEXT_FETCH_THROTTLE_MS = 5000; // min gap between fallback fetches
1765
1828
  // event handlers): cap each at 3s so a slow/hung client cannot pin resources.
1766
1829
  const V2_FALLBACK_TIMEOUT_MS = 3000;
1767
1830
 
1831
+ // Cumulative wall-clock budget for one /context-breakdown collection (root +
1832
+ // every descendant, fetched in PARALLEL). Each per-session live lookup is
1833
+ // additionally capped at V2_FALLBACK_TIMEOUT_MS, so the command costs about the
1834
+ // per-session cap (not the sum) instead of N × 3s for N subagents. A session
1835
+ // with no cached/snapshot categories whose bounded session.context lookup fails
1836
+ // or never gets any budget is tagged "no-data": ctx/model/updatedAt still come
1837
+ // from state.json / live, but the category cells stay n/a.
1838
+ const COMMAND_COLLECT_BUDGET_MS = 3500;
1839
+
1840
+ // `resume` for the full /context-breakdown command. resume:true admits the
1841
+ // payload to the transcript AND wakes the session, so the agent echoes the table
1842
+ // as a normal message that the Desktop renders in full (the notice chip only
1843
+ // shows `description`). Set to false to fall back to a plain inbox notice if a
1844
+ // given runtime renders the model answer worse than the notice.
1845
+ const CONTEXT_BREAKDOWN_RESUME = true;
1846
+
1768
1847
  // Fill breakdownCache for a session when the context hook has not already done
1769
1848
  // so. Throttled, fault-tolerant, READ-ONLY. Hook data always wins.
1770
1849
  async function ensureV2Breakdown(ctx, sessionID) {
@@ -1871,7 +1950,7 @@ async function ensureModelLimits(ctx) {
1871
1950
  typeof lim.output === "number" && lim.output > 0 ? lim.output : null;
1872
1951
  if (context == null && input == null) continue;
1873
1952
  const prevRec = modelLimits.get(key);
1874
- modelLimits.set(key, {
1953
+ setModelLimit(providerID, modelID, {
1875
1954
  context,
1876
1955
  input,
1877
1956
  output,
@@ -2036,8 +2115,16 @@ async function handleV2Event(ctx, event) {
2036
2115
  const data = event.data || {};
2037
2116
  const sid = data.sessionID;
2038
2117
  if (!sid) return;
2039
- sessionParentCache.set(sid, data.parentID || null);
2118
+ const parentID = data.parentID || null;
2119
+ // sessionParentCache is the durable parent link used when WRITING the
2120
+ // state.json entry (writeStateFile); discovery itself reads the persisted
2121
+ // parentID chain — see collectDescendantSessionIDs.
2122
+ sessionParentCache.set(sid, parentID);
2040
2123
  pruneMap(sessionParentCache, MAX_TRACKED_SESSIONS);
2124
+ if (typeof data.agent === "string" && data.agent) {
2125
+ sessionAgentCache.set(sid, data.agent);
2126
+ pruneMap(sessionAgentCache, MAX_TRACKED_SESSIONS);
2127
+ }
2041
2128
  if (data.model && typeof data.model === "object") {
2042
2129
  if (typeof data.model.id === "string") lastKnownModelCache.set(sid, data.model.id);
2043
2130
  if (typeof data.model.providerID === "string") {
@@ -2071,6 +2158,10 @@ async function handleV2Event(ctx, event) {
2071
2158
  if (event.type === "session.deleted") {
2072
2159
  const sid = event.data?.sessionID;
2073
2160
  if (!sid) return;
2161
+ // Discovery reads the persisted parentID chain, so a deleted session drops
2162
+ // out of the table as soon as its state.json entry is gone (or, while the
2163
+ // stale entry lingers, via the phantom filter — a deleted session has no
2164
+ // step). No dedicated in-memory index to clean up.
2074
2165
  clearSessionTracking(sid);
2075
2166
  return;
2076
2167
  }
@@ -2259,6 +2350,423 @@ async function handleV2Event(ctx, event) {
2259
2350
  }
2260
2351
  }
2261
2352
 
2353
+ // ---------------------------------------------------------------------------
2354
+ // V2 slash commands: /context and /context-breakdown
2355
+ // ---------------------------------------------------------------------------
2356
+ // /context prints a 3-line summary of the CURRENT session only (cache +
2357
+ // state.json, no subagents, no RPC); /context-breakdown prints a compact
2358
+ // markdown table for the CURRENT session plus EVERY descendant (subagent)
2359
+ // session. Both deliver via ctx.session.synthetic({sessionID, text, ...}):
2360
+ // resume:true -> wake / agent turn (the model renders the table);
2361
+ // resume:false -> a durable notice, no LLM call. Works in the Desktop v2 app
2362
+ // (which has no TUI slots, issue #49380) and in the TUI. Registered only on
2363
+ // the V2 path.
2364
+ //
2365
+ // Session resolution cascade (per task): in-memory breakdownCache -> the
2366
+ // state.json snapshot -> a throttled/3s ctx.session.context() fallback. The
2367
+ // fallback cannot see the assembled system prompt or the tool schemas, so those
2368
+ // two cells stay "n/a" and the row's source is labelled honestly.
2369
+
2370
+ // Read the whole state.json snapshot ({ sessionID: entry }) or {} on any error.
2371
+ function readStateSnapshot() {
2372
+ try {
2373
+ const all = JSON.parse(readFileSync(STATE_FILE, "utf8"));
2374
+ return all && typeof all === "object" && !Array.isArray(all) ? all : {};
2375
+ } catch {
2376
+ return {};
2377
+ }
2378
+ }
2379
+
2380
+ // True when a state.json entry carries no measured tokens at all: a session
2381
+ // registered (session.created) but with no served step. `writeStateFile` always
2382
+ // persists `categories` as an object, so the emptiness test must SUM those
2383
+ // fields — a missing/`undefined` categories must not be the only trigger.
2384
+ // Note: `system` / `toolSchemas` may legitimately be null (hook-only), so they
2385
+ // are coerced with `|| 0`.
2386
+ function isEmptyStateEntry(entry) {
2387
+ const c = entry?.categories || {};
2388
+ const sum =
2389
+ (c.user || 0) +
2390
+ (c.assistant || 0) +
2391
+ (c.reasoning || 0) +
2392
+ (c.toolArgs || 0) +
2393
+ (c.system || 0) +
2394
+ (c.toolSchemas || 0) +
2395
+ (c.other || 0);
2396
+ return (
2397
+ sum === 0 &&
2398
+ (!entry?.ctx || entry.ctx === 0) &&
2399
+ (!entry?.input || entry.input === 0)
2400
+ );
2401
+ }
2402
+
2403
+ // ALL descendant session IDs of `rootID` (breadth-first over the parentID chain
2404
+ // in the state.json snapshot), de-duplicated. The snapshot is the ONLY source:
2405
+ // each entry is keyed by its own sessionID and carries the parentID recorded
2406
+ // when it was written, so the chain is root-consistent — entries of OTHER roots
2407
+ // are unreachable from `rootID` (their parentID points at their own root). The
2408
+ // earlier cross-root leakage came from the in-memory index, which is no longer
2409
+ // consulted here.
2410
+ //
2411
+ // The BFS visits every level (subagents of subagents included). A phantom node
2412
+ // (no tokens, see isEmptyStateEntry) is never emitted, but traversal still
2413
+ // descends through it so real grandchildren behind a phantom parent are found.
2414
+ // `seen` guards against cycles (parentID loops) and double-listing.
2415
+ function collectDescendantSessionIDs(rootID, state) {
2416
+ const seen = new Set([rootID]);
2417
+ const out = [];
2418
+ const queue = [rootID];
2419
+ // Index the snapshot by parentID once (O(n)) instead of an O(n) scan per
2420
+ // visited node.
2421
+ const byParent = new Map();
2422
+ for (const [id, entry] of Object.entries(state || {})) {
2423
+ const pid = entry?.parentID;
2424
+ if (!pid) continue;
2425
+ let set = byParent.get(pid);
2426
+ if (!set) {
2427
+ set = new Set();
2428
+ byParent.set(pid, set);
2429
+ }
2430
+ set.add(id);
2431
+ }
2432
+ while (queue.length > 0) {
2433
+ const cur = queue.shift();
2434
+ const kids = byParent.get(cur);
2435
+ if (!kids) continue;
2436
+ for (const c of kids) {
2437
+ if (!c || seen.has(c)) continue; // cycle + dedup guard
2438
+ seen.add(c);
2439
+ if (!isEmptyStateEntry(state[c])) out.push(c);
2440
+ queue.push(c); // descend through phantom parents too
2441
+ }
2442
+ }
2443
+ return out;
2444
+ }
2445
+
2446
+ // Normalise a breakdown record (cache or state entry) into the 7 categories.
2447
+ // `inputTokens` is used only for the residual "other".
2448
+ function breakdownCategories(rec, inputTokens) {
2449
+ const user = rec?.userChars ?? rec?.user ?? 0;
2450
+ const assistant = rec?.assistantChars ?? rec?.assistant ?? 0;
2451
+ const reasoning = rec?.reasoningChars ?? rec?.reasoning ?? 0;
2452
+ const toolArgs = rec?.toolArgsChars ?? rec?.toolArgs ?? 0;
2453
+ const system = rec?.systemChars !== undefined ? rec.systemChars : rec?.system ?? null;
2454
+ const toolSchemas =
2455
+ rec?.toolSchemasChars !== undefined ? rec.toolSchemasChars : rec?.toolSchemas ?? null;
2456
+ const sum =
2457
+ user + assistant + reasoning + toolArgs + (system || 0) + (toolSchemas || 0);
2458
+ const other = Math.max(0, (inputTokens || 0) - sum);
2459
+ return { user, assistant, reasoning, toolArgs, system, toolSchemas, other };
2460
+ }
2461
+
2462
+ // Short source tag for the table (kept tiny on purpose).
2463
+ function sourceTag(source) {
2464
+ switch (source) {
2465
+ case "context-hook":
2466
+ return "live";
2467
+ case "client-context":
2468
+ return "fallback";
2469
+ case "snapshot":
2470
+ return "snap";
2471
+ case "no-data":
2472
+ return "no-data";
2473
+ default:
2474
+ return source || "none";
2475
+ }
2476
+ }
2477
+
2478
+ function timeOnly(iso) {
2479
+ if (!iso) return "-";
2480
+ try {
2481
+ const d = new Date(iso);
2482
+ if (Number.isNaN(d.getTime())) return "-";
2483
+ return d.toTimeString().slice(0, 8); // HH:MM:SS
2484
+ } catch {
2485
+ return "-";
2486
+ }
2487
+ }
2488
+
2489
+ // Resolve one session's report: totals, model, categories + honest source.
2490
+ // `deadline` is the shared wall-clock budget for the whole command collection
2491
+ // (see commandContextBreakdown): the per-session live lookups are capped by
2492
+ // whatever remains of it and skipped once it is exhausted. A session that ends
2493
+ // up with no category data is tagged "no-data" (see the branch below).
2494
+ async function collectSessionReport(ctx, sessionID, state, deadline) {
2495
+ const entry = state?.[sessionID] || null;
2496
+ const bd = breakdownCache.get(sessionID) || null;
2497
+
2498
+ const remaining = Math.max(0, (deadline || 0) - Date.now());
2499
+ const budget = Math.min(V2_FALLBACK_TIMEOUT_MS, remaining);
2500
+
2501
+ // The attribution/model caches survive across requests for the lifetime of a
2502
+ // plugin instance. When cold — and while budget remains — resolve the session
2503
+ // live so the [sub:<agent>] attribution and the model stay correct even if
2504
+ // state.json is stale. ParentID comes from state.json ONLY (entry?.parentID)
2505
+ // so the table is root-consistent; in-memory sessionParentCache is cross-session
2506
+ // and must NOT be used for attribution.
2507
+ const needEnrich =
2508
+ !sessionAgentCache.has(sessionID) ||
2509
+ lastKnownModel(sessionID) === "unknown";
2510
+
2511
+ // Fallback categories only when neither the live cache nor a state snapshot
2512
+ // has them: derive user/assistant/reasoning/tool-args from the persisted
2513
+ // message list (system/tool-schemas stay null there).
2514
+ const needFallback = !bd && !entry?.categories;
2515
+
2516
+ // Enrichment and the context fallback run CONCURRENTLY, both bounded by the
2517
+ // shared budget. Non-fatal: any failure resolves to null and keeps whatever
2518
+ // the cache / state.json already provides.
2519
+ const [live, contextMsgs] = await Promise.all([
2520
+ needEnrich && budget > 0
2521
+ ? withTimeout(ctx.session.get({ sessionID }), budget, "session.get").then(
2522
+ (info) => info?.data ?? info ?? null,
2523
+ (err) => {
2524
+ logErr(`context-breakdown: session.get enrichment failed for ${sessionID}`, err);
2525
+ return null;
2526
+ },
2527
+ )
2528
+ : null,
2529
+ needFallback && budget > 0
2530
+ ? withTimeout(ctx.session.context({ sessionID }), budget, "session.context").then(
2531
+ (res) => (Array.isArray(res) ? res : res?.data),
2532
+ (err) => {
2533
+ logErr(`context-breakdown: session.context fallback failed for ${sessionID}`, err);
2534
+ return null;
2535
+ },
2536
+ )
2537
+ : null,
2538
+ ]);
2539
+
2540
+ let categories = null;
2541
+ let source = "none";
2542
+ if (bd) {
2543
+ categories = breakdownCategories(bd, lastKnownInput(sessionID) || entry?.input || 0);
2544
+ source = bd.source || "live";
2545
+ } else if (entry?.categories) {
2546
+ categories = entry.categories;
2547
+ source = "snapshot";
2548
+ } else if (Array.isArray(contextMsgs)) {
2549
+ categories = breakdownCategories(
2550
+ estimateBreakdownFromClientMessages(contextMsgs),
2551
+ 0,
2552
+ );
2553
+ source = "client-context";
2554
+ } else if (needFallback) {
2555
+ // No live cache, no state.json categories, and the bounded session.context
2556
+ // fallback produced no usable messages (it failed or had no budget left).
2557
+ // The category cells stay n/a; ctx/model/updatedAt still come from
2558
+ // state.json / the live lookup.
2559
+ source = "no-data";
2560
+ }
2561
+
2562
+ const liveModel =
2563
+ live?.model && typeof live.model === "object" ? live.model : null;
2564
+ const cachedModel = lastKnownModel(sessionID);
2565
+ const modelID =
2566
+ cachedModel !== "unknown"
2567
+ ? cachedModel
2568
+ : liveModel?.id || entry?.model || "unknown";
2569
+ const providerID =
2570
+ lastKnownProvider(sessionID) || liveModel?.providerID || entry?.providerID || "";
2571
+ const ctxTokens = lastKnownCtx(sessionID) || entry?.ctx || 0;
2572
+ const usableInfo = getUsableContext(providerID, modelID);
2573
+ const usable =
2574
+ usableInfo?.usable ?? entry?.usable ?? getModelLimit(providerID, modelID) ?? null;
2575
+ const parentID = entry?.parentID ?? null;
2576
+ const updatedAt =
2577
+ entry?.updatedAt ||
2578
+ (bd?.capturedAt ? new Date(bd.capturedAt).toISOString() : null);
2579
+ return {
2580
+ sessionID,
2581
+ parentID,
2582
+ agent: sessionAgentCache.get(sessionID) || entry?.agent || null,
2583
+ modelID,
2584
+ providerID,
2585
+ ctxTokens,
2586
+ usable,
2587
+ categories,
2588
+ source,
2589
+ updatedAt,
2590
+ };
2591
+ }
2592
+
2593
+ function fmtCell(n) {
2594
+ return n == null ? "n/a" : fmt(n);
2595
+ }
2596
+
2597
+ // One markdown table row. Categories sit in columns (short headers) to keep the
2598
+ // table compact; the ctx column already carries the percentage.
2599
+ function renderSessionRow(r, isMain) {
2600
+ const c = r.categories || {};
2601
+ const role = isMain ? "main" : r.agent ? `sub:${r.agent}` : "sub";
2602
+ const pct =
2603
+ r.usable && r.usable > 0
2604
+ ? ` (${((r.ctxTokens / r.usable) * 100).toFixed(0)}%)`
2605
+ : "";
2606
+ const ctx = `${fmt(r.ctxTokens)}${pct}`;
2607
+ const upd = `${timeOnly(r.updatedAt)} ${sourceTag(r.source)}`;
2608
+ return `| ${role} | ${r.modelID} | ${ctx} | ${fmtCell(c.user)} | ${fmtCell(c.assistant)} | ${fmtCell(c.reasoning)} | ${fmtCell(c.toolArgs)} | ${fmtCell(c.system)} | ${fmtCell(c.toolSchemas)} | ${fmtCell(c.other)} | ${upd} |`;
2609
+ }
2610
+
2611
+ function renderBreakdownTable(rows, childCount) {
2612
+ const lines = [];
2613
+ lines.push("### Context breakdown");
2614
+ lines.push("");
2615
+ lines.push(
2616
+ "| role | model | ctx | usr | asst | rsn | tool | sys | schm | oth | updated (src) |",
2617
+ );
2618
+ lines.push("| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |");
2619
+ for (const row of rows) lines.push(row);
2620
+ lines.push("");
2621
+ lines.push(
2622
+ `_${childCount} subagent session(s). Tokens are estimates (unicode heuristic). ` +
2623
+ `usr=user, asst=assistant, rsn=reasoning, tool=tool args, sys=system prompt, ` +
2624
+ `schm=tool schemas, oth=residual input. src: live=context hook, snap=state.json, ` +
2625
+ `fallback=session.context (no system/schemas → n/a), no-data=categories unavailable._`,
2626
+ );
2627
+ return lines.join("\n");
2628
+ }
2629
+
2630
+ // Shared delivery via ctx.session.synthetic — a synthetic message ALWAYS enters
2631
+ // the session inbox; `resume` only gates whether the session is woken:
2632
+ // resume:false -> durable notice, session NOT woken (LLM context unchanged);
2633
+ // resume:true -> same inbox item PLUS a wake, i.e. an agent turn runs and the
2634
+ // session context grows (a real LLM request is issued).
2635
+ // Returns true on success. On failure the user sees nothing, so we log a loud,
2636
+ // actionable error; the payload is still written to the human-readable log by the
2637
+ // caller (there is no other verified delivery channel: no noReply/prompt option
2638
+ // exists in this runtime).
2639
+ async function deliverSynthetic(ctx, sid, text, description, resume) {
2640
+ try {
2641
+ await ctx.session.synthetic({ sessionID: sid, text, description, resume });
2642
+ return true;
2643
+ } catch (err) {
2644
+ logErr(
2645
+ `context command: session.synthetic FAILED (session=${sid}, ` +
2646
+ `resume=${resume}) — no message delivered; payload preserved in the context log`,
2647
+ err,
2648
+ );
2649
+ return false;
2650
+ }
2651
+ }
2652
+
2653
+ // ---- /context : quick summary of THIS session only (no subagents, no RPC) ----
2654
+
2655
+ // The one-line description is what the Desktop notice chip shows, so it must be
2656
+ // self-contained (model + tokens + % + time).
2657
+ function shortSummaryDescription(r) {
2658
+ const pct =
2659
+ r.usable && r.usable > 0
2660
+ ? ` (${((r.ctxTokens / r.usable) * 100).toFixed(0)}%)`
2661
+ : "";
2662
+ const hhmm = timeOnly(r.updatedAt).slice(0, 5);
2663
+ const model = r.modelID && r.modelID !== "unknown" ? r.modelID : "model?";
2664
+ return `Context: ${fmt(r.ctxTokens)}${pct} · ${model} · ${hhmm}`;
2665
+ }
2666
+
2667
+ // Three short lines, built from cache/state only (see commandContextSummary).
2668
+ function shortSummaryText(r) {
2669
+ const model =
2670
+ r.modelID && r.modelID !== "unknown" ? r.modelID : "unknown model";
2671
+ const total =
2672
+ r.usable && r.usable > 0
2673
+ ? ` / ${fmt(r.usable)} (${((r.ctxTokens / r.usable) * 100).toFixed(0)}%)`
2674
+ : "";
2675
+ const lines = [`**Context** — ${model} · ${fmt(r.ctxTokens)}${total}`];
2676
+ const c = r.categories;
2677
+ if (c) {
2678
+ lines.push(
2679
+ `usr ${fmtCell(c.user)} · asst ${fmtCell(c.assistant)} · rsn ${fmtCell(c.reasoning)} · ` +
2680
+ `tool ${fmtCell(c.toolArgs)} · sys ${fmtCell(c.system)} · schm ${fmtCell(c.toolSchemas)} · ` +
2681
+ `oth ${fmtCell(c.other)}`,
2682
+ );
2683
+ } else {
2684
+ lines.push("_category breakdown unavailable (no cache/state entry yet)_");
2685
+ }
2686
+ lines.push(`updated ${timeOnly(r.updatedAt)} (${sourceTag(r.source)})`);
2687
+ return lines.join("\n");
2688
+ }
2689
+
2690
+ // /context handler: main session only, cache + state.json only (NO enrichment,
2691
+ // NO child traversal) -> instant. Delivered as resume:true so the model
2692
+ // renders the 3-line summary as a normal assistant message (the Desktop
2693
+ // notice chip only shows `description`).
2694
+ async function commandContextSummary(ctx, invocation, opts) {
2695
+ const name = opts?.name || "context";
2696
+ const sid = invocation?.sessionID || invocation?.session?.id;
2697
+ if (!sid) {
2698
+ logErr("context: invocation carried no sessionID");
2699
+ return;
2700
+ }
2701
+ try {
2702
+ const state = readStateSnapshot();
2703
+ // Deadline in the past => collectSessionReport performs no live RPC at all
2704
+ // (cache + state.json only), which keeps this command instant.
2705
+ const report = await collectSessionReport(ctx, sid, state, 0);
2706
+ const body = shortSummaryText(report);
2707
+ // Reproduce verbatim so the model echoes the 3 lines as the assistant
2708
+ // answer; the Desktop renders that answer in full (the notice chip only
2709
+ // shows `description`).
2710
+ const text = `${BREAKDOWN_MODEL_INSTRUCTION}\n\n${body}`;
2711
+ const description = shortSummaryDescription(report);
2712
+ const delivered = await deliverSynthetic(ctx, sid, text, description, true);
2713
+ pushFinalSummary(`cmd:${sid}:${Date.now()}`, [
2714
+ `[${new Date().toISOString()}] COMMAND ${name} session=${sid} delivered=${delivered}`,
2715
+ ...text.split("\n"),
2716
+ ]);
2717
+ } catch (err) {
2718
+ logErr("context command failed", err);
2719
+ }
2720
+ }
2721
+
2722
+ // ---- /context-breakdown : full table (main + subagents), model-rendered ----
2723
+
2724
+ // Prefixed to the table for resume:true so the agent echoes it verbatim into the
2725
+ // transcript (the Desktop renders the model's answer as a normal message).
2726
+ const BREAKDOWN_MODEL_INSTRUCTION =
2727
+ "Reproduce the markdown table below exactly, verbatim, with no changes and no commentary:";
2728
+
2729
+ // Command handler shared by both slash commands. Registered via
2730
+ // ctx.command.transform in setup(); invoked by opencode with
2731
+ // { sessionID, prompt:{text,...}, delivery }. `opts` = { resume, name }.
2732
+ async function commandContextBreakdown(ctx, invocation, opts) {
2733
+ const resume = !!opts?.resume;
2734
+ const name = opts?.name || "context-breakdown";
2735
+ const sid = invocation?.sessionID || invocation?.session?.id;
2736
+ if (!sid) {
2737
+ logErr("context-breakdown: invocation carried no sessionID");
2738
+ return;
2739
+ }
2740
+ try {
2741
+ // state.json is read immediately and synchronously; the root and every
2742
+ // descendant are then collected IN PARALLEL under one cumulative budget, so
2743
+ // the command costs ~budget regardless of the subagent count (not N × 3s).
2744
+ const state = readStateSnapshot();
2745
+ const childIDs = collectDescendantSessionIDs(sid, state);
2746
+ const deadline = Date.now() + COMMAND_COLLECT_BUDGET_MS;
2747
+ const [root, ...children] = await Promise.all([
2748
+ collectSessionReport(ctx, sid, state, deadline),
2749
+ ...childIDs.map((cid) => collectSessionReport(ctx, cid, state, deadline)),
2750
+ ]);
2751
+ const rows = [renderSessionRow(root, true)];
2752
+ for (const rep of children) rows.push(renderSessionRow(rep, false));
2753
+ const table = renderBreakdownTable(rows, childIDs.length);
2754
+ const payload = resume ? `${BREAKDOWN_MODEL_INSTRUCTION}\n\n${table}` : table;
2755
+ const description = `Full context breakdown — ${1 + childIDs.length} session(s)`;
2756
+
2757
+ const delivered = await deliverSynthetic(ctx, sid, payload, description, resume);
2758
+
2759
+ // Persist the table itself (not the instruction) to the human-readable log
2760
+ // (survives rewrites, bounded) so it is always recoverable.
2761
+ pushFinalSummary(`cmd:${sid}:${Date.now()}`, [
2762
+ `[${new Date().toISOString()}] COMMAND ${name} session=${sid} delivered=${delivered}`,
2763
+ ...table.split("\n"),
2764
+ ]);
2765
+ } catch (err) {
2766
+ logErr("context-breakdown command failed", err);
2767
+ }
2768
+ }
2769
+
2262
2770
  // ---------------------------------------------------------------------------
2263
2771
  // Entrypoints
2264
2772
  // ---------------------------------------------------------------------------
@@ -2277,58 +2785,66 @@ export default {
2277
2785
  logErr("v2 context hook registration failed", err);
2278
2786
  }
2279
2787
 
2280
- // Effective per-model context limits, captured synchronously from the
2281
- // model registry whenever it is (re)built. This is the authoritative V2
2282
- // source and is available immediately (no async race right after boot).
2283
- // Read-only: the editor is never mutated.
2284
- try {
2285
- registrations.push(
2286
- await ctx.model.transform((editor) => {
2287
- try {
2288
- const list = typeof editor?.list === "function" ? editor.list() : [];
2289
- for (const m of list || []) {
2290
- const modelID = m?.modelID ?? m?.id;
2291
- const providerID =
2292
- typeof m?.providerID === "string" && m.providerID
2293
- ? m.providerID
2294
- : typeof m?.provider?.id === "string"
2295
- ? m.provider.id
2296
- : "";
2297
- const key = modelKey(providerID, modelID);
2298
- const lim = m?.limit;
2299
- if (!key || !lim || typeof lim !== "object") continue;
2300
- const context =
2301
- typeof lim.context === "number" && lim.context > 0 ? lim.context : null;
2302
- const input =
2303
- typeof lim.input === "number" && lim.input > 0 ? lim.input : null;
2304
- const output =
2305
- typeof lim.output === "number" && lim.output > 0 ? lim.output : null;
2306
- if (context == null && input == null) continue;
2307
- const prevRec = modelLimits.get(key);
2308
- modelLimits.set(key, {
2309
- context,
2310
- input,
2311
- output,
2312
- reserved:
2313
- typeof m?.compaction?.reserved === "number"
2314
- ? m.compaction.reserved
2315
- : (prevRec?.reserved ?? null),
2316
- });
2317
- }
2318
- } catch (err) {
2319
- logErr("v2 model transform limits failed", err);
2320
- }
2321
- }),
2322
- );
2323
- } catch (err) {
2324
- logErr("v2 model transform registration failed", err);
2325
- }
2788
+ // The model registry is captured asynchronously through ensureModelLimits
2789
+ // (ctx.model.list(), rank 3) — the authoritative source that carries the
2790
+ // user's merged config overrides. ctx.model.transform fires synchronously
2791
+ // on every catalog rebuild BEFORE ctx.model.list completes and carries the
2792
+ // generic Model.Info defaults (context:200000, output:32000) for provider-
2793
+ // declared models before overrides are applied; writing it to modelLimits
2794
+ // would flip the denominator from the configured window to the generic
2795
+ // default on every catalog rebuild (observed: a configured window flipping
2796
+ // to the generic 200k default), so the transform is not used for limit
2797
+ // population.
2326
2798
 
2327
2799
  // Fire-and-forget prefetch of effective model limits.
2328
2800
  ensureModelLimits(ctx).catch((err) =>
2329
2801
  logErr("model limits prefetch failed", err),
2330
2802
  );
2331
2803
 
2804
+ // Two V2 slash commands (V2 only): /context (instant per-session summary)
2805
+ // and /context-breakdown (full table, main + all subagent descendants).
2806
+ // Guarded: older 2.0.x builds may lack ctx.command / ctx.session.synthetic —
2807
+ // registering a command whose handler cannot deliver would fail silently.
2808
+ try {
2809
+ const canCommand =
2810
+ ctx.command && typeof ctx.command.transform === "function";
2811
+ const canSynthetic =
2812
+ !!ctx.session && typeof ctx.session.synthetic === "function";
2813
+ if (canCommand && canSynthetic) {
2814
+ registrations.push(
2815
+ await ctx.command.transform((editor) => {
2816
+ try {
2817
+ editor.add({
2818
+ name: "context",
2819
+ description:
2820
+ "Quick context summary for the current session (compact notice)",
2821
+ execute: (invocation) =>
2822
+ commandContextSummary(ctx, invocation, { name: "context" }),
2823
+ });
2824
+ editor.add({
2825
+ name: "context-breakdown",
2826
+ description:
2827
+ "Full per-session context breakdown table (main + subagents), rendered by the model",
2828
+ execute: (invocation) =>
2829
+ commandContextBreakdown(ctx, invocation, {
2830
+ resume: CONTEXT_BREAKDOWN_RESUME,
2831
+ name: "context-breakdown",
2832
+ }),
2833
+ });
2834
+ } catch (err) {
2835
+ logErr("v2 command transform failed", err);
2836
+ }
2837
+ }),
2838
+ );
2839
+ } else {
2840
+ logErr(
2841
+ `context commands not registered (command=${canCommand}, synthetic=${canSynthetic})`,
2842
+ );
2843
+ }
2844
+ } catch (err) {
2845
+ logErr("v2 command transform registration failed", err);
2846
+ }
2847
+
2332
2848
  const controller = new AbortController();
2333
2849
  void (async () => {
2334
2850
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-context-indicator",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
4
4
  "description": "Real-time OpenCode context-window usage indicator: per-category token breakdown written to a log file, plus an optional TUI sidebar. Dual V1 + V2 plugin entry.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -53,5 +53,11 @@
53
53
  "solid-js": {
54
54
  "optional": true
55
55
  }
56
+ },
57
+ "devDependencies": {
58
+ "@opencode/plugin": "^2.0.19",
59
+ "@opentui/core": "^0.5.12",
60
+ "@opentui/solid": "^0.5.12",
61
+ "solid-js": "^1.9.12"
56
62
  }
57
63
  }
package/tui.tsx CHANGED
@@ -1,6 +1,15 @@
1
+ /** @jsxImportSource @opentui/solid */
1
2
  /**
2
3
  * tui.tsx — optional OpenCode TUI sidebar for `opencode-context-indicator`.
3
4
  *
5
+ * The import-source pragma on the first line above is REQUIRED: without it the
6
+ * opencode TUI loader (Bun) transforms JSX against the React runtime and emits
7
+ * `import ... from "react/jsx-runtime"`, which the TUI runtime does not provide,
8
+ * so loading the plugin from the npm cache throws `Cannot find package 'react'`
9
+ * (react only ever resolved from a developer's local node_modules). The pragma
10
+ * must stay the very first line, before any import, and no other pragma-like
11
+ * token may appear in this file. See the OpenTUI Solid integration guide.
12
+ *
4
13
  * Exposed as the `./tui` package entry ("beside the main plugin for automatic
5
14
  * loading"). OpenCode loads it only in the terminal TUI; it is NOT loaded in
6
15
  * the Desktop app (Desktop exposes no TUI slot API). It renders the
@@ -19,6 +28,7 @@
19
28
  */
20
29
  import {Plugin, usePlugin} from "@opencode/plugin/tui"
21
30
  import {createSignal, Show} from "solid-js"
31
+ import type {ColorInput} from "@opentui/core"
22
32
  import {readFileSync} from "node:fs"
23
33
  import {tmpdir} from "node:os"
24
34
  import {join} from "node:path"
@@ -62,8 +72,10 @@ function num(v: unknown): number {
62
72
  // Theme tokens are OpenTUI RGBA values (see ResolvedTheme.text.base/.muted);
63
73
  // pass them straight to the renderer. Fall back to a named color when a token
64
74
  // is absent so an unexpected theme shape can never blank the sidebar.
65
- type Fg = string | object
66
- function token(v: unknown, fallback: string): Fg {
75
+ // `fg` on <text> is typed as OpenTUI's ColorInput (= string | RGBA); using the
76
+ // same alias keeps the JSX assignment type-correct instead of a loose `object`.
77
+ type Fg = ColorInput
78
+ function token(v: unknown, fallback: Fg): Fg {
67
79
  return v && typeof v === "object" ? (v as Fg) : fallback
68
80
  }
69
81