opencode-context-indicator 1.1.1 → 1.1.3

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 +74 -8
  2. package/index.js +121 -9
  3. package/package.json +4 -6
  4. package/tui.tsx +40 -2
package/README.md CHANGED
@@ -73,6 +73,10 @@ other 5.6k 12%
73
73
  updated 12:30:01
74
74
  ```
75
75
 
76
+ > Requires a `file://` install from a path outside `node_modules`; on the npm
77
+ > path the sidebar is auto-disabled. See
78
+ > [Live sidebar: npm vs file:// install](#live-sidebar-npm-vs-file-install).
79
+
76
80
  ### Slash commands (V2)
77
81
 
78
82
  On OpenCode 2.x (Desktop **and** terminal TUI) the plugin registers two slash
@@ -192,6 +196,13 @@ back to a plain notice if needed.
192
196
  with no category data (no cache/snapshot and the fallback failed or ran out of
193
197
  budget) is tagged `no-data`; `ctx` / `model` / `updatedAt` still come from
194
198
  `state.json` / the live lookup, while its category cells stay `n/a`.
199
+ * **The denominator survives a transient model-registry miss.** If the serving
200
+ instance's `ctx.model.list()` returns no usable limits (freshly loaded instance,
201
+ provider not yet registered, malformed payload), the window is recovered from
202
+ the `state.json` snapshot instead of degrading the `ctx` column to `?`; limits
203
+ are also seeded from the snapshot at startup, and the retry backs off
204
+ (30 s → 5 min, capped). A known `usable`/`limit`/`agent` is never overwritten
205
+ with `null` when another instance rewrites the snapshot.
195
206
  * Both commands append their payload to `context-breakdown.log` (final-summaries
196
207
  section), so it stays recoverable even if delivery fails.
197
208
  * If `ctx.session.synthetic` throws, the command logs an actionable error and the
@@ -225,6 +236,9 @@ Notes — these reflect what the plugin API actually exposes today:
225
236
  therefore delivers everything through the log file, the TUI sidebar and the
226
237
  `/context` / `/context-breakdown` slash commands. The sidebar is a TUI-process
227
238
  slot and does not exist in the Desktop app; the slash commands work in **both**.
