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.
- package/README.md +60 -0
- package/index.js +111 -23
- package/package.json +17 -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
|
-
|
|
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
|
|
2012
|
-
(m) => m?.providerID
|
|
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,
|
|
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.
|
|
2445
|
-
//
|
|
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
|
-
|
|
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
|
|
2469
|
-
typeof entry?.limit === "number" && entry.limit > 0
|
|
2470
|
-
|
|
2471
|
-
|
|
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:
|
|
2475
|
-
} else if (
|
|
2476
|
-
rec = { context:
|
|
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
|
+
"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": {
|
|
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": {
|
|
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 =
|
|
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
|
|