opencode-context-indicator 1.5.2 → 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/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) return []
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
- return measureContributors(m.list)
348
+ contributorValue = measureContributors(m.list)
349
+ contributorKey = key || contributorKey
261
350
  } catch {
262
- return []
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,
@@ -207,6 +208,35 @@ function addToolOut(map, name, chars) {
207
208
  map[safe] = (map[safe] || 0) + chars;
208
209
  }
209
210
 
211
+ // Bounded candidate list for the "top context consumers" section: the heaviest
212
+ // single units still in the assembled request (one tool output, one message,
213
+ // the system prompt, one tool-call's args). The list is capped: once full, a new
214
+ // item replaces the lightest candidate only when it is heavier, so a runaway
215
+ // session cannot grow the array without bound while the true top-N is kept.
216
+ const MAX_TOP_CANDIDATES = 256;
217
+ const TOP_N = 5;
218
+
219
+ function pushTopCandidate(list, kind, tokens, name, target) {
220
+ const t = Math.round(tokens);
221
+ if (!(t > 0)) return;
222
+ const item = { kind, tokens: t };
223
+ if (typeof name === "string" && name.length > 0) item.name = name;
224
+ if (typeof target === "string" && target.length > 0) item.target = target;
225
+ if (list.length < MAX_TOP_CANDIDATES) {
226
+ list.push(item);
227
+ return;
228
+ }
229
+ let min = 0;
230
+ for (let i = 1; i < list.length; i++) {
231
+ if (list[i].tokens < list[min].tokens) min = i;
232
+ }
233
+ if (t > list[min].tokens) list[min] = item;
234
+ }
235
+
236
+ function selectTopConsumers(list) {
237
+ return list.slice().sort((a, b) => b.tokens - a.tokens).slice(0, TOP_N);
238
+ }
239
+
210
240
  function estimateBreakdownFromContext(context, sessionID) {
211
241
  let userChars = 0;
212
242
  let assistantChars = 0;
@@ -214,6 +244,7 @@ function estimateBreakdownFromContext(context, sessionID) {
214
244
  let toolArgsChars = 0;
215
245
  let toolOutChars = 0;
216
246
  const toolOutByTool = {};
247
+ const topCandidates = [];
217
248
 
218
249
  const messages = Array.isArray(context?.messages) ? context.messages : [];
219
250
  // Waste detection: correlate tool-call (input -> target) with its tool-result
@@ -231,12 +262,14 @@ function estimateBreakdownFromContext(context, sessionID) {
231
262
  const chars = toolResultChars(p);
232
263
  toolOutChars += chars;
233
264
  addToolOut(toolOutByTool, p?.name, chars);
265
+ const call = p?.id != null ? callById.get(p.id) : null;
266
+ const name = p?.name || call?.name || null;
267
+ const target = extractToolTarget(name, call?.input);
268
+ pushTopCandidate(topCandidates, "tool", chars, name, target);
234
269
  if (chars > 0 && instances.length < MAX_WASTE_INSTANCES) {
235
- const call = p?.id != null ? callById.get(p.id) : null;
236
- const name = p?.name || call?.name || null;
237
270
  instances.push({
238
271
  name,
239
- target: extractToolTarget(name, call?.input),
272
+ target,
240
273
  tokens: chars,
241
274
  fp: contentFingerprint(toolResultText(p)),
242
275
  });
@@ -244,14 +277,30 @@ function estimateBreakdownFromContext(context, sessionID) {
244
277
  continue;
245
278
  }
246
279
  if (msg?.role === "user") {
247
- if (p?.type === "text") userChars += estimateTokens(p.text);
280
+ if (p?.type === "text") {
281
+ const t = estimateTokens(p.text);
282
+ userChars += t;
283
+ pushTopCandidate(topCandidates, "user", t);
284
+ }
248
285
  } else if (msg?.role === "assistant") {
249
286
  if (p?.type === "text") {
250
- assistantChars += estimateTokens(p.text);
287
+ const t = estimateTokens(p.text);
288
+ assistantChars += t;
289
+ pushTopCandidate(topCandidates, "assistant", t);
251
290
  } else if (p?.type === "reasoning") {
252
- reasoningChars += estimateTokens(p.text);
291
+ const t = estimateTokens(p.text);
292
+ reasoningChars += t;
293
+ pushTopCandidate(topCandidates, "reasoning", t);
253
294
  } else if (p?.type === "tool-call") {
254
- toolArgsChars += estJson(p.input);
295
+ const t = estJson(p.input);
296
+ toolArgsChars += t;
297
+ pushTopCandidate(
298
+ topCandidates,
299
+ "args",
300
+ t,
301
+ p?.name,
302
+ extractToolTarget(p?.name, p?.input),
303
+ );
255
304
  if (p?.id != null && callById.size < MAX_WASTE_INSTANCES && !callById.has(p.id)) {
256
305
  callById.set(p.id, { name: p.name, input: p.input });
257
306
  }
@@ -265,6 +314,7 @@ function estimateBreakdownFromContext(context, sessionID) {
265
314
  let systemChars = 0;
266
315
  const system = Array.isArray(context?.system) ? context.system : [];
267
316
  for (const s of system) systemChars += estimateTokens(s?.text);
317
+ pushTopCandidate(topCandidates, "system", systemChars);
268
318
 
269
319
  let toolSchemasChars = 0;
270
320
  const tools = context?.tools;
@@ -302,6 +352,7 @@ function estimateBreakdownFromContext(context, sessionID) {
302
352
  systemChars,
303
353
  toolSchemasChars,
304
354
  reclaim,
355
+ top: selectTopConsumers(topCandidates),
305
356
  };
306
357
  }
307
358
 
@@ -442,22 +493,39 @@ function estimateBreakdownFromClientMessages(messages) {
442
493
  let toolOutChars = 0;
443
494
  const toolOutByTool = {};
444
495
  const instances = [];
496
+ const topCandidates = [];
445
497
  for (const m of messages || []) {
446
498
  if (!m || typeof m !== "object") continue;
447
499
  if (m.type === "user") {
448
- userChars += estimateTokens(m.text);
500
+ const t = estimateTokens(m.text);
501
+ userChars += t;
502
+ pushTopCandidate(topCandidates, "user", t);
449
503
  } else if (m.type === "assistant") {
450
504
  const parts = Array.isArray(m.content) ? m.content : [];
451
505
  for (const p of parts) {
452
506
  if (p?.type === "text") {
453
- assistantChars += estimateTokens(p.text);
507
+ const t = estimateTokens(p.text);
508
+ assistantChars += t;
509
+ pushTopCandidate(topCandidates, "assistant", t);
454
510
  } else if (p?.type === "reasoning") {
455
- reasoningChars += estimateTokens(p.text);
511
+ const t = estimateTokens(p.text);
512
+ reasoningChars += t;
513
+ pushTopCandidate(topCandidates, "reasoning", t);
456
514
  } else if (p?.type === "tool") {
457
515
  // state.input is a string while streaming; only count the resolved
458
516
  // object (running/completed/error) to avoid partial counts.
459
517
  const input = p?.state?.input;
460
- if (input && typeof input === "object") toolArgsChars += estJson(input);
518
+ if (input && typeof input === "object") {
519
+ const t = estJson(input);
520
+ toolArgsChars += t;
521
+ pushTopCandidate(
522
+ topCandidates,
523
+ "args",
524
+ t,
525
+ p?.name,
526
+ extractToolTarget(p?.name, input),
527
+ );
528
+ }
461
529
  // Tool results: completed / errored states carry the payload in
462
530
  // state.content (ToolContent[]: {type:"text",text}|{type:"file"}) —
463
531
  // NOT state.output (that field does not exist on SessionMessageInfo).
@@ -477,10 +545,12 @@ function estimateBreakdownFromClientMessages(messages) {
477
545
  // The persisted tool part carries its name on `p.name`
478
546
  // (SessionMessageAssistantTool.name) — same field as the hook path.
479
547
  addToolOut(toolOutByTool, p?.name, outChars);
548
+ const target = extractToolTarget(p?.name, input);
549
+ pushTopCandidate(topCandidates, "tool", outChars, p?.name, target);
480
550
  if (outChars > 0 && instances.length < MAX_WASTE_INSTANCES) {
481
551
  instances.push({
482
552
  name: p?.name || null,
483
- target: extractToolTarget(p?.name, input),
553
+ target,
484
554
  tokens: outChars,
485
555
  fp: contentFingerprint(text),
486
556
  });
@@ -502,6 +572,7 @@ function estimateBreakdownFromClientMessages(messages) {
502
572
  systemChars: null,
503
573
  toolSchemasChars: null,
504
574
  reclaim,
575
+ top: selectTopConsumers(topCandidates),
505
576
  };
506
577
  }
507
578
 
@@ -763,6 +834,10 @@ async function handleV2Event(ctx, event) {
763
834
  // stale entry lingers, via the phantom filter — a deleted session has no
764
835
  // step). No dedicated in-memory index to clean up.
765
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);
766
841
  return;
767
842
  }
768
843
 
@@ -852,7 +927,13 @@ async function handleV2Event(ctx, event) {
852
927
  } catch (err) {
853
928
  logErr("v2 final summary on idle failed", err);
854
929
  }
855
- clearSessionTracking(sid);
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);
856
937
  return;
857
938
  }
858
939
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "opencode-context-indicator",
3
- "version": "1.5.2",
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.",
3
+ "version": "1.5.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",
7
7
  "exports": {
@@ -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"
@@ -61,11 +62,11 @@
61
62
  }
62
63
  },
63
64
  "dependencies": {
64
- "@opentui/solid": "0.6.6"
65
+ "@opentui/solid": "0.6.8"
65
66
  },
66
67
  "devDependencies": {
67
- "@opencode/plugin": "2.1.2",
68
- "@opentui/core": "0.6.6",
68
+ "@opencode/plugin": "2.1.4",
69
+ "@opentui/core": "0.6.8",
69
70
  "@types/node": "26.6.3",
70
71
  "esbuild": "^0.28.2",
71
72
  "node-pty": "^1.1.0",
package/tui.tsx CHANGED
@@ -38,7 +38,7 @@
38
38
  *
39
39
  * Runtime requirements: @opentui/core and solid-js are optional peers resolved
40
40
  * by OpenCode at runtime. @opentui/solid is a pinned direct dependency (exact
41
- * 0.6.6): OpenCode's TUI loader does not expose a host instance of it, so for
41
+ * 0.6.8): OpenCode's TUI loader does not expose a host instance of it, so for
42
42
  * npm-installed plugins the JSX pragma would otherwise fail to resolve
43
43
  * `@opentui/solid/jsx-runtime` (upstream opencode issue #33884).
44
44
  *