239
+ The TUI sidebar is **live only for a `file://` install** from a path outside
240
+ `node_modules`; on the npm path it is auto-disabled (upstream `#33884`) — see
241
+ [Live sidebar: npm vs file:// install](#live-sidebar-npm-vs-file-install).
228
242
  * **V1 has no sidebar and no slash command.** The command/slot API (and the
229
243
  `./tui` entry) is a V2 feature; OpenCode 1.x has no equivalent, so V1 is
230
244
  toast + log only.
@@ -275,13 +289,55 @@ or add it to `opencode.jsonc`:
275
289
  ```
276
290
 
277
291
  The `./tui` sidebar entry is loaded automatically alongside the main plugin in
278
- the terminal TUI. For a CLI-only setup against remote servers, the package can
279
- also be listed in [`cli.json`](https://opencode.ai/v2/docs/cli/plugins):
292
+ the terminal TUI — but its live behaviour depends on the install method, see
293
+ [Live sidebar: npm vs file:// install](#live-sidebar-npm-vs-file-install) below.
294
+ For a CLI-only setup against remote servers, the package can also be listed in
295
+ [`cli.json`](https://opencode.ai/v2/docs/cli/plugins):
280
296
 
281
297
  ```json
282
298
  { "plugins": ["opencode-context-indicator"] }
283
299
  ```
284
300
 
301
+ ### Live sidebar: npm vs file:// install
302
+
303
+ The TUI sidebar is a **TUI-process slot**. On OpenCode 2.x it behaves
304
+ differently depending on **how the plugin was installed** — an upstream OpenCode
305
+ limitation (`anomalyco/opencode#33884`), not a plugin bug: OpenCode's TUI loader
306
+ skips the host Solid transform for any path inside a `node_modules` directory, so
307
+ a slot mounted from an npm install renders once and then never live-updates.
308
+
309
+ | Install | Desktop | TUI commands | TUI live sidebar |
310
+ | --- | :---: | :---: | :---: |
311
+ | npm (`opencode plugin add opencode-context-indicator`) | ✅ | ✅ | ❌ auto-disabled (upstream #33884) |
312
+ | `file://` (path **outside** any `node_modules`) | ✅ | ✅ | ✅ live |
313
+
314
+ On the **npm** path the plugin detects that it lives under `node_modules` and
315
+ **disables the sidebar gracefully** — it logs a single hint and registers no
316
+ slot, so you never see a frozen "no data yet" panel. The `/context` /
317
+ `/context-breakdown` slash commands and the breakdown log file keep working on
318
+ every install.
319
+
320
+ #### Getting the live sidebar (`file://` install)
321
+
322
+ 1. Put the package **outside** any `node_modules`: clone the repository, or
323
+ unpack the npm tarball (`npm pack opencode-context-indicator`, then extract
324
+ the `.tgz`) — e.g. into `C:\Users\you\plugins\opencode-context-indicator`.
325
+ Double-check that the chosen path contains **no `node_modules` segment**
326
+ (e.g. `C:\Users\you\plugins\...`, not `.../node_modules/...`).
327
+ 2. Point OpenCode at that directory in `~/.config/opencode/opencode.jsonc`:
328
+
329
+ ```jsonc
330
+ {
331
+ "plugins": ["file:///C:/Users/you/plugins/opencode-context-indicator"]
332
+ }
333
+ ```
334
+
335
+ 3. Restart OpenCode. The sidebar is now live and updates as context grows.
336
+
337
+ > ⚠️ The `file://` path must **not** contain a `node_modules` segment. If it
338
+ > does, OpenCode skips the Solid transform and the sidebar will not update; the
339
+ > plugin auto-disables it and logs the hint above.
340
+
285
341
  ### OpenCode 1.x (V1)
286
342
 
287
343
  Add the package name to the `plugin` array in your `opencode.json`:
@@ -303,9 +359,11 @@ helper resolves. The `lib/` folder is support code, not a separate plugin entry:
303
359
  ~/.config/opencode/plugins/lib/dedup.js
304
360
  ```
305
361
 
306
- To use the sidebar locally as well, place `tui.tsx` where your OpenCode
307
- installation resolves the package's `./tui` entry (npm install is the
308
- recommended path for the sidebar).
362
+ To use the sidebar locally as well, point OpenCode at a `file://` copy of the
363
+ package that lives **outside any `node_modules`** (see
364
+ [Live sidebar: npm vs file:// install](#live-sidebar-npm-vs-file-install)) — that
365
+ is the only install path that keeps the sidebar live. An npm install auto-disables
366
+ the sidebar instead.
309
367
 
310
368
  ---
311
369
 
@@ -322,9 +380,17 @@ recommended path for the sidebar).
322
380
  * **OpenCode ≥ 2.0.16** for the V2 path (built and verified against
323
381
  **2.0.19**).
324
382
  * **TUI sidebar**: terminal TUI only, requires the OpenTUI rendering stack that
325
- ships with OpenCode (`@opentui/core`, `@opentui/solid`, `solid-js`). These are
326
- declared as **optional** peer dependencies, so the main plugin installs and
327
- runs fine without them (the sidebar simply is not available).
383
+ ships with OpenCode. `@opentui/core` and `solid-js` are **optional** peer
384
+ dependencies resolved by OpenCode at runtime; the main plugin installs and runs
385
+ fine without them (the sidebar simply is not available). `@opentui/solid` is
386
+ instead shipped as a **pinned direct dependency** (exact `0.5.12`): OpenCode's
387
+ TUI loader does not expose a host instance of it, so for npm-installed plugins
388
+ the JSX pragma would otherwise fail to resolve `@opentui/solid/jsx-runtime`
389
+ (upstream: opencode issue #33884 — `node_modules` plugins are excluded from the
390
+ host Solid transform and get an isolated OpenTUI copy). Because of that same
391
+ upstream limitation, on the **npm** path the sidebar is **auto-disabled**
392
+ (the plugin logs one hint and registers no slot) — use a `file://` install for
393
+ the live sidebar, see [Live sidebar: npm vs file:// install](#live-sidebar-npm-vs-file-install).
328
394
 
329
395
  ---
330
396
 
package/index.js CHANGED
@@ -177,7 +177,8 @@ const CRITICAL_THROTTLE_MS = 10000; // min gap between critical (>90%) toasts
177
177
  const ERROR_TOAST_DEDUP_MS = 60000; // same error -> at most one toast / window
178
178
  const COMPACTED_TOAST_DEDUP_MS = 60000; // compacted-toast dedup window
179
179
  const PROVIDERS_TTL_MS = 5 * 60 * 1000; // providers()/model.list() cache TTL after SUCCESS
180
- const PROVIDERS_FAIL_TTL_MS = 30 * 1000; // retry-no-sooner gate after FAILURE
180
+ const PROVIDERS_FAIL_TTL_MS = 30 * 1000; // retry-no-sooner base after FAILURE (doubles per consecutive miss)
181
+ const V2_LIMITS_MAX_BACKOFF_MS = 5 * 60 * 1000; // cap for the model.list retry backoff
181
182
  const PROVIDERS_FETCH_TIMEOUT_MS = 2000; // hard cap for one providers() call (V1)
182
183
  const V2_MODEL_FETCH_TIMEOUT_MS = 8000; // hard cap for one ctx.model.list() call (V2)
183
184
  const TOAST_MS_ERROR = 8000; // duration for error / overflow toasts
@@ -278,6 +279,7 @@ let providersInFlight = null;
278
279
  // absolute tokens (as in V1).
279
280
  let v2LimitsLoadedAt = 0;
280
281
  let v2LimitsFailAt = 0;
282
+ let v2LimitsFailCount = 0; // consecutive soft-failures -> exponential retry backoff
281
283
  let v2LimitsInFlight = null;
282
284
 
283
285
  // Bounded list of final summaries written on session.idle
@@ -472,6 +474,14 @@ function writeStateFile(
472
474
  const prevEntry = all[sessionID] || null;
473
475
  const pc = all[sessionID]?.categories || null;
474
476
 
477
+ // Same model AND provider? Only then is a previously persisted denominator
478
+ // meaningful — a session that switched model (or provider, even with the
479
+ // same modelID) must not inherit the old window.
480
+ const sameModelAsPrev =
481
+ prevEntry != null &&
482
+ prevEntry.model === (modelID || "unknown") &&
483
+ (prevEntry.providerID || "") === (providerID || "");
484
+
475
485
  const catUser = bd ? bd.userChars || 0 : pc?.user ?? 0;
476
486
  const catAssistant = bd ? bd.assistantChars || 0 : pc?.assistant ?? 0;
477
487
  const catReasoning = bd ? bd.reasoningChars || 0 : pc?.reasoning ?? 0;
@@ -504,13 +514,23 @@ function writeStateFile(
504
514
  sessionID,
505
515
  parentID,
506
516
  role: parentID ? "sub" : "main",
507
- agent: sessionAgentCache.get(sessionID) || null,
517
+ agent: sessionAgentCache.get(sessionID) || prevEntry?.agent || null,
508
518
  model: modelID || "unknown",
509
519
  providerID: providerID || "",
510
520
  ctx: total,
511
521
  input: inputTokens || 0,
512
- usable: denom,
513
- limit: limit != null ? limit : null,
522
+ // Do NOT clobber a known denominator with null: multiple plugin instances
523
+ // share this file and one of them may lack modelLimits (its ctx.model.list
524
+ // returned nothing). Keep the previous model's known value when the live
525
+ // lookup came back empty, so a transient miss never becomes permanent.
526
+ usable:
527
+ denom != null ? denom : sameModelAsPrev ? prevEntry.usable ?? null : null,
528
+ limit:
529
+ limit != null
530
+ ? limit
531
+ : sameModelAsPrev && typeof prevEntry.limit === "number"
532
+ ? prevEntry.limit
533
+ : null,
514
534
  reasoning: typeof reasoningTokens === "number" ? reasoningTokens : 0,
515
535
  categories: {
516
536
  user: catUser,
@@ -1916,7 +1936,14 @@ async function ensureV2Breakdown(ctx, sessionID) {
1916
1936
  async function ensureModelLimits(ctx) {
1917
1937
  const now = Date.now();
1918
1938
  if (now - v2LimitsLoadedAt < PROVIDERS_TTL_MS) return; // fresh success
1919
- if (now - v2LimitsFailAt < PROVIDERS_FAIL_TTL_MS) return; // recent failure
1939
+ // Recent failure: back off exponentially (30s, 60s, 120s … capped at 5 min)
1940
+ // so a registry that is slow to expose the provider is retried without
1941
+ // hammering it, while a one-off blip still recovers quickly.
1942
+ const failBackoff = Math.min(
1943
+ PROVIDERS_FAIL_TTL_MS * Math.pow(2, Math.max(0, v2LimitsFailCount - 1)),
1944
+ V2_LIMITS_MAX_BACKOFF_MS,
1945
+ );
1946
+ if (now - v2LimitsFailAt < failBackoff) return; // recent failure
1920
1947
  if (v2LimitsInFlight) return v2LimitsInFlight; // dedup parallel callers
1921
1948
  v2LimitsInFlight = (async () => {
1922
1949
  try {
@@ -1927,7 +1954,13 @@ async function ensureModelLimits(ctx) {
1927
1954
  );
1928
1955
  const models = Array.isArray(res) ? res : res?.data;
1929
1956
  if (!Array.isArray(models)) {
1957
+ v2LimitsFailCount++;
1930
1958
  v2LimitsFailAt = Date.now(); // malformed payload -> retry soon
1959
+ const seeded = hydrateModelLimitsFromState();
1960
+ logErr(
1961
+ "model.list returned no model array — limits unavailable, will retry",
1962
+ { payloadType: typeof res, hydratedFromState: seeded },
1963
+ );
1931
1964
  return;
1932
1965
  }
1933
1966
  let found = 0;
@@ -1964,11 +1997,36 @@ async function ensureModelLimits(ctx) {
1964
1997
  // An empty / limit-less payload right after boot (registry not ready yet)
1965
1998
  // must NOT be cached as success, or limits would stay unknown for the
1966
1999
  // full TTL — treat it as a soft failure and retry after the fail gate.
1967
- if (found > 0) v2LimitsLoadedAt = Date.now();
1968
- else v2LimitsFailAt = Date.now();
2000
+ if (found > 0) {
2001
+ v2LimitsLoadedAt = Date.now();
2002
+ v2LimitsFailCount = 0; // recovered
2003
+ } else {
2004
+ // Soft failure: the registry answered but carried no usable limit for
2005
+ // any model (provider not registered yet, or `limit` absent). Log WHY
2006
+ // once per backoff window, and fall back to the shared snapshot so
2007
+ // denominators do not vanish meanwhile.
2008
+ const withLimit = models.filter(
2009
+ (m) => m?.limit && typeof m.limit === "object",
2010
+ ).length;
2011
+ const lanit = models.filter(
2012
+ (m) => m?.providerID === "lanit" || m?.provider?.id === "lanit",
2013
+ ).length;
2014
+ const seeded = hydrateModelLimitsFromState();
2015
+ v2LimitsFailCount++;
2016
+ v2LimitsFailAt = Date.now();
2017
+ logErr(
2018
+ "model.list yielded 0 usable limits — retrying",
2019
+ { models: models.length, withLimit, lanit, hydratedFromState: seeded },
2020
+ );
2021
+ }
1969
2022
  } catch (err) {
1970
- v2LimitsFailAt = Date.now(); // failed attempt -> short retry gate
1971
- logErr("model.list failed", err);
2023
+ v2LimitsFailCount++;
2024
+ v2LimitsFailAt = Date.now(); // failed attempt -> backoff gate
2025
+ const seeded = hydrateModelLimitsFromState();
2026
+ logErr(
2027
+ `model.list failed (hydrated ${seeded} limit(s) from state.json)`,
2028
+ err,
2029
+ );
1972
2030
  } finally {
1973
2031
  v2LimitsInFlight = null;
1974
2032
  }
@@ -2377,6 +2435,56 @@ function readStateSnapshot() {
2377
2435
  }
2378
2436
  }
2379
2437
 
2438
+ // Seed `modelLimits` from previously persisted state.json entries. state.json is
2439
+ // a SHARED cross-instance snapshot rewritten at every live step, so it carries a
2440
+ // model's window even when THIS instance's ctx.model.list() came back empty (a
2441
+ // cold instance, or one whose model registry did not expose the provider).
2442
+ // Without this, a transient list miss degraded every denominator to "?" — and
2443
+ // because writeStateFile used to rewrite usable/limit from the (empty) in-memory
2444
+ // map, the degradation became permanent. Strictly additive: real in-memory
2445
+ // records always win (never overwritten). Returns the number of records seeded.
2446
+ function hydrateModelLimitsFromState() {
2447
+ try {
2448
+ const all = readStateSnapshot();
2449
+ let added = 0;
2450
+ for (const entry of Object.values(all)) {
2451
+ const providerID =
2452
+ typeof entry?.providerID === "string" ? entry.providerID : "";
2453
+ const modelID = entry?.model;
2454
+ const key = modelKey(providerID, modelID);
2455
+ if (!key || !modelID || modelID === "unknown") continue;
2456
+ if (modelLimits.has(key)) continue; // a real record always wins
2457
+ // A state entry stores the derived usable window (`usable`) and the raw
2458
+ // context window (`limit`) — NOT the input/output/reserved triple. Seed
2459
+ // `usable` as `input` (reserved 0) so getUsableContext() re-derives EXACTLY
2460
+ // the same denominator the warm instance persisted; seeding `limit` as
2461
+ // context instead would let the cold instance show the raw context window
2462
+ // (e.g. 200k) while the warm one shows the true usable window (e.g. 168k).
2463
+ // Fall back to `limit` as context only when no `usable` was recorded.
2464
+ const usable =
2465
+ typeof entry?.usable === "number" && entry.usable > 0
2466
+ ? entry.usable
2467
+ : null;
2468
+ const ctxLimit =
2469
+ typeof entry?.limit === "number" && entry.limit > 0
2470
+ ? entry.limit
2471
+ : null;
2472
+ let rec = null;
2473
+ if (usable != null) {
2474
+ rec = { context: ctxLimit, input: usable, output: null, reserved: 0 };
2475
+ } else if (ctxLimit != null) {
2476
+ rec = { context: ctxLimit, input: null, output: null, reserved: null };
2477
+ }
2478
+ if (rec == null) continue;
2479
+ modelLimits.set(key, rec);
2480
+ added++;
2481
+ }
2482
+ return added;
2483
+ } catch {
2484
+ return 0;
2485
+ }
2486
+ }
2487
+
2380
2488
  // True when a state.json entry carries no measured tokens at all: a session
2381
2489
  // registered (session.created) but with no served step. `writeStateFile` always
2382
2490
  // persists `categories` as an object, so the emptiness test must SUM those
@@ -2796,6 +2904,10 @@ export default {
2796
2904
  // to the generic 200k default), so the transform is not used for limit
2797
2905
  // population.
2798
2906
 
2907
+ // Seed limits from the shared snapshot FIRST: another instance may already
2908
+ // have resolved them, so a cold start never shows "?" denominators.
2909
+ hydrateModelLimitsFromState();
2910
+
2799
2911
  // Fire-and-forget prefetch of effective model limits.
2800
2912
  ensureModelLimits(ctx).catch((err) =>
2801
2913
  logErr("model limits prefetch failed", err),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-context-indicator",
3
- "version": "1.1.1",
3
+ "version": "1.1.3",
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",
@@ -37,7 +37,6 @@
37
37
  "peerDependencies": {
38
38
  "@opencode/plugin": ">=2.0.16",
39
39
  "@opentui/core": ">=0.5.12",
40
- "@opentui/solid": ">=0.5.12",
41
40
  "solid-js": ">=1.9.0"
42
41
  },
43
42
  "peerDependenciesMeta": {
@@ -47,17 +46,16 @@
47
46
  "@opentui/core": {
48
47
  "optional": true
49
48
  },
50
- "@opentui/solid": {
51
- "optional": true
52
- },
53
49
  "solid-js": {
54
50
  "optional": true
55
51
  }
56
52
  },
53
+ "dependencies": {
54
+ "@opentui/solid": "0.5.12"
55
+ },
57
56
  "devDependencies": {
58
57
  "@opencode/plugin": "^2.0.19",
59
58
  "@opentui/core": "^0.5.12",
60
- "@opentui/solid": "^0.5.12",
61
59
  "solid-js": "^1.9.12"
62
60
  }
63
61
  }
package/tui.tsx CHANGED
@@ -23,8 +23,19 @@
23
23
  * file renders "no data yet". The main plugin never imports this file, so the
24
24
  * absence of the OpenTUI peers cannot break the server/V1 path.
25
25
  *
26
- * Peer requirements (declared optional in package.json): @opentui/core,
27
- * @opentui/solid, solid-js.
26
+ * Runtime requirements: @opentui/core and solid-js are optional peers resolved
27
+ * by OpenCode at runtime. @opentui/solid is a pinned direct dependency (exact
28
+ * 0.5.12): OpenCode's TUI loader does not expose a host instance of it, so for
29
+ * npm-installed plugins the JSX pragma would otherwise fail to resolve
30
+ * `@opentui/solid/jsx-runtime` (upstream opencode issue #33884).
31
+ *
32
+ * Graceful mode: when this file is loaded from an npm install (its own path is
33
+ * inside a `node_modules` directory), OpenCode skips the host Solid transform
34
+ * for `node_modules` paths, so a mounted slot renders once and then never
35
+ * live-updates (anomalyco/opencode#33884). Rather than freeze a permanent
36
+ * "no data yet" panel, the entry detects that case, logs a single hint and
37
+ * registers no slot. Install via `file://` from a path outside any
38
+ * `node_modules` for the live sidebar (see README → "Live sidebar").
28
39
  */
29
40
  import {Plugin, usePlugin} from "@opencode/plugin/tui"
30
41
  import {createSignal, Show} from "solid-js"
@@ -32,11 +43,25 @@ import type {ColorInput} from "@opentui/core"
32
43
  import {readFileSync} from "node:fs"
33
44
  import {tmpdir} from "node:os"
34
45
  import {join} from "node:path"
46
+ import {fileURLToPath} from "node:url"
35
47
 
36
48
  const STATE_FILE = join(tmpdir(), "opencode-context-indicator-state.json")
37
49
  const POLL_MS = 1000
38
50
  const LABEL_WIDTH = 12
39
51
 
52
+ // True when this module was loaded from an npm install, i.e. its own file path
53
+ // contains a `node_modules` segment (OpenCode's TUI loader skips the host Solid
54
+ // transform for such paths → slots mount once but never live-update; see the
55
+ // Graceful mode note above). Normalised to forward slashes for Windows paths.
56
+ function isNodeModulesInstall(): boolean {
57
+ try {
58
+ const selfPath = fileURLToPath(import.meta.url).replace(/\\/g, "/").toLowerCase()
59
+ return selfPath.includes("/node_modules/")
60
+ } catch {
61
+ return false
62
+ }
63
+ }
64
+
40
65
  type Categories = {
41
66
  user?: number
42
67
  assistant?: number
@@ -163,6 +188,19 @@ function Sidebar(props: { entry: () => StateEntry | null }) {
163
188
  export default Plugin.define({
164
189
  id: "context-indicator.tui",
165
190
  setup(context) {
191
+ // npm installs (path under node_modules) cannot get live slot updates on
192
+ // this OpenCode version. Stay inert and log a single hint instead of
193
+ // registering a slot that would freeze on "no data yet".
194
+ if (isNodeModulesInstall()) {
195
+ console.log(
196
+ "[context-indicator] TUI sidebar disabled: npm-installed plugins cannot get " +
197
+ "live updates on this OpenCode version (anomalyco/opencode#33884). " +
198
+ 'Install via file:// (see README → "Live sidebar") for the live sidebar. ' +
199
+ "Slash commands still work everywhere.",
200
+ )
201
+ return
202
+ }
203
+
166
204
  // Re-read the bridge file once per second and re-render reactively.
167
205
  const [tick, setTick] = createSignal(0)
168
206
  let currentSession: string | undefined