opencode-context-indicator 1.5.3 → 1.5.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/CHANGELOG.md +38 -0
- package/README.md +29 -17
- package/docs/sidebar.png +0 -0
- package/lib/cache.js +37 -3
- package/lib/state.js +21 -2
- package/lib/tui-panel.tsx +95 -3
- package/lib/v2.js +12 -1
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,44 @@ Notable user-facing changes per release. Dates are commit days (UTC).
|
|
|
4
4
|
The package also ships a dual V1/V2 plugin — see the README for the surface
|
|
5
5
|
matrix; everything below concerns the V2 path unless stated otherwise.
|
|
6
6
|
|
|
7
|
+
## 1.5.4 — 2026-10-27
|
|
8
|
+
|
|
9
|
+
Fix-and-hygiene release after a full-project review. No new commands.
|
|
10
|
+
|
|
11
|
+
- **Subagent roll-up no longer collapses on a child's idle.** `session.idle`
|
|
12
|
+
wiped the child's parent attribution and accumulated totals, so the very next
|
|
13
|
+
live line reported `subagents 0 session(s)` right after any subagent
|
|
14
|
+
finished, even though its parent was still running. V2 now keeps a finished
|
|
15
|
+
child's entries until the parent itself goes idle or is deleted
|
|
16
|
+
(`clearChildSubagentTracking`, breadth-first, so sub-subagents are collected
|
|
17
|
+
too); `/context-breakdown` and `/cx` were never affected (they read
|
|
18
|
+
`state.json`). T29 covers the lifetime.
|
|
19
|
+
- **TUI panel no longer re-measures every message once per second.** The
|
|
20
|
+
measured-contributors memo was keyed on the 1000 ms snapshot tick and the host
|
|
21
|
+
rebuilds the message array on every poll, so the full walk (per-character
|
|
22
|
+
token estimates plus `JSON.stringify` of tool args) ran on the render thread
|
|
23
|
+
every second. It is now keyed on a cheap signature (list length, last message
|
|
24
|
+
id, last message size including tool results and status), so a static session
|
|
25
|
+
costs almost nothing while streaming text and completing tool calls still
|
|
26
|
+
refresh.
|
|
27
|
+
- **A summary-only log write keeps the live snapshot.** `pushFinalSummary` used
|
|
28
|
+
to rewrite `context-breakdown.log` with an empty live section, so every idle /
|
|
29
|
+
compaction / command payload blanked it until the next measured write. The
|
|
30
|
+
module now remembers the last rendered snapshot and re-renders it.
|
|
31
|
+
- Doc fixes: the log is rewritten atomically rather than "appended" with
|
|
32
|
+
`appendFileSync`, and both its sections are process-local (survive rewrites,
|
|
33
|
+
not a restart); the `state.json` field list now includes `toolOutByTool`,
|
|
34
|
+
`reclaim`, `cacheRead`/`cacheWrite` and `compactedAt`/`compactStale`; "tested
|
|
35
|
+
against opencode" now says 2.1.4 (was a stale v2.0.28); the gate table now
|
|
36
|
+
states the actual scan scope.
|
|
37
|
+
- Packaging: `docs/` is published, so the README screenshot resolves on the npm
|
|
38
|
+
page. Gate (a) expands `docs/` the same way it expands `lib/` and rejects any
|
|
39
|
+
non-image file there.
|
|
40
|
+
- Tests: new `OPENCODE_CONTEXT_INDICATOR_LOG_FILE` override (test isolation -
|
|
41
|
+
idle/compacted handlers no longer rewrite the user's real
|
|
42
|
+
`context-breakdown.log`), T29 added (child / grand-child lifetime). 75 unit
|
|
43
|
+
checks.
|
|
44
|
+
|
|
7
45
|
## 1.5.3 — 2026-10-26
|
|
8
46
|
|
|
9
47
|
- **Compaction ceiling aligned with opencode 2.1.4 (#51271).** `lib/limits.js`
|
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ arguments, tool results (the outputs fed back into context) and the residual
|
|
|
13
13
|
The package ships a **dual V1 + V2 plugin** (one entry works in both OpenCode
|
|
14
14
|
generations) plus an optional TUI module (`./tui` entry) that renders a **live
|
|
15
15
|
sidebar** and a **zero-LLM `/cx` breakdown panel** with measured contributors
|
|
16
|
-
and a redacted export. The V2 path is **tested against opencode
|
|
16
|
+
and a redacted export. The V2 path is **tested against opencode 2.1.4**; the
|
|
17
17
|
V1 path against 1.18.29+.
|
|
18
18
|
|
|
19
19
|
> **V1 is feature-frozen.** It is kept working for compatibility (bug fixes
|
|
@@ -302,8 +302,9 @@ notice if needed.
|
|
|
302
302
|
are also seeded from the snapshot at startup, and the retry backs off
|
|
303
303
|
(30 s → 5 min, capped). A known `usable`/`limit`/`agent` is never overwritten
|
|
304
304
|
with `null` when another instance rewrites the snapshot.
|
|
305
|
-
* All three commands
|
|
306
|
-
section), so it stays recoverable even if
|
|
305
|
+
* All three commands copy their payload into `context-breakdown.log` (final-summaries
|
|
306
|
+
section) while the plugin instance lives, so it stays recoverable even if
|
|
307
|
+
delivery fails (see the log lifetime note under "Log / state file locations").
|
|
307
308
|
* If `ctx.session.synthetic` throws, the command logs an actionable error and the
|
|
308
309
|
payload is preserved in the log — there is no other delivery channel in this
|
|
309
310
|
runtime (no `noReply`/`prompt` option exists).
|
|
@@ -405,16 +406,24 @@ and `$TMPDIR` (usually `/tmp`) elsewhere:
|
|
|
405
406
|
| File | Purpose |
|
|
406
407
|
| --- | --- |
|
|
407
408
|
| `context-breakdown.log` | Human-readable live snapshot + bounded final summaries |
|
|
408
|
-
| `opencode-context-indicator-state.json` | Machine-readable snapshot consumed by the TUI sidebar and the `/cx` panel. Fields per entry: `sessionID`, `parentID`, `role`, `agent`, `model`, `providerID`, `ctx`, `input`, `usable` (compaction ceiling), `reserve` (tokens reserved for compaction), `limit` (context window), `reasoning`, `categories{user,assistant,reasoning,toolArgs,toolOut,system,toolSchemas,other}`, `top` (top-5 heaviest single items, path-free), `previous` (previous distinct measurement for `/context-diff`), `updatedAt`. |
|
|
409
|
+
| `opencode-context-indicator-state.json` | Machine-readable snapshot consumed by the TUI sidebar and the `/cx` panel. Fields per entry: `sessionID`, `parentID`, `role`, `agent`, `model`, `providerID`, `ctx`, `input`, `usable` (compaction ceiling), `reserve` (tokens reserved for compaction), `limit` (context window), `reasoning`, `categories{user,assistant,reasoning,toolArgs,toolOut,system,toolSchemas,other}`, `toolOutByTool` (per-tool repeated-output estimate), `reclaim` (duplicate tool output, bounded), `cacheRead`/`cacheWrite` (provider-reported prompt-cache tokens), `compactedAt`/`compactStale`, `top` (top-5 heaviest single items, path-free), `previous` (previous distinct measurement for `/context-diff`), `updatedAt`. |
|
|
409
410
|
| `context-events.log` | Raw event tap — **only** when `DEBUG_EVENTS` is flipped to `true` in the source (off by default) |
|
|
410
411
|
|
|
411
|
-
The
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
412
|
+
The whole `context-breakdown.log` is **rewritten atomically** (temp file +
|
|
413
|
+
rename, like the state file): the live snapshot is always regenerated and the
|
|
414
|
+
final summaries (on `session.idle`) plus compaction notes are re-rendered from
|
|
415
|
+
an in-memory buffer (bounded, last 50 per log), so the file never grows and
|
|
416
|
+
concurrent plugin instances cannot observe a half-written file. A summary-only
|
|
417
|
+
write (idle, compaction, command payload) re-renders the last live snapshot, so
|
|
418
|
+
adding a note never blanks that section. That snapshot may predate a compaction
|
|
419
|
+
or a deleted session - the leading `[<timestamp>]` line is the freshness cue.
|
|
420
|
+
Both parts are **process-local**: they
|
|
421
|
+
survive every rewrite and failed command delivery within the lifetime of the
|
|
422
|
+
running instance, but they are dropped when the plugin instance is re-created
|
|
423
|
+
(opencode restarts, or about 24 h of idling). A different instance that rewrites
|
|
424
|
+
the file keeps its own view of the summary buffer; cross-instance duplicate
|
|
425
|
+
summaries are prevented separately by an atomic claim marker (see
|
|
426
|
+
`lib/dedup.js`).
|
|
418
427
|
|
|
419
428
|
---
|
|
420
429
|
|
|
@@ -617,7 +626,7 @@ caller.
|
|
|
617
626
|
### Running tests
|
|
618
627
|
|
|
619
628
|
```bash
|
|
620
|
-
npm run test:prepublish # fast gate: tarball + tui.tsx + resolve + T1-
|
|
629
|
+
npm run test:prepublish # fast gate: tarball + tui.tsx + resolve + T1-T29 unit checks
|
|
621
630
|
npm run test:e2e # full E2E: isolated plugin load + one "Say OK" round-trip
|
|
622
631
|
npm run test:e2e:quick # E2E load-only, no LLM call
|
|
623
632
|
npm run test:drift # advisory: warns on non-breaking host drift (also in CI)
|
|
@@ -636,12 +645,12 @@ STEP 80 s, ~3 min total.
|
|
|
636
645
|
|
|
637
646
|
| Step | What | Why |
|
|
638
647
|
|---|---|---|
|
|
639
|
-
| (a) tarball composition | `package.json` `files[]` expands to the base files plus every `lib/*` module (count asserted dynamically) | no test/node_modules/tsconfig in the tarball |
|
|
648
|
+
| (a) tarball composition | `package.json` `files[]` expands to the base files plus every `lib/*` module (count asserted dynamically); `docs/` may contain images only | no test/node_modules/tsconfig in the tarball |
|
|
640
649
|
| (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 |
|
|
641
650
|
| (c) resolve chain | replica install: host-bridged + peer packages resolve; local tui.tsx relative imports resolve via node from the entry (resolve-bridge.cjs) | broken peer/optional dep or a bad local import silently breaks the sidebar |
|
|
642
|
-
| (d) unit harness T1-
|
|
643
|
-
| (e) corporate-identifier scan | no forbidden identifiers in the published surface
|
|
644
|
-
| (f) non-English scan | no non-ASCII letters (`\p{L}` above U+007F) in
|
|
651
|
+
| (d) unit harness T1-T29 | limit/agent/state fix assertions plus measureContributors, redacted-export, shared category-helper, sanitizer/unicode, prompt-cache, waste-detector, compaction-reserve, top-consumer, diff and subagent-rollup checks (75 total) | regressions in modelLimits, writeStateFile, hydrate, contributors, export, subagent lifetime |
|
|
652
|
+
| (e) corporate-identifier scan | no forbidden identifiers in the published surface (`index.js`, `tui.tsx`, `lib/*`, `README.md`, `CHANGELOG.md`, `package.json`) or the test tree | compliance |
|
|
653
|
+
| (f) non-English scan | no non-ASCII letters (`\p{L}` above U+007F) in the same published surface and test tree (`docs/` is image-only and is not text-scanned) | English-only source rule |
|
|
645
654
|
|
|
646
655
|
**Isolated E2E — `test/e2e-tui.mjs`** (requires the OpenCode CLI)
|
|
647
656
|
|
|
@@ -655,7 +664,10 @@ The E2E is fully **non-invasive** to your running OpenCode Desktop:
|
|
|
655
664
|
- `--standalone` starts a *private* server instead of the background service Desktop uses;
|
|
656
665
|
- `OPENCODE_DB` points at a throwaway DB in `%TEMP%` (your sessions are untouched);
|
|
657
666
|
- `OPENCODE_CONTEXT_INDICATOR_STATE_FILE` points at a throwaway state file in `%TEMP%`
|
|
658
|
-
|
|
667
|
+
- the live `opencode-context-indicator-state.json` is never read or written;
|
|
668
|
+
- `OPENCODE_CONTEXT_INDICATOR_LOG_FILE` does the same for the human-readable
|
|
669
|
+
`context-breakdown.log` (used by the unit gate, which drives idle/compacted
|
|
670
|
+
handlers);
|
|
659
671
|
- `OPENCODE_CONFIG_DIR` points at a throwaway global config written by the script
|
|
660
672
|
(plugins: this checkout; `update: "disable"`) **for Check A only** — the load
|
|
661
673
|
source is deterministic and fail-closed instead of following your live config;
|
package/docs/sidebar.png
ADDED
|
Binary file
|
package/lib/cache.js
CHANGED
|
@@ -120,9 +120,15 @@ function lastKnownCacheWrite(sessionID) {
|
|
|
120
120
|
// Clear per-session tracking. Full wipe on session.idle / session.deleted;
|
|
121
121
|
// on session.compacted (keepToastDedup=true) the toast-dedup entries survive
|
|
122
122
|
// the cleanup so consecutive compacted events still dedup.
|
|
123
|
+
//
|
|
124
|
+
// `keepSubagentRollup` preserves THIS session's parent attribution and token
|
|
125
|
+
// totals. A finished subagent goes idle while its parent is still running; the
|
|
126
|
+
// parent's live roll-up must keep counting that child, so the V2 idle handler
|
|
127
|
+
// passes `true` for children and lets the parent's own idle/deleted wipe them
|
|
128
|
+
// (see clearChildSubagentTracking).
|
|
123
129
|
// ---------------------------------------------------------------------------
|
|
124
130
|
|
|
125
|
-
function clearSessionTracking(sessionID, keepToastDedup = false) {
|
|
131
|
+
function clearSessionTracking(sessionID, keepToastDedup = false, keepSubagentRollup = false) {
|
|
126
132
|
if (!sessionID) return;
|
|
127
133
|
lastShown.delete(sessionID);
|
|
128
134
|
breakdownCache.delete(sessionID);
|
|
@@ -134,7 +140,7 @@ function clearSessionTracking(sessionID, keepToastDedup = false) {
|
|
|
134
140
|
lastCompactedToast.delete(sessionID);
|
|
135
141
|
}
|
|
136
142
|
// Attribution cache (re-resolved on the next toast if needed).
|
|
137
|
-
sessionParentCache.delete(sessionID);
|
|
143
|
+
if (!keepSubagentRollup) sessionParentCache.delete(sessionID);
|
|
138
144
|
// Idle-summary caches were previously left dirty — clean them too.
|
|
139
145
|
lastKnownModelCache.delete(sessionID);
|
|
140
146
|
lastKnownProviderCache.delete(sessionID);
|
|
@@ -145,7 +151,7 @@ function clearSessionTracking(sessionID, keepToastDedup = false) {
|
|
|
145
151
|
lastKnownCacheWriteCache.delete(sessionID);
|
|
146
152
|
lastBdWrite.delete(sessionID);
|
|
147
153
|
// V2 subagent accumulation for this session.
|
|
148
|
-
sessionTotals.delete(sessionID);
|
|
154
|
+
if (!keepSubagentRollup) sessionTotals.delete(sessionID);
|
|
149
155
|
// V2 agent label + context-fallback throttle (previously left dirty).
|
|
150
156
|
sessionAgentCache.delete(sessionID);
|
|
151
157
|
v2ContextFetchTimes.delete(sessionID);
|
|
@@ -155,6 +161,33 @@ function clearSessionTracking(sessionID, keepToastDedup = false) {
|
|
|
155
161
|
lastCompactionHookAt.delete(sessionID);
|
|
156
162
|
}
|
|
157
163
|
|
|
164
|
+
// Drop the roll-up entries of every descendant of `rootID` (attribution plus
|
|
165
|
+
// accumulated totals). Called when a session itself goes idle or is deleted:
|
|
166
|
+
// until then, finished children stay in the cache so the live roll-up keeps
|
|
167
|
+
// reporting them instead of collapsing to "0 session(s)" the moment a child
|
|
168
|
+
// finishes. Breadth-first, because a sub-subagent hangs off a child and a
|
|
169
|
+
// single-level sweep would leave it as a permanent orphan. Deletion during Map
|
|
170
|
+
// iteration is well-defined in JS.
|
|
171
|
+
function clearChildSubagentTracking(rootID) {
|
|
172
|
+
if (!rootID) return;
|
|
173
|
+
const queue = [rootID];
|
|
174
|
+
const seen = new Set(queue);
|
|
175
|
+
while (queue.length > 0) {
|
|
176
|
+
const parent = queue.shift();
|
|
177
|
+
for (const [childID, parentID] of sessionParentCache) {
|
|
178
|
+
if (parentID !== parent) continue;
|
|
179
|
+
// Full per-session wipe, not just the two roll-up maps: the child's
|
|
180
|
+
// lastKnown*/breakdown/tool-schema caches would otherwise linger until
|
|
181
|
+
// FIFO eviction and never be updated again.
|
|
182
|
+
clearSessionTracking(childID);
|
|
183
|
+
if (!seen.has(childID)) {
|
|
184
|
+
seen.add(childID);
|
|
185
|
+
queue.push(childID);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
158
191
|
export {
|
|
159
192
|
MAX_TRACKED_SESSIONS,
|
|
160
193
|
BREAKDOWN_THROTTLE_MS,
|
|
@@ -190,4 +223,5 @@ export {
|
|
|
190
223
|
lastKnownCacheRead,
|
|
191
224
|
lastKnownCacheWrite,
|
|
192
225
|
clearSessionTracking,
|
|
226
|
+
clearChildSubagentTracking,
|
|
193
227
|
};
|
package/lib/state.js
CHANGED
|
@@ -52,7 +52,11 @@ const MAX_FINAL_SUMMARIES = 50; // bound the in-memory final-summary log
|
|
|
52
52
|
const DEBUG_EVENTS = false;
|
|
53
53
|
const EVENT_LOG_MAX_BYTES = 1024 * 1024; // ~1MB truncate threshold
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
// Human-readable live log. Overridable via env for test isolation (same
|
|
56
|
+
// contract as STATE_FILE); in production the env is never set.
|
|
57
|
+
const LOG_FILE =
|
|
58
|
+
process.env.OPENCODE_CONTEXT_INDICATOR_LOG_FILE ||
|
|
59
|
+
join(tmpdir(), "context-breakdown.log");
|
|
56
60
|
|
|
57
61
|
// Machine-readable snapshot consumed by the optional TUI sidebar (./tui.tsx,
|
|
58
62
|
// package entry "./tui"). Written next to each log update — same moment, same
|
|
@@ -149,12 +153,27 @@ function renderFile(liveLines) {
|
|
|
149
153
|
// Atomically rewrite the whole log file (bounded by design). The temp+rename
|
|
150
154
|
// makes concurrent writers from multiple plugin instances safe: a reader sees
|
|
151
155
|
// either the previous or the new file, never a half-written one.
|
|
156
|
+
//
|
|
157
|
+
// The last rendered live snapshot is remembered: callers that only add a final
|
|
158
|
+
// summary / compaction note pass no live lines, and an empty snapshot section
|
|
159
|
+
// after every idle would make the log useless.
|
|
160
|
+
let lastLiveLines = [];
|
|
161
|
+
|
|
152
162
|
function writeLog(liveLines) {
|
|
153
163
|
try {
|
|
154
|
-
|
|
164
|
+
if (Array.isArray(liveLines) && liveLines.length > 0) {
|
|
165
|
+
lastLiveLines = liveLines;
|
|
166
|
+
}
|
|
167
|
+
const content = renderFile(
|
|
168
|
+
Array.isArray(liveLines) && liveLines.length > 0 ? liveLines : lastLiveLines,
|
|
169
|
+
);
|
|
155
170
|
// Random temp suffix + exclusive create ("wx"): a predictable name could be
|
|
156
171
|
// pre-created as a symlink for us to follow (CWE-377). "wx" fails instead.
|
|
157
172
|
const tmp = `${LOG_FILE}.${randomBytes(6).toString("hex")}.tmp`;
|
|
173
|
+
// Unlike writeStateFile there is no ensureRegularFile(LOG_FILE) guard: the
|
|
174
|
+
// destination is only ever replaced by rename(2), which swaps the directory
|
|
175
|
+
// entry and never follows a symlink, so a pre-planted link cannot redirect a
|
|
176
|
+
// write. The "wx" temp below already blocks the symlink-attack vector.
|
|
158
177
|
// Create BEFORE the finally-guarded section (mirrors writeStateFile): on an
|
|
159
178
|
// (astronomically unlikely) EEXIST collision with a peer's temp, THEIR file
|
|
160
179
|
// must not be cleaned up by us — the write fails and is logged instead.
|
package/lib/tui-panel.tsx
CHANGED
|
@@ -53,6 +53,74 @@ type PanelInput = {
|
|
|
53
53
|
// (never once per poll) when it is unavailable and hide the whole section.
|
|
54
54
|
let warnedMessagesUnavailable = false
|
|
55
55
|
|
|
56
|
+
// Cheap change detector for the live message list. The host hands back a fresh
|
|
57
|
+
// array on every poll, so identity cannot be used as a memo key, while walking
|
|
58
|
+
// the whole list (per-character estimates + JSON.stringify of tool args) once
|
|
59
|
+
// per 1000 ms tick is exactly the cost we want to avoid. Length + last message
|
|
60
|
+
// id + created stamp + last message payload size covers appends, streaming and
|
|
61
|
+
// tool completion; an in-place edit of an OLDER message lands one tick later,
|
|
62
|
+
// when the tail changes. Known blind spot by design: the size is a SUM, so a
|
|
63
|
+
// same-length substitution inside the last message is not detected.
|
|
64
|
+
function listSignature(list: unknown[]): string {
|
|
65
|
+
const n = list.length
|
|
66
|
+
if (n === 0) return "0"
|
|
67
|
+
const last: unknown = list[n - 1]
|
|
68
|
+
if (last == null || typeof last !== "object") return `${n}:?`
|
|
69
|
+
const rec = last as Record<string, unknown>
|
|
70
|
+
const id = typeof rec.id === "string" ? rec.id : ""
|
|
71
|
+
const time = rec.time
|
|
72
|
+
const created =
|
|
73
|
+
time != null && typeof time === "object"
|
|
74
|
+
? String((time as Record<string, unknown>).created ?? "")
|
|
75
|
+
: ""
|
|
76
|
+
let size = typeof rec.text === "string" ? rec.text.length : 0
|
|
77
|
+
const parts: unknown[] = Array.isArray(rec.content) ? rec.content : []
|
|
78
|
+
for (const raw of parts) {
|
|
79
|
+
if (raw == null || typeof raw !== "object") continue
|
|
80
|
+
const part = raw as Record<string, unknown>
|
|
81
|
+
if (typeof part.text === "string") {
|
|
82
|
+
size += part.text.length
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
const state = part.state
|
|
86
|
+
if (state == null || typeof state !== "object") continue
|
|
87
|
+
const st = state as Record<string, unknown>
|
|
88
|
+
if (typeof st.input === "string") size += st.input.length
|
|
89
|
+
else if (st.input != null) {
|
|
90
|
+
try {
|
|
91
|
+
size += JSON.stringify(st.input).length
|
|
92
|
+
} catch {
|
|
93
|
+
/* unstringifiable input contributes no size */
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// Tool RESULTS matter as much as args: measureContributors reads
|
|
97
|
+
// state.content, and a call that finishes between two ticks changes nothing
|
|
98
|
+
// else on the message. Without this the measured section would freeze for
|
|
99
|
+
// the whole remainder of the turn.
|
|
100
|
+
const content = st.content
|
|
101
|
+
if (typeof content === "string") {
|
|
102
|
+
size += content.length
|
|
103
|
+
} else if (Array.isArray(content)) {
|
|
104
|
+
for (const rawItem of content) {
|
|
105
|
+
if (rawItem == null || typeof rawItem !== "object") continue
|
|
106
|
+
const item = rawItem as Record<string, unknown>
|
|
107
|
+
if (typeof item.text === "string") {
|
|
108
|
+
size += item.text.length
|
|
109
|
+
} else {
|
|
110
|
+
// Non-text parts (e.g. file refs with long/base64 URIs) are not
|
|
111
|
+
// measured by measureContributors, so their marker is enough: never
|
|
112
|
+
// JSON.stringify a potentially megabyte-sized reference per tick.
|
|
113
|
+
size += 1
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// Status transitions (pending -> completed/error) are cheap and always
|
|
118
|
+
// accompany a visible change.
|
|
119
|
+
if (typeof st.status === "string") size += st.status.length
|
|
120
|
+
}
|
|
121
|
+
return `${n}:${id}:${created}:${size}`
|
|
122
|
+
}
|
|
123
|
+
|
|
56
124
|
// Panel column layout (monospace), derived from the host-provided panel width
|
|
57
125
|
// so cells never collide on narrow terminals. Gaps between columns keep a
|
|
58
126
|
// truncated cell visually separated from its neighbour.
|
|
@@ -253,14 +321,38 @@ export function BreakdownPanel(props: {
|
|
|
253
321
|
}
|
|
254
322
|
})
|
|
255
323
|
|
|
324
|
+
// Memo for the measured contributors section: the signature/key pair keeps
|
|
325
|
+
// the expensive walk out of the per-tick path (see listSignature).
|
|
326
|
+
let contributorKey: string | null = null
|
|
327
|
+
let contributorValue: Contributor[] = []
|
|
328
|
+
|
|
256
329
|
const contributors = createMemo<Contributor[]>(() => {
|
|
257
330
|
const m = messages()
|
|
258
|
-
if (!m.available)
|
|
331
|
+
if (!m.available) {
|
|
332
|
+
contributorKey = null
|
|
333
|
+
contributorValue = []
|
|
334
|
+
return contributorValue
|
|
335
|
+
}
|
|
336
|
+
// Re-measure ONLY when the message list actually changed. The host rebuilds
|
|
337
|
+
// the array (and `props.snapshot()` ticks) every second, so keying on array
|
|
338
|
+
// identity would re-walk every message — per-character token estimates plus
|
|
339
|
+
// JSON.stringify of tool args — once per tick on the render thread.
|
|
340
|
+
let key: string
|
|
341
|
+
try {
|
|
342
|
+
key = listSignature(m.list)
|
|
343
|
+
} catch {
|
|
344
|
+
key = ""
|
|
345
|
+
}
|
|
346
|
+
if (key && key === contributorKey) return contributorValue
|
|
259
347
|
try {
|
|
260
|
-
|
|
348
|
+
contributorValue = measureContributors(m.list)
|
|
349
|
+
contributorKey = key || contributorKey
|
|
261
350
|
} catch {
|
|
262
|
-
|
|
351
|
+
// Transient failure: leave the key unset so the next tick retries.
|
|
352
|
+
contributorValue = []
|
|
353
|
+
contributorKey = null
|
|
263
354
|
}
|
|
355
|
+
return contributorValue
|
|
264
356
|
})
|
|
265
357
|
|
|
266
358
|
// Redacted export (key `e`). No message text, no full session id, no tool
|
package/lib/v2.js
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
import {
|
|
25
25
|
BREAKDOWN_THROTTLE_MS,
|
|
26
26
|
breakdownCache,
|
|
27
|
+
clearChildSubagentTracking,
|
|
27
28
|
clearSessionTracking,
|
|
28
29
|
ERROR_TOAST_DEDUP_MS,
|
|
29
30
|
lastBdWrite,
|
|
@@ -833,6 +834,10 @@ async function handleV2Event(ctx, event) {
|
|
|
833
834
|
// stale entry lingers, via the phantom filter — a deleted session has no
|
|
834
835
|
// step). No dedicated in-memory index to clean up.
|
|
835
836
|
clearSessionTracking(sid);
|
|
837
|
+
// Whatever this session was, its own descendants must go too: a deleted
|
|
838
|
+
// parent takes the whole subtree, and a deleted child takes its own
|
|
839
|
+
// sub-subagents (they would otherwise never be swept).
|
|
840
|
+
clearChildSubagentTracking(sid);
|
|
836
841
|
return;
|
|
837
842
|
}
|
|
838
843
|
|
|
@@ -922,7 +927,13 @@ async function handleV2Event(ctx, event) {
|
|
|
922
927
|
} catch (err) {
|
|
923
928
|
logErr("v2 final summary on idle failed", err);
|
|
924
929
|
}
|
|
925
|
-
|
|
930
|
+
// Children keep their parent attribution + totals while the parent runs:
|
|
931
|
+
// otherwise the very next live line reports "subagents 0" right after a
|
|
932
|
+
// subagent finishes. A root idle sweeps the whole child set instead.
|
|
933
|
+
const parentID = sessionParentCache.get(sid);
|
|
934
|
+
const isSubagent = parentID != null;
|
|
935
|
+
clearSessionTracking(sid, false, isSubagent);
|
|
936
|
+
if (!isSubagent) clearChildSubagentTracking(sid);
|
|
926
937
|
return;
|
|
927
938
|
}
|
|
928
939
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-context-indicator",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.4",
|
|
4
4
|
"description": "Real-time context-window usage for OpenCode: per-category token breakdown, /context-diff, a zero-LLM /cx panel with per-session cost and a searchable family-session picker, and a TUI sidebar. Dual V1 + V2 plugin entry.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"index.js",
|
|
13
13
|
"lib/",
|
|
14
14
|
"tui.tsx",
|
|
15
|
+
"docs/",
|
|
15
16
|
"README.md",
|
|
16
17
|
"CHANGELOG.md",
|
|
17
18
|
"LICENSE"
|