opencode-context-indicator 1.4.0 → 1.4.1

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 CHANGED
@@ -4,6 +4,33 @@ 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.4.1 — 2026-10-14
8
+
9
+ Highlights: the `toolOut` category is now split **by tool name**, and a
10
+ `(stale — compacted)` marker tells you when the numbers predate a compaction.
11
+
12
+ - **Tool results by tool**: `/context-breakdown` (a `### Tool results by tool
13
+ (main)` section) and the `./cx` panel (`tool results by tool` line) split the
14
+ `toolOut` column by tool name (`webfetch`, `read`, `bash`, …) — top-N with a
15
+ `+N more` tail. Aggregated on both the hook and the fallback path from each
16
+ tool part's `name`; names are sanitised and the map is bounded (overflow folds
17
+ into `other tools`). Persisted as `toolOutByTool` in `state.json`.
18
+ - **Compaction staleness marker**: opencode's compaction fires no `context`
19
+ hook, so `/context` right after `/compact` used to show stale pre-compaction
20
+ numbers with no hint. Such an entry is now flagged (`compactStale`) and every
21
+ surface (sidebar, panel, `/context`, `/context-breakdown`) shows
22
+ `(stale — compacted)` until the next model request refreshes the breakdown.
23
+ - Legacy `state.json` entries (no new fields) behave exactly as before.
24
+ - Tests: T19/T19a (staleness mark lifecycle + surfaces), T20–T20c (per-tool
25
+ split on both paths, sanitisation/bounding, persistence + rendering).
26
+ - **Refactor (behaviour-preserving)**: extracted the shared category helpers
27
+ (`sumCategories` / `breakdownCategories`) into `lib/estimate.js`, removing
28
+ four hand-rolled copies of the residual-`other` computation (V2 live log,
29
+ state writer, command report, TUI panel); the TUI now imports the ONE token
30
+ estimator and per-tool split from `lib/estimate.js` instead of keeping byte
31
+ copies; dropped unused module exports; unified the per-tool map cap. New
32
+ guard test T21.
33
+
7
34
  ## 1.4.0 — 2026-10-13
8
35
 
9
36
  Highlights: a new **toolOut** category names what used to drown in `other` —
package/README.md CHANGED
@@ -49,9 +49,10 @@ For every served request the plugin estimates how the context window is split:
49
49
  * `other` is the residual (`input − sum of estimates`): what no category
50
50
  explains — images/media, unknown part types.
51
51
  * `toolOut` names the former blind spot: **tool results** fed back into the
52
- context (webfetch pages, file reads, subagent reports). The TUI panel's
53
- *largest contributors* section attributes them **per tool call**; the
54
- category only totals them.
52
+ context (webfetch pages, file reads, subagent reports). `/context-breakdown`
53
+ and the TUI panel split it **by tool name** (`webfetch`, `read`, `bash`, …);
54
+ the TUI panel's *largest contributors* section attributes them **per tool
55
+ call**; the category only totals them.
55
56
  * `ctx` is OpenCode's native overflow count: `tokens.total` when present, else
56
57
  `input + output + cache.read + cache.write`. Percentages are **not clamped**
57
58
  at 100% — exceeding the window stays visible.
@@ -160,6 +161,9 @@ reads, subagent reports fed back into context), sys=system prompt, schm=tool
160
161
  schemas, oth=residual input. src: live=context hook, snap=state.json,
161
162
  fallback=session.context (no system/schemas → n/a), no-data=categories
162
163
  unavailable._
164
+
165
+ ### Tool results by tool (main)
166
+ webfetch 242.1k · read 18.4k · bash 4.9k · +3 more
163
167
  ```
164
168
 
165
169
  It is delivered with `resume:true` **and** a verbatim instruction prefix, so the
@@ -524,6 +528,13 @@ duplicate final summaries are suppressed with an atomic claim marker
524
528
  (`lib/dedup.js`). Every file / estimate / subagent path is fault-tolerant: a
525
529
  failure is logged and never breaks the main event or toast path.
526
530
 
531
+ **Compaction staleness.** OpenCode's compaction runs a separate flow that fires
532
+ no `context` hook, so the numbers a `session.compacted` write re-persists are
533
+ the *pre-compaction* ones. The plugin flags such an entry
534
+ (`compactStale` in `state.json`) and every surface shows `(stale — compacted)`
535
+ until the next model request refreshes the breakdown — i.e. the data is exactly
536
+ as fresh as the last model request in the session.
537
+
527
538
  This plugin does not touch `opencode-token-monitor`
528
539
  (`token_stats` / `token_history` / `token_export` keep working unchanged).
529
540
 
@@ -542,7 +553,7 @@ caller.
542
553
  ### Running tests
543
554
 
544
555
  ```bash
545
- npm run test:prepublish # fast gate: tarball + tui.tsx + resolve + T1–T16i unit checks
556
+ npm run test:prepublish # fast gate: tarball + tui.tsx + resolve + T1–T22 unit checks
546
557
  npm run test:e2e # full E2E: isolated plugin load + one "Say OK" round-trip
547
558
  npm run test:e2e:quick # E2E load-only, no LLM call
548
559
  npm run test:drift # OpenTUI pin vs the latest RELEASED opencode (also in CI)
