opencode-context-indicator 1.1.3 → 1.1.4

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 +60 -0
  2. package/index.js +111 -23
  3. package/package.json +17 -4
  4. package/tui.tsx +3 -1
package/README.md CHANGED
@@ -445,6 +445,66 @@ caller.
445
445
 
446
446
  ---
447
447
 
448
+ ## Development / Testing
449
+
450
+ ### Running tests
451
+
452
+ ```bash
453
+ npm run test:prepublish # fast gate: tarball + tui.tsx + resolve + T1–T12 unit
454
+ npm run test:e2e # full E2E: isolated plugin load + one "Say OK" round-trip
455
+ npm run test:e2e:quick # E2E load-only, no LLM call
456
+ ```
457
+
458
+ `prepublishOnly` runs **both** gates automatically on `npm publish`
459
+ (`node test/prepublish.mjs && node test/e2e-tui.mjs --full`) and is skipped with
460
+ `--ignore-scripts`. The whole chain takes roughly **1 minute** (fast gate ~20 s,
461
+ isolated E2E ~10 s warm / up to ~90 s cold); the E2E is bounded by INIT 45 s +
462
+ STEP 80 s, ~3 min total.
463
+
464
+ ### What the gates check
465
+
466
+ **Fast gate — `test/prepublish.mjs`**
467
+
468
+ | Step | What | Why |
469
+ |---|---|---|
470
+ | (a) tarball composition | `package.json` `files[]` field expands to exactly 6 members | no test/node_modules/tsconfig in the tarball |
471
+ | (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 |
472
+ | (c) resolve chain | replica install → `import.meta.resolve` for all tui.tsx imports | broken peer/optional dep silently breaks the sidebar |
473
+ | (d) unit harness T1–T12 | all limit/agent/state fix assertions | regressions in modelLimits, writeStateFile, hydrate |
474
+
475
+ **Isolated E2E — `test/e2e-tui.mjs`** (requires the OpenCode CLI)
476
+
477
+ | Check | What | Why |
478
+ |---|---|---|
479
+ | A — plugin load | spins a private `opencode --standalone` PTY, asserts `msg="loading plugin" → opencode-context-indicator` and that the `file:///` entrypoint is **this checkout** | catches a plugin that no longer imports cleanly, or a config pointing at the published npm copy |
480
+ | B — live update | one `opencode run "Say OK" --standalone` round-trip; asserts the LLM answered **and** the isolated `state.json` gained a session with `ctx > 0` | catches a regression in the `session.idle` → `writeStateFile` path |
481
+
482
+ The E2E is fully **non-invasive** to your running OpenCode Desktop:
483
+
484
+ - `--standalone` starts a *private* server instead of the background service Desktop uses;
485
+ - `OPENCODE_DB` points at a throwaway DB in `%TEMP%` (your sessions are untouched);
486
+ - `OPENCODE_CONTEXT_INDICATOR_STATE_FILE` points at a throwaway state file in `%TEMP%`
487
+ — the live `opencode-context-indicator-state.json` is never read or written;
488
+ - `OPENCODE_DISABLE_PROJECT_CONFIG=1` avoids project-level config;
489
+ - exactly **one** minimal LLM call (`Say OK`) is made, in the isolated session.
490
+
491
+ The script prints `[e2e] running against ISOLATED opencode instance — your running
492
+ sessions are not touched` at start and reports the Desktop service/GUI PIDs before
493
+ and after the run. Override the binary with `OPENCODE_CLI`. The full run needs a
494
+ model for the one round-trip: set `OPENCODE_E2E_MODEL` to *your* `provider/model`
495
+ id (e.g. `OPENCODE_E2E_MODEL=myprovider/some-model`); it is **required** in full
496
+ mode and the script exits with an instruction if it is unset. `--quick` needs no
497
+ model.
498
+
499
+ ### CI
500
+
501
+ `.github/workflows/ci.yml` runs `test/prepublish.mjs` on every push/PR and additionally
502
+ does a standalone esbuild transpile check (pragmas, no React). The Node-pty E2E is not
503
+ run in CI (requires the OpenCode Desktop binary) and is skipped gracefully when the CLI
504
+ is absent (`[SKIP] opencode CLI not found`).
505
+
506
+ ---
507
+
448
508
  ## License
449
509
 
450
510
  [MIT](./LICENSE)
package/index.js CHANGED
@@ -195,7 +195,13 @@ const LOG_FILE = join(tmpdir(), "context-breakdown.log");
195
195
  // Machine-readable snapshot consumed by the optional TUI sidebar (./tui.tsx,
196
196
  // package entry "./tui"). Written next to each log update — same moment, same
197
197
  // data. See writeStateFile() below.
198
- const STATE_FILE = join(tmpdir(), "opencode-context-indicator-state.json");
198
+ // State file path — overridable via env for test isolation.
199
+ // In production the env is never set, so behaviour is unchanged.
200
+ // In tests: export OPENCODE_CONTEXT_INDICATOR_STATE_FILE=/tmp/test-state.json
201
+ // and the harness writes a controlled seed; no live file is touched.
202
+ const STATE_FILE =
203
+ process.env.OPENCODE_CONTEXT_INDICATOR_STATE_FILE ||
204
+ join(tmpdir(), "opencode-context-indicator-state.json");
199
205
 
200
206
  // Separate tap log for raw events (written only when DEBUG_EVENTS is true).
201
207
  const EVENT_LOG_FILE = join(tmpdir(), "context-events.log");
@@ -2008,15 +2014,15 @@ async function ensureModelLimits(ctx) {
2008
2014
  const withLimit = models.filter(
2009
2015
  (m) => m?.limit && typeof m.limit === "object",
2010
2016
  ).length;
2011
- const lanit = models.filter(
2012
- (m) => m?.providerID === "lanit" || m?.provider?.id === "lanit",
2017
+ const withProvider = models.filter(
2018
+ (m) => m?.providerID || m?.provider?.id,
2013
2019
  ).length;
2014
2020
  const seeded = hydrateModelLimitsFromState();
2015
2021
  v2LimitsFailCount++;
2016
2022
  v2LimitsFailAt = Date.now();
2017
2023
  logErr(
2018
2024
  "model.list yielded 0 usable limits — retrying",
2019
- { models: models.length, withLimit, lanit, hydratedFromState: seeded },
2025
+ { models: models.length, withLimit, withProvider, hydratedFromState: seeded },
2020
2026
  );
2021
2027
  }
2022
2028
  } catch (err) {
@@ -2441,43 +2447,102 @@ function readStateSnapshot() {
2441
2447
  // cold instance, or one whose model registry did not expose the provider).
2442
2448
  // Without this, a transient list miss degraded every denominator to "?" — and
2443
2449
  // 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.
2450
+ // map, the degradation became permanent.
2451
+ //
2452
+ // The snapshot also holds history: during an earlier outage some instances wrote
2453
+ // a WRONG usable window (e.g. 168000 instead of 1015808). A naive
2454
+ // first-match seed would then re-inject that stale garbage. Therefore, per
2455
+ // `<provider>:<model>`:
2456
+ // * candidates are sanity-filtered (drop `usable >= limit`, `usable <= 0`);
2457
+ // * when any candidate carries a precise `usable`, only those are considered;
2458
+ // * the FRESHEST (max `updatedAt`) candidate wins;
2459
+ // * a divergent spread (max > 3× min usable) is logged for diagnosis.
2460
+ // Strictly additive: real in-memory records always win (never overwritten).
2461
+ // Returns the number of records seeded.
2446
2462
  function hydrateModelLimitsFromState() {
2447
2463
  try {
2448
2464
  const all = readStateSnapshot();
2449
- let added = 0;
2465
+ // Group candidate entries by key first, so "freshest" is decided against
2466
+ // ALL records of that model — not the first one encountered.
2467
+ const byKey = new Map(); // key -> [ { usable, limit, updatedAt } ]
2450
2468
  for (const entry of Object.values(all)) {
2451
2469
  const providerID =
2452
2470
  typeof entry?.providerID === "string" ? entry.providerID : "";
2453
2471
  const modelID = entry?.model;
2454
2472
  const key = modelKey(providerID, modelID);
2455
2473
  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.
2474
+ if (modelLimits.has(key)) continue; // a real in-memory record always wins
2464
2475
  const usable =
2465
2476
  typeof entry?.usable === "number" && entry.usable > 0
2466
2477
  ? entry.usable
2467
2478
  : null;
2468
- const ctxLimit =
2469
- typeof entry?.limit === "number" && entry.limit > 0
2470
- ? entry.limit
2471
- : null;
2479
+ const limit =
2480
+ typeof entry?.limit === "number" && entry.limit > 0 ? entry.limit : null;
2481
+ if (usable == null && limit == null) continue;
2482
+ let arr = byKey.get(key);
2483
+ if (!arr) {
2484
+ arr = [];
2485
+ byKey.set(key, arr);
2486
+ }
2487
+ arr.push({
2488
+ usable,
2489
+ limit,
2490
+ updatedAt: typeof entry?.updatedAt === "string" ? entry.updatedAt : "",
2491
+ });
2492
+ }
2493
+
2494
+ let added = 0;
2495
+ for (const [key, candidates] of byKey) {
2496
+ // (b) Sanity: drop impossible records (usable >= limit, usable <= 0).
2497
+ // Candidates without a usable (context-only) are kept as-is.
2498
+ const sane = candidates.filter(
2499
+ (c) =>
2500
+ c.usable == null ||
2501
+ (c.usable > 0 && (c.limit == null || c.usable <= c.limit)),
2502
+ );
2503
+ if (sane.length === 0) continue; // every record was impossible -> skip
2504
+
2505
+ // (a) Prefer the precise variant (a recorded usable) when any exists.
2506
+ const precise = sane.filter((c) => c.usable != null);
2507
+ const pool = precise.length > 0 ? precise : sane;
2508
+
2509
+ // (1) Freshest wins (ISO timestamps compare lexicographically).
2510
+ let best = pool[0];
2511
+ for (const c of pool) {
2512
+ if (String(c.updatedAt) > String(best.updatedAt)) best = c;
2513
+ }
2514
+
2515
+ // (c) Diagnostic: a >3× spread means the snapshot is polluted by a
2516
+ // limited/fallback instance — log the numbers once for this model.
2517
+ const usables = pool.map((c) => c.usable).filter((u) => u != null);
2518
+ if (usables.length > 1) {
2519
+ const max = Math.max(...usables);
2520
+ const min = Math.min(...usables);
2521
+ if (min > 0 && max / min > 3) {
2522
+ logErr(
2523
+ `limit seed spread for ${key} — using freshest`,
2524
+ { min, max, candidates: pool.length, chosen: best.usable ?? best.limit },
2525
+ );
2526
+ }
2527
+ }
2528
+
2529
+ // Build the record: precise usable-as-input when known (so getUsableContext
2530
+ // re-derives EXACTLY the persisted window), else the raw context window.
2472
2531
  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 };
2532
+ if (best.usable != null) {
2533
+ rec = { context: best.limit, input: best.usable, output: null, reserved: 0 };
2534
+ } else if (best.limit != null) {
2535
+ rec = { context: best.limit, input: null, output: null, reserved: null };
2477
2536
  }
2478
2537
  if (rec == null) continue;
2479
2538
  modelLimits.set(key, rec);
2480
2539
  added++;
2540
+ // (3) One seed line per model: chosen value + provenance.
2541
+ console.log(
2542
+ `[context-indicator] limit seed ${key} -> usable=${
2543
+ best.usable != null ? best.usable : best.limit
2544
+ } (from ${best.updatedAt || "unknown"}, ${pool.length} candidates)`,
2545
+ );
2481
2546
  }
2482
2547
  return added;
2483
2548
  } catch {
@@ -2875,6 +2940,29 @@ async function commandContextBreakdown(ctx, invocation, opts) {
2875
2940
  }
2876
2941
  }
2877
2942
 
2943
+ // ---------------------------------------------------------------------------
2944
+ // Named exports for the prepublish unit harness (test/prepublish.mjs).
2945
+ // These are module-level const variables that the harness needs to mutate
2946
+ // (modelLimits, sessionAgentCache, etc.) and functions it needs to call
2947
+ // directly (hydrateModelLimitsFromState, writeStateFile, etc.).
2948
+ // The plugin itself works without any of these — they are additive.
2949
+ // ---------------------------------------------------------------------------
2950
+ export {
2951
+ modelLimits,
2952
+ sessionAgentCache,
2953
+ sessionParentCache,
2954
+ writeStateFile,
2955
+ hydrateModelLimitsFromState,
2956
+ getUsableContext,
2957
+ getModelLimit,
2958
+ collectSessionReport,
2959
+ renderSessionRow,
2960
+ shortSummaryText,
2961
+ shortSummaryDescription,
2962
+ ensureModelLimits,
2963
+ readStateSnapshot,
2964
+ };
2965
+
2878
2966
  // ---------------------------------------------------------------------------
2879
2967
  // Entrypoints
2880
2968
  // ---------------------------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-context-indicator",
3
- "version": "1.1.3",
3
+ "version": "1.1.4",
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",
@@ -31,9 +31,14 @@
31
31
  "access": "public"
32
32
  },
33
33
  "author": "VladSmr",
34
- "repository": { "type": "git", "url": "git+https://github.com/VladSmr/opencode-context-indicator.git" },
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/VladSmr/opencode-context-indicator.git"
37
+ },
35
38
  "homepage": "https://github.com/VladSmr/opencode-context-indicator#readme",
36
- "bugs": { "url": "https://github.com/VladSmr/opencode-context-indicator/issues" },
39
+ "bugs": {
40
+ "url": "https://github.com/VladSmr/opencode-context-indicator/issues"
41
+ },
37
42
  "peerDependencies": {
38
43
  "@opencode/plugin": ">=2.0.16",
39
44
  "@opentui/core": ">=0.5.12",
@@ -56,6 +61,14 @@
56
61
  "devDependencies": {
57
62
  "@opencode/plugin": "^2.0.19",
58
63
  "@opentui/core": "^0.5.12",
64
+ "esbuild": "^0.28.2",
65
+ "node-pty": "^1.1.0",
59
66
  "solid-js": "^1.9.12"
60
- }
67
+ },
68
+ "scripts": {
69
+ "test:prepublish": "node test/prepublish.mjs",
70
+ "test:e2e": "node test/e2e-tui.mjs --full",
71
+ "test:e2e:quick": "node test/e2e-tui.mjs --quick"
72
+ },
73
+ "prepublishOnly": "node test/prepublish.mjs && node test/e2e-tui.mjs --full"
61
74
  }
package/tui.tsx CHANGED
@@ -45,7 +45,9 @@ import {tmpdir} from "node:os"
45
45
  import {join} from "node:path"
46
46
  import {fileURLToPath} from "node:url"
47
47
 
48
- const STATE_FILE = join(tmpdir(), "opencode-context-indicator-state.json")
48
+ const STATE_FILE =
49
+ process.env.OPENCODE_CONTEXT_INDICATOR_STATE_FILE ||
50
+ join(tmpdir(), "opencode-context-indicator-state.json")
49
51
  const POLL_MS = 1000
50
52
  const LABEL_WIDTH = 12
51
53