opencode-context-indicator 1.1.2 → 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 +67 -0
- package/index.js +210 -10
- package/package.json +17 -4
- package/tui.tsx +3 -1
package/README.md
CHANGED
|
@@ -196,6 +196,13 @@ back to a plain notice if needed.
|
|
|
196
196
|
with no category data (no cache/snapshot and the fallback failed or ran out of
|
|
197
197
|
budget) is tagged `no-data`; `ctx` / `model` / `updatedAt` still come from
|
|
198
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.
|
|
199
206
|
* Both commands append their payload to `context-breakdown.log` (final-summaries
|
|
200
207
|
section), so it stays recoverable even if delivery fails.
|
|
201
208
|
* If `ctx.session.synthetic` throws, the command logs an actionable error and the
|
|
@@ -438,6 +445,66 @@ caller.
|
|
|
438
445
|
|
|
439
446
|
---
|
|
440
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
|
+
|
|
441
508
|
## License
|
|
442
509
|
|
|
443
510
|
[MIT](./LICENSE)
|
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
|
|
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
|
|
@@ -194,7 +195,13 @@ const LOG_FILE = join(tmpdir(), "context-breakdown.log");
|
|
|
194
195
|
// Machine-readable snapshot consumed by the optional TUI sidebar (./tui.tsx,
|
|
195
196
|
// package entry "./tui"). Written next to each log update — same moment, same
|
|
196
197
|
// data. See writeStateFile() below.
|
|
197
|
-
|
|
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");
|
|
198
205
|
|
|
199
206
|
// Separate tap log for raw events (written only when DEBUG_EVENTS is true).
|
|
200
207
|
const EVENT_LOG_FILE = join(tmpdir(), "context-events.log");
|
|
@@ -278,6 +285,7 @@ let providersInFlight = null;
|
|
|
278
285
|
// absolute tokens (as in V1).
|
|
279
286
|
let v2LimitsLoadedAt = 0;
|
|
280
287
|
let v2LimitsFailAt = 0;
|
|
288
|
+
let v2LimitsFailCount = 0; // consecutive soft-failures -> exponential retry backoff
|
|
281
289
|
let v2LimitsInFlight = null;
|
|
282
290
|
|
|
283
291
|
// Bounded list of final summaries written on session.idle
|
|
@@ -472,6 +480,14 @@ function writeStateFile(
|
|
|
472
480
|
const prevEntry = all[sessionID] || null;
|
|
473
481
|
const pc = all[sessionID]?.categories || null;
|
|
474
482
|
|
|
483
|
+
// Same model AND provider? Only then is a previously persisted denominator
|
|
484
|
+
// meaningful — a session that switched model (or provider, even with the
|
|
485
|
+
// same modelID) must not inherit the old window.
|
|
486
|
+
const sameModelAsPrev =
|
|
487
|
+
prevEntry != null &&
|
|
488
|
+
prevEntry.model === (modelID || "unknown") &&
|
|
489
|
+
(prevEntry.providerID || "") === (providerID || "");
|
|
490
|
+
|
|
475
491
|
const catUser = bd ? bd.userChars || 0 : pc?.user ?? 0;
|
|
476
492
|
const catAssistant = bd ? bd.assistantChars || 0 : pc?.assistant ?? 0;
|
|
477
493
|
const catReasoning = bd ? bd.reasoningChars || 0 : pc?.reasoning ?? 0;
|
|
@@ -504,13 +520,23 @@ function writeStateFile(
|
|
|
504
520
|
sessionID,
|
|
505
521
|
parentID,
|
|
506
522
|
role: parentID ? "sub" : "main",
|
|
507
|
-
agent: sessionAgentCache.get(sessionID) || null,
|
|
523
|
+
agent: sessionAgentCache.get(sessionID) || prevEntry?.agent || null,
|
|
508
524
|
model: modelID || "unknown",
|
|
509
525
|
providerID: providerID || "",
|
|
510
526
|
ctx: total,
|
|
511
527
|
input: inputTokens || 0,
|
|
512
|
-
|
|
513
|
-
|
|
528
|
+
// Do NOT clobber a known denominator with null: multiple plugin instances
|
|
529
|
+
// share this file and one of them may lack modelLimits (its ctx.model.list
|
|
530
|
+
// returned nothing). Keep the previous model's known value when the live
|
|
531
|
+
// lookup came back empty, so a transient miss never becomes permanent.
|
|
532
|
+
usable:
|
|
533
|
+
denom != null ? denom : sameModelAsPrev ? prevEntry.usable ?? null : null,
|
|
534
|
+
limit:
|
|
535
|
+
limit != null
|
|
536
|
+
? limit
|
|
537
|
+
: sameModelAsPrev && typeof prevEntry.limit === "number"
|
|
538
|
+
? prevEntry.limit
|
|
539
|
+
: null,
|
|
514
540
|
reasoning: typeof reasoningTokens === "number" ? reasoningTokens : 0,
|
|
515
541
|
categories: {
|
|
516
542
|
user: catUser,
|
|
@@ -1916,7 +1942,14 @@ async function ensureV2Breakdown(ctx, sessionID) {
|
|
|
1916
1942
|
async function ensureModelLimits(ctx) {
|
|
1917
1943
|
const now = Date.now();
|
|
1918
1944
|
if (now - v2LimitsLoadedAt < PROVIDERS_TTL_MS) return; // fresh success
|
|
1919
|
-
|
|
1945
|
+
// Recent failure: back off exponentially (30s, 60s, 120s … capped at 5 min)
|
|
1946
|
+
// so a registry that is slow to expose the provider is retried without
|
|
1947
|
+
// hammering it, while a one-off blip still recovers quickly.
|
|
1948
|
+
const failBackoff = Math.min(
|
|
1949
|
+
PROVIDERS_FAIL_TTL_MS * Math.pow(2, Math.max(0, v2LimitsFailCount - 1)),
|
|
1950
|
+
V2_LIMITS_MAX_BACKOFF_MS,
|
|
1951
|
+
);
|
|
1952
|
+
if (now - v2LimitsFailAt < failBackoff) return; // recent failure
|
|
1920
1953
|
if (v2LimitsInFlight) return v2LimitsInFlight; // dedup parallel callers
|
|
1921
1954
|
v2LimitsInFlight = (async () => {
|
|
1922
1955
|
try {
|
|
@@ -1927,7 +1960,13 @@ async function ensureModelLimits(ctx) {
|
|
|
1927
1960
|
);
|
|
1928
1961
|
const models = Array.isArray(res) ? res : res?.data;
|
|
1929
1962
|
if (!Array.isArray(models)) {
|
|
1963
|
+
v2LimitsFailCount++;
|
|
1930
1964
|
v2LimitsFailAt = Date.now(); // malformed payload -> retry soon
|
|
1965
|
+
const seeded = hydrateModelLimitsFromState();
|
|
1966
|
+
logErr(
|
|
1967
|
+
"model.list returned no model array — limits unavailable, will retry",
|
|
1968
|
+
{ payloadType: typeof res, hydratedFromState: seeded },
|
|
1969
|
+
);
|
|
1931
1970
|
return;
|
|
1932
1971
|
}
|
|
1933
1972
|
let found = 0;
|
|
@@ -1964,11 +2003,36 @@ async function ensureModelLimits(ctx) {
|
|
|
1964
2003
|
// An empty / limit-less payload right after boot (registry not ready yet)
|
|
1965
2004
|
// must NOT be cached as success, or limits would stay unknown for the
|
|
1966
2005
|
// full TTL — treat it as a soft failure and retry after the fail gate.
|
|
1967
|
-
if (found > 0)
|
|
1968
|
-
|
|
2006
|
+
if (found > 0) {
|
|
2007
|
+
v2LimitsLoadedAt = Date.now();
|
|
2008
|
+
v2LimitsFailCount = 0; // recovered
|
|
2009
|
+
} else {
|
|
2010
|
+
// Soft failure: the registry answered but carried no usable limit for
|
|
2011
|
+
// any model (provider not registered yet, or `limit` absent). Log WHY
|
|
2012
|
+
// once per backoff window, and fall back to the shared snapshot so
|
|
2013
|
+
// denominators do not vanish meanwhile.
|
|
2014
|
+
const withLimit = models.filter(
|
|
2015
|
+
(m) => m?.limit && typeof m.limit === "object",
|
|
2016
|
+
).length;
|
|
2017
|
+
const withProvider = models.filter(
|
|
2018
|
+
(m) => m?.providerID || m?.provider?.id,
|
|
2019
|
+
).length;
|
|
2020
|
+
const seeded = hydrateModelLimitsFromState();
|
|
2021
|
+
v2LimitsFailCount++;
|
|
2022
|
+
v2LimitsFailAt = Date.now();
|
|
2023
|
+
logErr(
|
|
2024
|
+
"model.list yielded 0 usable limits — retrying",
|
|
2025
|
+
{ models: models.length, withLimit, withProvider, hydratedFromState: seeded },
|
|
2026
|
+
);
|
|
2027
|
+
}
|
|
1969
2028
|
} catch (err) {
|
|
1970
|
-
|
|
1971
|
-
|
|
2029
|
+
v2LimitsFailCount++;
|
|
2030
|
+
v2LimitsFailAt = Date.now(); // failed attempt -> backoff gate
|
|
2031
|
+
const seeded = hydrateModelLimitsFromState();
|
|
2032
|
+
logErr(
|
|
2033
|
+
`model.list failed (hydrated ${seeded} limit(s) from state.json)`,
|
|
2034
|
+
err,
|
|
2035
|
+
);
|
|
1972
2036
|
} finally {
|
|
1973
2037
|
v2LimitsInFlight = null;
|
|
1974
2038
|
}
|
|
@@ -2377,6 +2441,115 @@ function readStateSnapshot() {
|
|
|
2377
2441
|
}
|
|
2378
2442
|
}
|
|
2379
2443
|
|
|
2444
|
+
// Seed `modelLimits` from previously persisted state.json entries. state.json is
|
|
2445
|
+
// a SHARED cross-instance snapshot rewritten at every live step, so it carries a
|
|
2446
|
+
// model's window even when THIS instance's ctx.model.list() came back empty (a
|
|
2447
|
+
// cold instance, or one whose model registry did not expose the provider).
|
|
2448
|
+
// Without this, a transient list miss degraded every denominator to "?" — and
|
|
2449
|
+
// because writeStateFile used to rewrite usable/limit from the (empty) in-memory
|
|
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.
|
|
2462
|
+
function hydrateModelLimitsFromState() {
|
|
2463
|
+
try {
|
|
2464
|
+
const all = readStateSnapshot();
|
|
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 } ]
|
|
2468
|
+
for (const entry of Object.values(all)) {
|
|
2469
|
+
const providerID =
|
|
2470
|
+
typeof entry?.providerID === "string" ? entry.providerID : "";
|
|
2471
|
+
const modelID = entry?.model;
|
|
2472
|
+
const key = modelKey(providerID, modelID);
|
|
2473
|
+
if (!key || !modelID || modelID === "unknown") continue;
|
|
2474
|
+
if (modelLimits.has(key)) continue; // a real in-memory record always wins
|
|
2475
|
+
const usable =
|
|
2476
|
+
typeof entry?.usable === "number" && entry.usable > 0
|
|
2477
|
+
? entry.usable
|
|
2478
|
+
: 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.
|
|
2531
|
+
let rec = 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 };
|
|
2536
|
+
}
|
|
2537
|
+
if (rec == null) continue;
|
|
2538
|
+
modelLimits.set(key, rec);
|
|
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
|
+
);
|
|
2546
|
+
}
|
|
2547
|
+
return added;
|
|
2548
|
+
} catch {
|
|
2549
|
+
return 0;
|
|
2550
|
+
}
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2380
2553
|
// True when a state.json entry carries no measured tokens at all: a session
|
|
2381
2554
|
// registered (session.created) but with no served step. `writeStateFile` always
|
|
2382
2555
|
// persists `categories` as an object, so the emptiness test must SUM those
|
|
@@ -2767,6 +2940,29 @@ async function commandContextBreakdown(ctx, invocation, opts) {
|
|
|
2767
2940
|
}
|
|
2768
2941
|
}
|
|
2769
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
|
+
|
|
2770
2966
|
// ---------------------------------------------------------------------------
|
|
2771
2967
|
// Entrypoints
|
|
2772
2968
|
// ---------------------------------------------------------------------------
|
|
@@ -2796,6 +2992,10 @@ export default {
|
|
|
2796
2992
|
// to the generic 200k default), so the transform is not used for limit
|
|
2797
2993
|
// population.
|
|
2798
2994
|
|
|
2995
|
+
// Seed limits from the shared snapshot FIRST: another instance may already
|
|
2996
|
+
// have resolved them, so a cold start never shows "?" denominators.
|
|
2997
|
+
hydrateModelLimitsFromState();
|
|
2998
|
+
|
|
2799
2999
|
// Fire-and-forget prefetch of effective model limits.
|
|
2800
3000
|
ensureModelLimits(ctx).catch((err) =>
|
|
2801
3001
|
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.
|
|
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
|
|