@@ -563,7 +574,7 @@ STEP 80 s, ~3 min total.
563
574
  | (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 |
564
575
  | (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 |
565
576
  | (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 |
566
- | (d) unit harness T1–T16i | limit/agent/state fix assertions plus measureContributors and redacted-export checks (34 total) | regressions in modelLimits, writeStateFile, hydrate, contributors, export |
577
+ | (d) unit harness T1–T22 | limit/agent/state fix assertions plus measureContributors, redacted-export, shared category-helper and sanitizer/unicode checks (51 total) | regressions in modelLimits, writeStateFile, hydrate, contributors, export |
567
578
  | (e) corporate-identifier scan | no forbidden identifiers in the published surface + test tree | compliance |
568
579
  | (f) non-English scan | no non-ASCII letters (`\p{L}` above U+007F) in any repo file | English-only source rule |
569
580
 
package/index.js CHANGED
@@ -151,12 +151,14 @@
151
151
 
152
152
  import { makeV1 } from "./lib/v1.js";
153
153
  import { handleV2Event, onV2Context, estimateBreakdownFromContext, estimateBreakdownFromClientMessages } from "./lib/v2.js";
154
+ import { breakdownCategories, estimateTokens, isSafeSessionKey, safeCell, safeToolOutByTool, sumCategories, toolOutEntries } from "./lib/estimate.js";
154
155
  import {
155
156
  commandContextBreakdown,
156
157
  commandContextSummary,
157
158
  CONTEXT_BREAKDOWN_RESUME,
158
159
  collectSessionReport,
159
160
  renderSessionRow,
161
+ renderBreakdownTable,
160
162
  shortSummaryText,
161
163
  shortSummaryDescription,
162
164
  } from "./lib/commands.js";
@@ -194,6 +196,7 @@ export {
194
196
  reserveFor,
195
197
  collectSessionReport,
196
198
  renderSessionRow,
199
+ renderBreakdownTable,
197
200
  shortSummaryText,
198
201
  shortSummaryDescription,
199
202
  ensureModelLimits,
@@ -201,6 +204,13 @@ export {
201
204
  estimateBreakdownFromContext,
202
205
  estimateBreakdownFromClientMessages,
203
206
  onV2Context,
207
+ safeToolOutByTool,
208
+ toolOutEntries,
209
+ sumCategories,
210
+ breakdownCategories,
211
+ safeCell,
212
+ isSafeSessionKey,
213
+ estimateTokens,
204
214
  };
205
215
 
206
216
  // ---------------------------------------------------------------------------
package/lib/commands.js CHANGED
@@ -6,12 +6,17 @@
6
6
  // from index.js.
7
7
 
8
8
  import {
9
+ breakdownCategories,
9
10
  fmt,
10
11
  fmtCell,
11
12
  isSafeSessionKey,
12
13
  oneLine,
13
14
  safeCategories,
14
15
  safeCell,
16
+ safeCount,
17
+ safeToolOutByTool,
18
+ sumCategories,
19
+ toolOutEntries,
15
20
  withTimeout,
16
21
  } from "./estimate.js";
17
22
  import {
@@ -73,16 +78,7 @@ const CONTEXT_BREAKDOWN_RESUME = true;
73
78
  // Note: `system` / `toolSchemas` may legitimately be null (hook-only), so they
74
79
  // are coerced with `|| 0`.
75
80
  function isEmptyStateEntry(entry) {
76
- const c = entry?.categories || {};
77
- const sum =
78
- (c.user || 0) +
79
- (c.assistant || 0) +
80
- (c.reasoning || 0) +
81
- (c.toolArgs || 0) +
82
- (c.toolOut || 0) +
83
- (c.system || 0) +
84
- (c.toolSchemas || 0) +
85
- (c.other || 0);
81
+ const sum = sumCategories(entry?.categories);
86
82
  return (
87
83
  sum === 0 &&
88
84
  (!entry?.ctx || entry.ctx === 0) &&
@@ -135,21 +131,8 @@ function collectDescendantSessionIDs(rootID, state) {
135
131
  }
136
132
 
137
133
  // Normalise a breakdown record (cache or state entry) into the 8 categories.
138
- // `inputTokens` is used only for the residual "other".
139
- function breakdownCategories(rec, inputTokens) {
140
- const user = rec?.userChars ?? rec?.user ?? 0;
141
- const assistant = rec?.assistantChars ?? rec?.assistant ?? 0;
142
- const reasoning = rec?.reasoningChars ?? rec?.reasoning ?? 0;
143
- const toolArgs = rec?.toolArgsChars ?? rec?.toolArgs ?? 0;
144
- const toolOut = rec?.toolOutChars ?? rec?.toolOut ?? 0;
145
- const system = rec?.systemChars !== undefined ? rec.systemChars : rec?.system ?? null;
146
- const toolSchemas =
147
- rec?.toolSchemasChars !== undefined ? rec.toolSchemasChars : rec?.toolSchemas ?? null;
148
- const sum =
149
- user + assistant + reasoning + toolArgs + toolOut + (system || 0) + (toolSchemas || 0);
150
- const other = Math.max(0, (inputTokens || 0) - sum);
151
- return { user, assistant, reasoning, toolArgs, toolOut, system, toolSchemas, other };
152
- }
134
+ // Moved to lib/estimate.js (shared with the V2 live log); re-used here so the
135
+ // residual computation has exactly one implementation.
153
136
 
154
137
  // Short source tag for the table (kept tiny on purpose).
155
138
  function sourceTag(source) {
@@ -238,6 +221,7 @@ async function collectSessionReport(ctx, sessionID, state, deadline) {
238
221
 
239
222
  let categories = null;
240
223
  let source = "none";
224
+ let fallbackParts = null;
241
225
  if (bd) {
242
226
  // Sanitise: every value reaches a markdown/log sink and must be a number.
243
227
  categories = safeCategories(
@@ -249,9 +233,8 @@ async function collectSessionReport(ctx, sessionID, state, deadline) {
249
233
  categories = safeCategories(entry.categories);
250
234
  source = "snapshot";
251
235
  } else if (Array.isArray(contextMsgs)) {
252
- categories = safeCategories(
253
- breakdownCategories(estimateBreakdownFromClientMessages(contextMsgs), 0),
254
- );
236
+ fallbackParts = estimateBreakdownFromClientMessages(contextMsgs);
237
+ categories = safeCategories(breakdownCategories(fallbackParts, 0));
255
238
  source = "client-context";
256
239
  } else if (needFallback) {
257
240
  // No live cache, no state.json categories, and the bounded session.context
@@ -284,6 +267,11 @@ async function collectSessionReport(ctx, sessionID, state, deadline) {
284
267
  const updatedAt =
285
268
  entry?.updatedAt ||
286
269
  (bd?.capturedAt ? new Date(bd.capturedAt).toISOString() : null);
270
+ const toolOutByTool = bd
271
+ ? safeToolOutByTool(bd.toolOutByTool)
272
+ : safeToolOutByTool(entry?.toolOutByTool) ||
273
+ safeToolOutByTool(fallbackParts?.toolOutByTool);
274
+ const compactStale = entry?.compactStale === true;
287
275
  return {
288
276
  sessionID,
289
277
  parentID,
@@ -294,6 +282,8 @@ async function collectSessionReport(ctx, sessionID, state, deadline) {
294
282
  usable,
295
283
  reserve,
296
284
  categories,
285
+ toolOutByTool,
286
+ compactStale,
297
287
  source,
298
288
  updatedAt,
299
289
  };
@@ -311,11 +301,27 @@ function renderSessionRow(r, isMain) {
311
301
  ? ` (${((r.ctxTokens / r.usable) * 100).toFixed(0)}%)`
312
302
  : "";
313
303
  const ctx = `${fmt(r.ctxTokens)}${pct}`;
314
- const upd = `${timeOnly(r.updatedAt)} ${sourceTag(r.source)}`;
304
+ const upd = `${timeOnly(r.updatedAt)} ${sourceTag(r.source)}${r.compactStale ? " (stale)" : ""}`;
315
305
  return `| ${role} | ${safeCell(r.modelID)} | ${ctx} | ${fmtCell(c.user)} | ${fmtCell(c.assistant)} | ${fmtCell(c.reasoning)} | ${fmtCell(c.toolArgs)} | ${fmtCell(c.toolOut)} | ${fmtCell(c.system)} | ${fmtCell(c.toolSchemas)} | ${fmtCell(c.other)} | ${upd} |`;
316
306
  }
317
307
 
318
- function renderBreakdownTable(rows, childCount) {
308
+ // A compact one-line "tool results by tool" split for the main session, built
309
+ // from the server-side aggregate (sanitised names, top-N). Empty when the map
310
+ // carries nothing.
311
+ function renderToolOutLine(toolOutByTool, limit) {
312
+ const entries = toolOutEntries(toolOutByTool, limit || 8);
313
+ if (entries.length === 0) return "";
314
+ const parts = entries.map(
315
+ ([name, val]) => `${safeCell(name)} ${fmt(val)}`,
316
+ );
317
+ const total = Object.values(toolOutByTool || {}).filter(
318
+ (v) => safeCount(v) > 0,
319
+ ).length;
320
+ if (total > entries.length) parts.push(`+${total - entries.length} more`);
321
+ return parts.join(" · ");
322
+ }
323
+
324
+ function renderBreakdownTable(rows, childCount, mainReport) {
319
325
  const lines = [];
320
326
  lines.push("### Context breakdown");
321
327
  lines.push("");
@@ -332,6 +338,14 @@ function renderBreakdownTable(rows, childCount) {
332
338
  `schm=tool schemas, oth=residual input. src: live=context hook, snap=state.json, ` +
333
339
  `fallback=session.context (no system/schemas → n/a), no-data=categories unavailable._`,
334
340
  );
341
+ // Per-tool split of the main session's tool results (which tool ate the
342
+ // "out" column). Server-side aggregate; names sanitised, top-N only.
343
+ const byTool = renderToolOutLine(mainReport?.toolOutByTool, 8);
344
+ if (byTool) {
345
+ lines.push("");
346
+ lines.push("### Tool results by tool (main)");
347
+ lines.push(byTool);
348
+ }
335
349
  return lines.join("\n");
336
350
  }
337
351
 
@@ -403,6 +417,11 @@ function shortSummaryText(r) {
403
417
  lines.push(compact);
404
418
  }
405
419
  lines.push(`updated ${timeOnly(r.updatedAt)} (${sourceTag(r.source)})`);
420
+ if (r.compactStale) {
421
+ lines.push(
422
+ "stale — compacted: these are pre-compaction numbers; they refresh after the next message",
423
+ );
424
+ }
406
425
  return lines.join("\n");
407
426
  }
408
427
 
@@ -484,7 +503,7 @@ async function commandContextBreakdown(ctx, invocation, opts) {
484
503
  ]);
485
504
  const rows = [renderSessionRow(root, true)];
486
505
  for (const rep of children) rows.push(renderSessionRow(rep, false));
487
- const table = renderBreakdownTable(rows, childIDs.length);
506
+ const table = renderBreakdownTable(rows, childIDs.length, root);
488
507
  const payload = resume
489
508
  ? `${BREAKDOWN_MODEL_INSTRUCTION}\n\n<context-breakdown>\n${table}\n</context-breakdown>`
490
509
  : table;
@@ -510,6 +529,7 @@ export {
510
529
  CONTEXT_BREAKDOWN_RESUME,
511
530
  collectSessionReport,
512
531
  renderSessionRow,
532
+ renderBreakdownTable,
513
533
  shortSummaryText,
514
534
  shortSummaryDescription,
515
535
  };
@@ -3,48 +3,19 @@
3
3
  // Pure contributor-measurement helpers extracted from tui.tsx for unit testing.
4
4
  // All functions are stateless transforms of their arguments.
5
5
  //
6
- // Extracted 1:1 (behaviour + WHY comments, stripped of TS syntax):
7
- // estimateTokensLocal, asRecord, safeText, measureContributors,
8
- // contributorLine, exportFileName, buildRedactedPayload, panelDenom, panelRole.
6
+ // Functions: asRecord, safeText, measureContributors, contributorLine,
7
+ // exportFileName, buildRedactedPayload, panelDenom, panelRole. Token estimation
8
+ // is imported from lib/estimate.js (single source of truth).
9
+
10
+ import { estimateTokens } from "./estimate.js";
9
11
 
10
12
  // ---------------------------------------------------------------------------
11
- // Token estimation (mirrors lib/estimate.js but self-contained — tui.tsx
12
- // loads standalone and must NOT import lib/*).
13
+ // Token estimation — the SHARED lib/estimate.js heuristic (the TUI panel and
14
+ // the server must price the same text identically; the earlier self-contained
15
+ // copy had drifted risk). estimate.js is pure and dependency-free, so it loads
16
+ // in the TUI host exactly like the rest of lib/.
13
17
  // ---------------------------------------------------------------------------
14
18
 
15
- // Unicode heuristic — estimate, not a tokenizer. Cyrillic ~2.5, CJK ~1.5,
16
- // latin/ASCII ~4 chars/token.
17
- function estimateTokensLocal(s) {
18
- let other = 0;
19
- let cyr = 0;
20
- let cjk = 0;
21
- for (let i = 0; i < s.length; i++) {
22
- const cp = s.codePointAt(i);
23
- if (cp > 0xffff) i++; // astral pair — consume the low surrogate
24
- if (cp < 0x80) other++;
25
- else if (
26
- (cp >= 0x0400 && cp <= 0x04ff) || // Cyrillic
27
- (cp >= 0x0500 && cp <= 0x052f) || // Cyrillic Supplement
28
- (cp >= 0x2de0 && cp <= 0x2dff) || // Cyrillic Extended-A
29
- (cp >= 0xa640 && cp <= 0xa69f) || // Cyrillic Extended-B
30
- (cp >= 0x1c80 && cp <= 0x1c8f) // Cyrillic Extended-C
31
- )
32
- cyr++;
33
- else if (
34
- (cp >= 0x4e00 && cp <= 0x9fff) || // CJK Unified Ideographs
35
- (cp >= 0x3400 && cp <= 0x4dbf) || // CJK Extension A
36
- (cp >= 0xf900 && cp <= 0xfaff) || // CJK Compatibility Ideographs
37
- (cp >= 0x3040 && cp <= 0x30ff) || // Hiragana + Katakana
38
- (cp >= 0xac00 && cp <= 0xd7af) || // Hangul Syllables
39
- (cp >= 0x3000 && cp <= 0x303f) || // CJK Symbols and Punctuation
40
- (cp >= 0xff00 && cp <= 0xffef) // Halfwidth/Fullwidth Forms
41
- )
42
- cjk++;
43
- else other++;
44
- }
45
- return Math.round(other / 4 + cyr / 2.5 + cjk / 1.5);
46
- }
47
-
48
19
  // Live message data is untrusted: coerce every field defensively, never throw.
49
20
  function asRecord(v) {
50
21
  return v && typeof v === "object" ? v : null;
@@ -96,7 +67,7 @@ function measureContributors(messages) {
96
67
  kind,
97
68
  ref,
98
69
  chars: sample.length,
99
- tokensEstimate: estimateTokensLocal(sample),
70
+ tokensEstimate: estimateTokens(sample),
100
71
  tool: tool || "",
101
72
  preview: previewOverride !== undefined ? previewOverride : previewOf(sample),
102
73
  });
@@ -276,8 +247,6 @@ function panelRole(e, isMain) {
276
247
  }
277
248
 
278
249
  export {
279
- estimateTokensLocal,
280
- asRecord,
281
250
  safeText,
282
251
  measureContributors,
283
252
  contributorLine,
package/lib/dedup.js CHANGED
@@ -17,9 +17,9 @@
17
17
  //
18
18
  // EXPORTS
19
19
  // claimOnce(key, dedupDir) atomic cross-instance / cross-process claim
20
- // pruneDedup(dedupDir) delete claim markers older than DEDUP_TTL_MS
21
20
  // pruneMap(map, maxSize) FIFO-trim an in-process Map (insertion order)
22
- // DEDUP_TTL_MS marker lifetime (24h)
21
+ // (pruneDedup / DEDUP_TTL_MS are module-internal: pruning is driven from
22
+ // claimOnce, never from outside this file)
23
23
  //
24
24
  // The claim mechanism is the exact one proven in token-usage-logger.js:
25
25
  // OpenCode loads one plugin instance per loaded location and mirrors the public
@@ -40,7 +40,7 @@ import {
40
40
  import { createHash } from "node:crypto";
41
41
  import { join } from "node:path";
42
42
 
43
- export const DEDUP_TTL_MS = 24 * 60 * 60 * 1000; // prune claim markers after a day
43
+ const DEDUP_TTL_MS = 24 * 60 * 60 * 1000; // prune claim markers after a day
44
44
  // Time-throttled pruning. A claim directory can hold tens of thousands of
45
45
  // markers (mostly legacy per-delta markers), and pruneDedup() does a
46
46
  // synchronous readdirSync+statSync over the whole directory — running that in
@@ -103,7 +103,7 @@ export function claimOnce(key, dedupDir) {
103
103
  }
104
104
 
105
105
  // Delete claim markers older than DEDUP_TTL_MS. Never throws.
106
- export function pruneDedup(dedupDir) {
106
+ function pruneDedup(dedupDir) {
107
107
  try {
108
108
  const now = Date.now();
109
109
  for (const f of readdirSync(dedupDir)) {
package/lib/estimate.js CHANGED
@@ -99,6 +99,53 @@ function safeCategories(raw) {
99
99
  };
100
100
  }
101
101
 
102
+ // A session key safe to use as an object property / map key. Rejects the
103
+ // prototype-pollution vectors ("__proto__", "constructor", "prototype").
104
+ // Sum of the 8 canonical category fields (null `system`/`toolSchemas` count as
105
+ // 0). The single place the "total measured tokens" is derived — shared by the
106
+ // residual-`other` computation, the continuity test and the log/summary writers.
107
+ function sumCategories(c) {
108
+ if (!c || typeof c !== "object") return 0;
109
+ return (
110
+ safeCount(c.user) +
111
+ safeCount(c.assistant) +
112
+ safeCount(c.reasoning) +
113
+ safeCount(c.toolArgs) +
114
+ safeCount(c.toolOut) +
115
+ safeCount(c.system) +
116
+ safeCount(c.toolSchemas) +
117
+ safeCount(c.other)
118
+ );
119
+ }
120
+
121
+ // Normalise a breakdown record (cache record with *Chars keys, or a persisted
122
+ // state entry with canonical keys) into the 8 categories, deriving the residual
123
+ // `other` from `inputTokens`. Pure; the one converter shared by the V2 live log
124
+ // (lib/v2.js) and the slash-command report (lib/commands.js).
125
+ function breakdownCategories(rec, inputTokens) {
126
+ const user = rec?.userChars ?? rec?.user ?? 0;
127
+ const assistant = rec?.assistantChars ?? rec?.assistant ?? 0;
128
+ const reasoning = rec?.reasoningChars ?? rec?.reasoning ?? 0;
129
+ const toolArgs = rec?.toolArgsChars ?? rec?.toolArgs ?? 0;
130
+ const toolOut = rec?.toolOutChars ?? rec?.toolOut ?? 0;
131
+ const system =
132
+ rec?.systemChars !== undefined ? rec.systemChars : (rec?.system ?? null);
133
+ const toolSchemas =
134
+ rec?.toolSchemasChars !== undefined
135
+ ? rec.toolSchemasChars
136
+ : (rec?.toolSchemas ?? null);
137
+ const sum =
138
+ user +
139
+ assistant +
140
+ reasoning +
141
+ toolArgs +
142
+ toolOut +
143
+ (system || 0) +
144
+ (toolSchemas || 0);
145
+ const other = Math.max(0, (inputTokens || 0) - sum);
146
+ return { user, assistant, reasoning, toolArgs, toolOut, system, toolSchemas, other };
147
+ }
148
+
102
149
  // A session key safe to use as an object property / map key. Rejects the
103
150
  // prototype-pollution vectors ("__proto__", "constructor", "prototype").
104
151
  function isSafeSessionKey(k) {
@@ -111,6 +158,46 @@ function isSafeSessionKey(k) {
111
158
  );
112
159
  }
113
160
 
161
+ // Bound on the number of distinct tool names kept in a per-tool tool-result
162
+ // map (state.json / context payload are untrusted and a plugin could define an
163
+ // unbounded tool set). Everything past the cap — plus any unsafe name — is
164
+ // folded into a single "other tools" bucket so the total still reconciles with
165
+ // the toolOut category.
166
+ const TOOL_OUT_MAX_KEYS = 24;
167
+ // Normalise a per-tool tool-result map (tool name -> estimated tokens) from
168
+ // untrusted input. Names become object keys AND display cells, so prototype
169
+ // vectors are dropped and the map is bounded (see TOOL_OUT_MAX_KEYS). The
170
+ // overflow bucket is named "other tools" — a real tool with that exact name
171
+ // would merge into it (accepted: it is a display-only label).
172
+ function safeToolOutByTool(raw, maxKeys = TOOL_OUT_MAX_KEYS) {
173
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
174
+ const out = {};
175
+ let rest = 0;
176
+ let used = 0;
177
+ for (const [k, v] of Object.entries(raw)) {
178
+ const val = safeCount(v);
179
+ if (!isSafeSessionKey(k) || used >= maxKeys) {
180
+ rest += val;
181
+ continue;
182
+ }
183
+ out[k] = (out[k] || 0) + val;
184
+ used++;
185
+ }
186
+ if (rest > 0) out["other tools"] = (out["other tools"] || 0) + rest;
187
+ return Object.keys(out).length > 0 ? out : null;
188
+ }
189
+
190
+ // Descending [name, tokens] pairs for display, capped at `limit`; empty when
191
+ // the map carries no positive rows.
192
+ function toolOutEntries(map, limit = 8) {
193
+ if (!map || typeof map !== "object" || Array.isArray(map)) return [];
194
+ return Object.entries(map)
195
+ .map(([k, v]) => [k, safeCount(v)])
196
+ .filter(([, v]) => v > 0)
197
+ .sort((a, b) => b[1] - a[1])
198
+ .slice(0, Math.max(0, limit));
199
+ }
200
+
114
201
  function fmt(n) {
115
202
  return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
116
203
  }
@@ -215,17 +302,18 @@ function fmtCell(n) {
215
302
  }
216
303
 
217
304
  export {
218
- CHARS_PER_TOKEN_OTHER,
219
- CHARS_PER_TOKEN_CYRILLIC,
220
- CHARS_PER_TOKEN_CJK,
221
- MAX_SANE_LIMIT,
222
305
  oneLine,
223
306
  safeCell,
224
307
  finitePos,
225
308
  safeCount,
226
309
  safeCountOrNull,
227
310
  safeCategories,
311
+ sumCategories,
312
+ breakdownCategories,
228
313
  isSafeSessionKey,
314
+ safeToolOutByTool,
315
+ toolOutEntries,
316
+ TOOL_OUT_MAX_KEYS,
229
317
  fmt,
230
318
  estimateTokens,
231
319
  estJson,
package/lib/limits.js CHANGED
@@ -453,7 +453,6 @@ export {
453
453
  getModelLimit,
454
454
  getUsableContext,
455
455
  reserveFor,
456
- setModelLimit,
457
456
  ensureProviderLimits,
458
457
  ensureModelLimits,
459
458
  hydrateModelLimitsFromState,
package/lib/state.js CHANGED
@@ -27,6 +27,8 @@ import {
27
27
  oneLine,
28
28
  safeCount,
29
29
  safeCountOrNull,
30
+ safeToolOutByTool,
31
+ sumCategories,
30
32
  } from "./estimate.js";
31
33
  import {
32
34
  breakdownCache,
@@ -168,16 +170,6 @@ function writeLog(liveLines) {
168
170
  }
169
171
  }
170
172
 
171
- // Append a single line to the file (used for final summaries / notes).
172
- function appendLogLine(line) {
173
- try {
174
- ensureRegularFile(LOG_FILE); // refuse to follow a symlink planted at the path
175
- appendFileSync(LOG_FILE, line + "\n", "utf8");
176
- } catch (err) {
177
- logErr("appendLogLine failed", err);
178
- }
179
- }
180
-
181
173
  // Synchronous sleep for the lock retry loop. Atomics.wait is the documented
182
174
  // non-busy way to block this thread; fall back to a bounded spin if unavailable.
183
175
  function sleepSync(ms) {
@@ -316,6 +308,7 @@ function writeStateFile(
316
308
  inputTokens,
317
309
  ctxTokens,
318
310
  reasoningTokens,
311
+ opts,
319
312
  ) {
320
313
  try {
321
314
  if (!sessionID) return;
@@ -432,15 +425,57 @@ function writeStateFile(
432
425
  ? safeCount(bd.toolOutChars)
433
426
  : safeCount(pc?.toolOut);
434
427
 
435
- const sumEstimates =
436
- catUser +
437
- catAssistant +
438
- catReasoning +
439
- catToolArgs +
440
- catToolOut +
441
- (catSystem || 0) +
442
- (catToolSchemas || 0);
443
- const other = Math.max(0, (inputTokens || 0) - sumEstimates);
428
+ // Per-tool split of the toolOut category (webfetch / read / bash / …).
429
+ // Untrusted names -> sanitised + bounded. A present breakdown OWNS the
430
+ // split (an empty one means "no tool results now" — do NOT revive a stale
431
+ // peer value); only fall back to the persisted map when this instance has
432
+ // no live breakdown at all.
433
+ const toolOutByTool = bd
434
+ ? safeToolOutByTool(bd.toolOutByTool)
435
+ : safeToolOutByTool(prevEntry?.toolOutByTool);
436
+
437
+ // Compaction staleness: a `session.compacted` write re-persists the
438
+ // pre-compaction numbers (the compaction flow fires no context hook), so
439
+ // the entry is stale BY CONSTRUCTION — even when this instance has no live
440
+ // breakdown to compare against (a mirror instance must not drop the
441
+ // signal). Later writes inherit `compactedAt` and clear the mark only once
442
+ // a FRESH breakdown (bd.capturedAt newer than the compaction) lands.
443
+ const compactedAt = opts?.compacted
444
+ ? new Date().toISOString()
445
+ : typeof prevEntry?.compactedAt === "string"
446
+ ? prevEntry.compactedAt
447
+ : null;
448
+ let compactStale = false;
449
+ if (opts?.compacted === true) {
450
+ compactStale = true;
451
+ } else if (compactedAt) {
452
+ const cMs = Date.parse(compactedAt);
453
+ const measuredMs =
454
+ bd && typeof bd.capturedAt === "number" ? bd.capturedAt : NaN;
455
+ if (Number.isFinite(cMs) && Number.isFinite(measuredMs)) {
456
+ compactStale = measuredMs <= cMs;
457
+ } else {
458
+ // No live breakdown here: keep the previous verdict (a peer instance
459
+ // may have measured it).
460
+ compactStale = prevEntry?.compactStale === true;
461
+ }
462
+ }
463
+
464
+ // Residual = input minus every measured category (shared sum; null
465
+ // system/toolSchemas count as 0).
466
+ const other = Math.max(
467
+ 0,
468
+ safeCount(inputTokens) -
469
+ sumCategories({
470
+ user: catUser,
471
+ assistant: catAssistant,
472
+ reasoning: catReasoning,
473
+ toolArgs: catToolArgs,
474
+ toolOut: catToolOut,
475
+ system: catSystem,
476
+ toolSchemas: catToolSchemas,
477
+ }),
478
+ );
444
479
  // Parent link for the /context-breakdown command. Prefer the live cache;
445
480
  // fall back to the previously persisted value (backward compatible with
446
481
  // snapshots written before parentID existed -> null). role mirrors it.
@@ -490,6 +525,9 @@ function writeStateFile(
490
525
  toolSchemas: catToolSchemas,
491
526
  other,
492
527
  },
528
+ toolOutByTool,
529
+ compactedAt,
530
+ compactStale,
493
531
  updatedAt: new Date().toISOString(),
494
532
  };
495
533
  all[sessionID] = entry;
@@ -618,19 +656,9 @@ function pushFinalSummary(claimKey, summaryLines) {
618
656
 
619
657
  export {
620
658
  logErr,
621
- renderFile,
622
659
  writeLog,
623
- appendLogLine,
624
- sleepSync,
625
- ensureRegularFile,
626
- isStaleLock,
627
- acquireStateLock,
628
- releaseStateLock,
629
- renameWithRetry,
630
660
  writeStateFile,
631
- appendEventLog,
632
661
  debugEvent,
633
662
  readStateSnapshot,
634
- finalSummaries,
635
663
  pushFinalSummary,
636
664
  };
package/lib/tui-data.d.ts CHANGED
@@ -29,6 +29,12 @@ export interface StateEntry {
29
29
  limit?: number | null
30
30
  reasoning?: number
31
31
  categories?: Categories
32
+ /** Per-tool tool-result split: tool name -> estimated tokens. */
33
+ toolOutByTool?: Record<string, number>
34
+ /** ISO time of the last session.compacted (marker only). */
35
+ compactedAt?: string | null
36
+ /** True while the entry still shows pre-compaction numbers. */
37
+ compactStale?: boolean
32
38
  updatedAt?: string
33
39
  }
34
40
 
@@ -44,3 +50,7 @@ export declare function fmt(n: number): string
44
50
  export declare function num(v: unknown): number
45
51
  export declare function fit(s: string, w: number): string
46
52
  export declare function updatedAt(iso: string | undefined): string
53
+ export declare function toolOutEntries(
54
+ map: Record<string, number> | undefined,
55
+ limit?: number,
56
+ ): Array<[string, number]>
package/lib/tui-data.js CHANGED
@@ -12,7 +12,8 @@
12
12
  //
13
13
  // state.json shape (written by writeStateFile() in index.js, untrusted input):
14
14
  // { [sessionID]: { sessionID, parentID, role, agent, model, providerID,
15
- // ctx, input, usable, reserve, limit, reasoning, categories{...}, updatedAt } }
15
+ // ctx, input, usable, reserve, limit, reasoning, categories{...},
16
+ // toolOutByTool{name->tokens}, compactedAt, compactStale, updatedAt } }
16
17
  // The authoritative TS mirror of the entry shape is the `StateEntry` interface
17
18
  // in lib/tui-data.d.ts — keep the two structurally compatible.
18
19
 
@@ -20,6 +21,11 @@ import { readFileSync } from "node:fs";
20
21
  import { tmpdir } from "node:os";
21
22
  import { join } from "node:path";
22
23
  import { fileURLToPath } from "node:url";
24
+ import { isSafeSessionKey, sumCategories } from "./estimate.js";
25
+
26
+ // Re-exported so the panel keeps importing it from this module; the
27
+ // implementation is the shared one (lib/estimate.js) — no second copy to drift.
28
+ export { toolOutEntries } from "./estimate.js";
23
29
 
24
30
  export const STATE_FILE =
25
31
  process.env.OPENCODE_CONTEXT_INDICATOR_STATE_FILE ||
@@ -42,19 +48,6 @@ export function isNodeModulesInstall() {
42
48
  }
43
49
  }
44
50
 
45
- // Reserved object keys must never be treated as session ids: state.json is
46
- // untrusted input (structural mirror of isSafeSessionKey() in lib/estimate.js,
47
- // including the string/length requirement).
48
- function isSafeKey(k) {
49
- return (
50
- typeof k === "string" &&
51
- k.length > 0 &&
52
- k !== "__proto__" &&
53
- k !== "constructor" &&
54
- k !== "prototype"
55
- )
56
- }
57
-
58
51
  // Whole state.json snapshot ({ sessionID: entry }) or {} on any error. The
59
52
  // sidebar resolves one entry from it and the breakdown panel walks the
60
53
  // parentID family over it. Never throws — a missing / empty / corrupt
@@ -68,7 +61,7 @@ export function readSnapshot() {
68
61
  // (__proto__ / constructor / prototype) and non-object entries in one pass.
69
62
  const clean = {}
70
63
  for (const [k, v] of Object.entries(all)) {
71
- if (isSafeKey(k) && v && typeof v === "object") clean[k] = v
64
+ if (isSafeSessionKey(k) && v && typeof v === "object") clean[k] = v
72
65
  }
73
66
  return clean
74
67
  } catch {
@@ -82,17 +75,11 @@ export function readSnapshot() {
82
75
  // a missing categories object is not the only trigger. `system` / `toolSchemas`
83
76
  // may legitimately be null, hence the num() coercion.
84
77
  function isEmptyEntry(e) {
85
- const c = e?.categories
86
- const sum =
87
- num(c?.user) +
88
- num(c?.assistant) +
89
- num(c?.reasoning) +
90
- num(c?.toolArgs) +
91
- num(c?.toolOut) +
92
- num(c?.system) +
93
- num(c?.toolSchemas) +
94
- num(c?.other)
95
- return sum === 0 && num(e?.ctx) === 0 && num(e?.input) === 0
78
+ return (
79
+ sumCategories(e?.categories) === 0 &&
80
+ num(e?.ctx) === 0 &&
81
+ num(e?.input) === 0
82
+ )
96
83
  }
97
84
 
98
85
  // The panel row set: the root entry (panel.sessionID) plus every entry whose
package/lib/tui-panel.tsx CHANGED
@@ -11,7 +11,7 @@ import { mkdirSync, writeFileSync } from "node:fs"
11
11
  import { tmpdir } from "node:os"
12
12
  import { basename, join } from "node:path"
13
13
  import type { StateEntry } from "./tui-data.js"
14
- import { collectFamily, fit, fmt, num, updatedAt } from "./tui-data.js"
14
+ import { collectFamily, fit, fmt, num, toolOutEntries, updatedAt } from "./tui-data.js"
15
15
  import { token } from "./tui-theme.js"
16
16
  import {
17
17
  buildRedactedPayload,
@@ -475,11 +475,13 @@ export function BreakdownPanel(props: {
475
475
  const model = safeText(r.model && r.model !== "unknown" ? r.model : "model?")
476
476
  const ctx = fmt(num(r.ctx))
477
477
  const d = panelDenom(r)
478
- return d > 0
479
- ? `Context breakdown \u2014 ${model} ${ctx}/${fmt(d)} (${Math.round(
480
- (num(r.ctx) / d) * 100,
481
- )}%)`
482
- : `Context breakdown \u2014 ${model} ${ctx}`
478
+ const base =
479
+ d > 0
480
+ ? `Context breakdown \u2014 ${model} ${ctx}/${fmt(d)} (${Math.round(
481
+ (num(r.ctx) / d) * 100,
482
+ )}%)`
483
+ : `Context breakdown \u2014 ${model} ${ctx}`
484
+ return r.compactStale === true ? `${base} (stale — compacted)` : base
483
485
  }
484
486
 
485
487
  const layout = (): {
@@ -523,10 +525,27 @@ export function BreakdownPanel(props: {
523
525
  L.gutter +
524
526
  fit(money(costOf(e.sessionID)), L.costW) +
525
527
  L.gutter +
526
- updatedAt(e.updatedAt)
528
+ updatedAt(e.updatedAt) +
529
+ (e.compactStale === true ? " (stale)" : "")
527
530
  )
528
531
  }
529
532
 
533
+ // One compact line: the main session's tool results split by tool name
534
+ // (server-side aggregate; the per-call "largest contributors" block above is
535
+ // the measured, client-side view).
536
+ const byToolLine = (): string => {
537
+ const all = data().root?.toolOutByTool
538
+ const entries = toolOutEntries(all, 5)
539
+ if (entries.length === 0) return ""
540
+ const total =
541
+ all && typeof all === "object"
542
+ ? Object.values(all).filter((v) => num(v) > 0).length
543
+ : 0
544
+ const parts = entries.map(([name, val]) => `${safeText(name)} ${fmt(val)}`)
545
+ if (total > entries.length) parts.push(`+${total - entries.length} more`)
546
+ return parts.join(" \u00b7 ")
547
+ }
548
+
530
549
  return (
531
550
  <box flexDirection="column">
532
551
  <text fg={base()}>{header()}</text>
@@ -569,6 +588,10 @@ export function BreakdownPanel(props: {
569
588
  }
570
589
  </text>
571
590
  </Show>
591
+ <Show when={byToolLine().length > 0}>
592
+ <text fg={base()}>{"tool results by tool"}</text>
593
+ <text fg={muted()}>{byToolLine()}</text>
594
+ </Show>
572
595
  </Show>
573
596
  </box>
574
597
  )
@@ -66,6 +66,9 @@ export function Sidebar(props: { entry: () => StateEntry | null }) {
66
66
  : line("tool schemas", e()?.categories?.toolSchemas)}
67
67
  </text>
68
68
  <text fg={muted()}>{line("other", e()?.categories?.other)}</text>
69
+ <Show when={e()?.compactStale === true}>
70
+ <text fg={muted()}>{" stale — compacted"}</text>
71
+ </Show>
69
72
  <text fg={muted()}>{`updated ${updatedAt(e()?.updatedAt)}`}</text>
70
73
  </Show>
71
74
  </box>
package/lib/v2.js CHANGED
@@ -7,11 +7,14 @@
7
7
  // from index.js.
8
8
 
9
9
  import {
10
+ breakdownCategories,
10
11
  estJson,
11
12
  estimateTokens,
12
13
  fmt,
14
+ isSafeSessionKey,
13
15
  oneLine,
14
16
  tokenCountOf,
17
+ TOOL_OUT_MAX_KEYS,
15
18
  withTimeout,
16
19
  } from "./estimate.js";
17
20
  import {
@@ -75,10 +78,11 @@ const V2_FALLBACK_TIMEOUT_MS = 3000;
75
78
  // every schema does not run on every single model request.
76
79
  // Cheap structural fingerprint of the tool schema map: count + total description
77
80
  // length + number of schema property keys + two O(n) charCode checksums (mod
78
- // 0xFFFFFF) over every description and over the top-level input keys. The
79
- // checksums widen the fingerprint so two different schemas that happen to share
80
- // the same length/key counts do not collide into a wrong cache hit — without
81
- // paying for full JSON.stringify (the whole point of the cache).
81
+ // 0xFFFFFF) over every description and over the PARAMETER names
82
+ // (input.properties keys). The checksums widen the fingerprint so two different
83
+ // schemas that happen to share the same length/key counts do not collide into a
84
+ // wrong cache hit — without paying for full JSON.stringify (the whole point of
85
+ // the cache).
82
86
  function toolsFingerprint(tools) {
83
87
  if (!tools || typeof tools !== "object") return "none";
84
88
  let n = 0;
@@ -98,14 +102,16 @@ function toolsFingerprint(tools) {
98
102
  const input = t?.input;
99
103
  if (input && typeof input === "object") {
100
104
  const props = input.properties;
101
- propKeys +=
105
+ // Hash the PARAMETER names (input.properties keys) when present — a
106
+ // property rename that keeps the same count/description must change the
107
+ // fingerprint, or the TTL cache would serve a stale schema estimate.
108
+ // Fall back to the top-level schema keys for schemas without `properties`.
109
+ const nameSource =
102
110
  props && typeof props === "object"
103
- ? Object.keys(props).length
104
- : Object.keys(input).length;
105
- // Sum the top-level key names so property RENAMES that keep the same count
106
- // still change the fingerprint.
107
- const topKeys = Object.keys(input);
108
- for (const k of topKeys) {
111
+ ? Object.keys(props)
112
+ : Object.keys(input);
113
+ propKeys += nameSource.length;
114
+ for (const k of nameSource) {
109
115
  for (let i = 0; i < k.length; i++) {
110
116
  keyHash = (keyHash + k.charCodeAt(i)) & 0xffffff;
111
117
  }
@@ -138,12 +144,34 @@ function toolResultChars(p) {
138
144
  return estJson(r.value); // json | error | unexpected shapes
139
145
  }
140
146
 
147
+ // Accumulate the per-tool tool-result estimate. Tool names are untrusted
148
+ // (host tool ids, plugin-defined tools): unsafe names and any name beyond the
149
+ // cap fold into "other tools" so the bucket never becomes a prototype vector
150
+ // and the map stays bounded. The sum of the map mirrors the toolOut category.
151
+ // The cap is the SHARED TOOL_OUT_MAX_KEYS (lib/estimate.js) so the accumulation
152
+ // bound and the persisted/displayed bound can never drift apart.
153
+ function addToolOut(map, name, chars) {
154
+ if (!(chars > 0)) return;
155
+ const safe =
156
+ isSafeSessionKey(name) && name.length <= 64 ? name : "other tools";
157
+ if (
158
+ safe !== "other tools" &&
159
+ !Object.prototype.hasOwnProperty.call(map, safe) &&
160
+ Object.keys(map).length >= TOOL_OUT_MAX_KEYS
161
+ ) {
162
+ map["other tools"] = (map["other tools"] || 0) + chars;
163
+ return;
164
+ }
165
+ map[safe] = (map[safe] || 0) + chars;
166
+ }
167
+
141
168
  function estimateBreakdownFromContext(context, sessionID) {
142
169
  let userChars = 0;
143
170
  let assistantChars = 0;
144
171
  let reasoningChars = 0;
145
172
  let toolArgsChars = 0;
146
173
  let toolOutChars = 0;
174
+ const toolOutByTool = {};
147
175
 
148
176
  const messages = Array.isArray(context?.messages) ? context.messages : [];
149
177
  for (const msg of messages) {
@@ -153,7 +181,9 @@ function estimateBreakdownFromContext(context, sessionID) {
153
181
  // carrying them varies by host version (user, assistant, or the dedicated
154
182
  // "tool" role) — count them role-independently.
155
183
  if (p?.type === "tool-result") {
156
- toolOutChars += toolResultChars(p);
184
+ const chars = toolResultChars(p);
185
+ toolOutChars += chars;
186
+ addToolOut(toolOutByTool, p?.name, chars);
157
187
  continue;
158
188
  }
159
189
  if (msg?.role === "user") {
@@ -206,6 +236,7 @@ function estimateBreakdownFromContext(context, sessionID) {
206
236
  reasoningChars,
207
237
  toolArgsChars,
208
238
  toolOutChars,
239
+ toolOutByTool,
209
240
  systemChars,
210
241
  toolSchemasChars,
211
242
  };
@@ -259,6 +290,7 @@ function estimateBreakdownFromClientMessages(messages) {
259
290
  let reasoningChars = 0;
260
291
  let toolArgsChars = 0;
261
292
  let toolOutChars = 0;
293
+ const toolOutByTool = {};
262
294
  for (const m of messages || []) {
263
295
  if (!m || typeof m !== "object") continue;
264
296
  if (m.type === "user") {
@@ -282,11 +314,16 @@ function estimateBreakdownFromClientMessages(messages) {
282
314
  // policy as the hook path).
283
315
  const content = p?.state?.content;
284
316
  if (Array.isArray(content)) {
317
+ let outChars = 0;
285
318
  for (const item of content) {
286
319
  if (item?.type === "text" && typeof item.text === "string") {
287
- toolOutChars += estimateTokens(item.text);
320
+ outChars += estimateTokens(item.text);
288
321
  }
289
322
  }
323
+ toolOutChars += outChars;
324
+ // The persisted tool part carries its name on `p.name`
325
+ // (SessionMessageAssistantTool.name) — same field as the hook path.
326
+ addToolOut(toolOutByTool, p?.name, outChars);
290
327
  }
291
328
  }
292
329
  }
@@ -298,6 +335,7 @@ function estimateBreakdownFromClientMessages(messages) {
298
335
  reasoningChars,
299
336
  toolArgsChars,
300
337
  toolOutChars,
338
+ toolOutByTool,
301
339
  systemChars: null,
302
340
  toolSchemasChars: null,
303
341
  };
@@ -423,14 +461,9 @@ function buildLiveLinesV2(sessionID, modelID, providerID, inputTokens, ctxTokens
423
461
  const source = bd?.source || "none";
424
462
  const rt = typeof reasoningTokens === "number" ? reasoningTokens : 0;
425
463
 
426
- const sumEstimates = (bd?.userChars || 0) +
427
- (bd?.assistantChars || 0) +
428
- (bd?.reasoningChars || 0) +
429
- (bd?.toolArgsChars || 0) +
430
- (bd?.toolOutChars || 0) +
431
- (bd?.systemChars || 0) +
432
- (bd?.toolSchemasChars || 0);
433
- const other = Math.max(0, inputTokens - sumEstimates);
464
+ // Residual = input minus every measured category (shared normaliser; `bd`
465
+ // may be absent -> everything 0 and the residual is the whole input).
466
+ const other = breakdownCategories(bd, inputTokens).other;
434
467
 
435
468
  if (bd) {
436
469
  lines.push(` user ${fmt(bd.userChars || 0)} (est tokens)`);
@@ -569,6 +602,11 @@ async function handleV2Event(ctx, event) {
569
602
  lastKnownInput(sid),
570
603
  lastKnownCtx(sid),
571
604
  lastKnownReasoning(sid),
605
+ // This write re-persists the PRE-compaction snapshot (the compaction
606
+ // flow fires no context hook — see README): mark the entry stale so the
607
+ // UI shows the numbers as pre-compaction until the next model request
608
+ // refreshes them.
609
+ { compacted: true },
572
610
  );
573
611
  pushFinalSummary(
574
612
  `compacted:${event.id ?? sid}`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-context-indicator",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "description": "Real-time context-window usage for OpenCode: per-category token breakdown, 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",