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.
- package/README.md +74 -8
- package/index.js +121 -9
- package/package.json +4 -6
- 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
|
|
279
|
-
|
|
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,
|
|
307
|
-
|
|
308
|
-
|
|
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
|
|
326
|
-
|
|
327
|
-
|
|
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
|
|
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
|
-
|
|
513
|
-
|
|
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
|
-
|
|
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)
|
|
1968
|
-
|
|
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
|
-
|
|
1971
|
-
|
|
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.
|
|
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
|
-
*
|
|
27
|
-
* @opentui/solid
|
|
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
|