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/CHANGELOG.md +72 -0
- package/README.md +91 -40
- package/docs/sidebar.png +0 -0
- package/index.js +22 -6
- package/lib/cache.js +37 -3
- package/lib/commands.js +170 -0
- package/lib/estimate.js +84 -0
- package/lib/limits.js +20 -11
- package/lib/state.js +100 -19
- package/lib/tui-panel.tsx +95 -3
- package/lib/v2.js +94 -13
- package/package.json +6 -5
- package/tui.tsx +1 -1
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,
|
|
@@ -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
|
|
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")
|
|
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
|
-
|
|
287
|
+
const t = estimateTokens(p.text);
|
|
288
|
+
assistantChars += t;
|
|
289
|
+
pushTopCandidate(topCandidates, "assistant", t);
|
|
251
290
|
} else if (p?.type === "reasoning") {
|
|
252
|
-
|
|
291
|
+
const t = estimateTokens(p.text);
|
|
292
|
+
reasoningChars += t;
|
|
293
|
+
pushTopCandidate(topCandidates, "reasoning", t);
|
|
253
294
|
} else if (p?.type === "tool-call") {
|
|
254
|
-
|
|
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
|
-
|
|
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
|
-
|
|
507
|
+
const t = estimateTokens(p.text);
|
|
508
|
+
assistantChars += t;
|
|
509
|
+
pushTopCandidate(topCandidates, "assistant", t);
|
|
454
510
|
} else if (p?.type === "reasoning") {
|
|
455
|
-
|
|
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")
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
65
|
+
"@opentui/solid": "0.6.8"
|
|
65
66
|
},
|
|
66
67
|
"devDependencies": {
|
|
67
|
-
"@opencode/plugin": "2.1.
|
|
68
|
-
"@opentui/core": "0.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.
|
|
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
|
*
|