opencode-context-indicator 1.2.0 → 1.2.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/README.md +13 -5
- package/index.js +8 -5
- package/lib/commands.js +15 -0
- package/lib/contributors.js +288 -0
- package/lib/limits.js +80 -23
- package/lib/state.js +16 -1
- package/package.json +3 -3
- package/tui.tsx +59 -268
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ arguments and the residual "other" bucket.
|
|
|
10
10
|
The package ships a **dual V1 + V2 plugin** (one entry works in both OpenCode
|
|
11
11
|
generations) plus an optional TUI module (`./tui` entry) that renders a **live
|
|
12
12
|
sidebar** and a **zero-LLM `/cx` breakdown panel** with measured contributors
|
|
13
|
-
and a redacted export. The V2 path is **tested against opencode v2.0.
|
|
13
|
+
and a redacted export. The V2 path is **tested against opencode v2.0.25**; the
|
|
14
14
|
V1 path against 1.18.29+.
|
|
15
15
|
|
|
16
16
|
> **V1 is feature-frozen.** It is kept working for compatibility (bug fixes
|
|
@@ -50,6 +50,14 @@ For every served request the plugin estimates how the context window is split:
|
|
|
50
50
|
* `ctx` is OpenCode's native overflow count: `tokens.total` when present, else
|
|
51
51
|
`input + output + cache.read + cache.write`. Percentages are **not clamped**
|
|
52
52
|
at 100% — exceeding the window stays visible.
|
|
53
|
+
* The denominator is the **opencode v2.0.23 compaction ceiling**: `window =
|
|
54
|
+
limit.input || limit.context`; `reserve = max(floor(window * 0.1), window >=
|
|
55
|
+
32_000 ? 16_000 : 0)`; the ceiling is `window - reserve`. When opencode
|
|
56
|
+
carries a `compaction.buffer` configured in its own settings, the plugin
|
|
57
|
+
cannot read that value (it is not exposed to plugins), so the default
|
|
58
|
+
reserve formula is used — set `OPENCODE_CONTEXT_INDICATOR_COMPACT_BUFFER`
|
|
59
|
+
to override the reserve for testing or custom setups (positive integer <=
|
|
60
|
+
1e9; invalid values are silently ignored).
|
|
53
61
|
|
|
54
62
|
### Toast (V1 only)
|
|
55
63
|
|
|
@@ -296,7 +304,7 @@ and `$TMPDIR` (usually `/tmp`) elsewhere:
|
|
|
296
304
|
| File | Purpose |
|
|
297
305
|
| --- | --- |
|
|
298
306
|
| `context-breakdown.log` | Human-readable live snapshot + bounded final summaries |
|
|
299
|
-
| `opencode-context-indicator-state.json` | Machine-readable snapshot consumed by the TUI sidebar and the `/cx` panel |
|
|
307
|
+
| `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,system,toolSchemas,other}`, `updatedAt`. |
|
|
300
308
|
| `context-events.log` | Raw event tap — **only** when `DEBUG_EVENTS` is flipped to `true` in the source (off by default) |
|
|
301
309
|
|
|
302
310
|
The live `context-breakdown.log` snapshot is **rewritten** (never grows); final
|
|
@@ -429,13 +437,13 @@ the sidebar instead.
|
|
|
429
437
|
gracefully (the breakdown falls back to throttled `session.messages` fetches
|
|
430
438
|
and toasts keep working).
|
|
431
439
|
* **OpenCode ≥ 2.0.16** for the V2 path (built and verified against
|
|
432
|
-
**2.0.
|
|
440
|
+
**2.0.25**).
|
|
433
441
|
* **TUI sidebar**: terminal TUI only, requires the OpenTUI rendering stack that
|
|
434
442
|
ships with OpenCode. `@opentui/core` and `solid-js` are **optional** peer
|
|
435
443
|
dependencies resolved by OpenCode at runtime; the main plugin installs and runs
|
|
436
444
|
fine without them (the sidebar simply is not available). `@opentui/solid` is
|
|
437
|
-
instead shipped as a **pinned direct dependency** (exact `0.5.
|
|
438
|
-
the OpenTUI stack OpenCode 2.0.
|
|
445
|
+
instead shipped as a **pinned direct dependency** (exact `0.5.16`, matching
|
|
446
|
+
the OpenTUI stack OpenCode 2.0.25 ships): OpenCode's
|
|
439
447
|
TUI loader does not expose a host instance of it, so for npm-installed plugins
|
|
440
448
|
the JSX pragma would otherwise fail to resolve `@opentui/solid/jsx-runtime`
|
|
441
449
|
(upstream: opencode issue #33884 — `node_modules` plugins are excluded from the
|
package/index.js
CHANGED
|
@@ -112,8 +112,8 @@
|
|
|
112
112
|
* Every live/final log write ALSO rewrites
|
|
113
113
|
* %TEMP%\opencode-context-indicator-state.json (os.tmpdir())
|
|
114
114
|
* — a machine-readable { sessionID: { model, providerID, ctx, usable,
|
|
115
|
-
* limit, categories{...}, updatedAt } } snapshot consumed by the
|
|
116
|
-
* TUI sidebar in ./tui.tsx (package entry "./tui"). Written atomically
|
|
115
|
+
* reserve, limit, categories{...}, updatedAt } } snapshot consumed by the
|
|
116
|
+
* optional TUI sidebar in ./tui.tsx (package entry "./tui"). Written atomically
|
|
117
117
|
* (temp + rename), keyed by session, bounded to MAX_TRACKED_SESSIONS, and
|
|
118
118
|
* strictly additive: any failure is swallowed and never affects the log.
|
|
119
119
|
*
|
|
@@ -123,9 +123,10 @@
|
|
|
123
123
|
*
|
|
124
124
|
* - Metric: the native opencode overflow count — tokens.total when present,
|
|
125
125
|
* else input + output + cache.read + cache.write — compared against the
|
|
126
|
-
* model's
|
|
127
|
-
|
|
128
|
-
|
|
126
|
+
* model's compaction ceiling (opencode v2.0.23 calculateCeiling): window =
|
|
127
|
+
* limit.input || limit.context, reserve = max(10% of window, 16k when
|
|
128
|
+
* window >= 32k), usable = window - reserve. Percentages are NOT
|
|
129
|
+
* clamped at 100% - exceeding the ceiling must stay visible.
|
|
129
130
|
* - Category estimates use a unicode-aware heuristic (see estimateTokens):
|
|
130
131
|
* Cyrillic ~2.5 chars/token, CJK ~1.5, latin/ASCII ~4. These are ESTIMATES,
|
|
131
132
|
* not a tokenizer; "other" is the residual input − sum of estimates.
|
|
@@ -165,6 +166,7 @@ import {
|
|
|
165
166
|
getUsableContext,
|
|
166
167
|
hydrateModelLimitsFromState,
|
|
167
168
|
modelLimits,
|
|
169
|
+
reserveFor,
|
|
168
170
|
} from "./lib/limits.js";
|
|
169
171
|
import { sessionAgentCache, sessionParentCache } from "./lib/cache.js";
|
|
170
172
|
import {
|
|
@@ -189,6 +191,7 @@ export {
|
|
|
189
191
|
hydrateModelLimitsFromState,
|
|
190
192
|
getUsableContext,
|
|
191
193
|
getModelLimit,
|
|
194
|
+
reserveFor,
|
|
192
195
|
collectSessionReport,
|
|
193
196
|
renderSessionRow,
|
|
194
197
|
shortSummaryText,
|
package/lib/commands.js
CHANGED
|
@@ -272,6 +272,12 @@ async function collectSessionReport(ctx, sessionID, state, deadline) {
|
|
|
272
272
|
const usableInfo = getUsableContext(providerID, modelID);
|
|
273
273
|
const usable =
|
|
274
274
|
usableInfo?.usable ?? entry?.usable ?? getModelLimit(providerID, modelID) ?? null;
|
|
275
|
+
const reserve =
|
|
276
|
+
usableInfo?.reserved != null
|
|
277
|
+
? usableInfo.reserved
|
|
278
|
+
: Number.isFinite(entry?.reserve) && entry.reserve > 0
|
|
279
|
+
? entry.reserve
|
|
280
|
+
: null;
|
|
275
281
|
const parentID = entry?.parentID ?? null;
|
|
276
282
|
const updatedAt =
|
|
277
283
|
entry?.updatedAt ||
|
|
@@ -284,6 +290,7 @@ async function collectSessionReport(ctx, sessionID, state, deadline) {
|
|
|
284
290
|
providerID,
|
|
285
291
|
ctxTokens,
|
|
286
292
|
usable,
|
|
293
|
+
reserve,
|
|
287
294
|
categories,
|
|
288
295
|
source,
|
|
289
296
|
updatedAt,
|
|
@@ -384,6 +391,14 @@ function shortSummaryText(r) {
|
|
|
384
391
|
} else {
|
|
385
392
|
lines.push("_category breakdown unavailable (no cache/state entry yet)_");
|
|
386
393
|
}
|
|
394
|
+
// Compact-at line: show the new ceiling and, when known, the reserve.
|
|
395
|
+
if (r.usable && r.usable > 0) {
|
|
396
|
+
let compact = `compact at ${fmt(r.usable)}`;
|
|
397
|
+
if (r.reserve != null && r.reserve > 0) {
|
|
398
|
+
compact += ` (${fmt(r.reserve)} reserve)`;
|
|
399
|
+
}
|
|
400
|
+
lines.push(compact);
|
|
401
|
+
}
|
|
387
402
|
lines.push(`updated ${timeOnly(r.updatedAt)} (${sourceTag(r.source)})`);
|
|
388
403
|
return lines.join("\n");
|
|
389
404
|
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// lib/contributors.js
|
|
2
|
+
//
|
|
3
|
+
// Pure contributor-measurement helpers extracted from tui.tsx for unit testing.
|
|
4
|
+
// All functions are stateless transforms of their arguments.
|
|
5
|
+
//
|
|
6
|
+
// Extracted 1:1 (behaviour + WHY comments, stripped of TS syntax):
|
|
7
|
+
// estimateTokensLocal, asRecord, safeText, measureContributors,
|
|
8
|
+
// contributorLine, exportFileName, buildRedactedPayload, panelDenom, panelRole.
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Token estimation (mirrors lib/estimate.js but self-contained — tui.tsx
|
|
12
|
+
// loads standalone and must NOT import lib/*).
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
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
|
+
// Live message data is untrusted: coerce every field defensively, never throw.
|
|
49
|
+
function asRecord(v) {
|
|
50
|
+
return v && typeof v === "object" ? v : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// One-line sanitisation for untrusted labels (agent / model). Drops zero-width
|
|
54
|
+
// + bidi control chars, collapses C0/C1 controls, caps at 32 code points
|
|
55
|
+
// (surrogate-safe). Only labels are ever rendered — never message content.
|
|
56
|
+
function safeText(v) {
|
|
57
|
+
try {
|
|
58
|
+
const s = v == null ? "" : String(v);
|
|
59
|
+
const cleaned = s
|
|
60
|
+
.replace(/[\u200B-\u200F\u202A-\u202E\u2066-\u2069]/g, "")
|
|
61
|
+
.replace(/[\u0000-\u001F\u007F-\u009F\u2028\u2029]/g, " ");
|
|
62
|
+
const cps = Array.from(cleaned);
|
|
63
|
+
return cps.length > 32 ? cps.slice(0, 31).join("") + "~" : cleaned;
|
|
64
|
+
} catch {
|
|
65
|
+
return "";
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Contributor measurement
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
// Char counts per (message, bucket). `asst.text` = assistant text,
|
|
74
|
+
// `asst.rsn` = reasoning, `asst.tool` = tool CALL arguments (streaming raw
|
|
75
|
+
// string or JSON.stringify of the input object), `asst.result` = tool RESULT
|
|
76
|
+
// outputs (text content of completed/error tool states). Sorted descending by
|
|
77
|
+
// estimated tokens.
|
|
78
|
+
//
|
|
79
|
+
// Returns an array of plain objects (no class/prototype dependency):
|
|
80
|
+
// { kind, ref, chars, tokensEstimate, tool, preview }
|
|
81
|
+
function measureContributors(messages) {
|
|
82
|
+
const out = [];
|
|
83
|
+
// Bounded preview: control chars collapse to spaces, bidi/zero-width stripped,
|
|
84
|
+
// capped at 26 code points with a "~" tail. Local rendering only.
|
|
85
|
+
const previewOf = (sample) => {
|
|
86
|
+
const cleaned = sample
|
|
87
|
+
.replace(/[\u200B-\u200F\u202A-\u202E\u2066-\u2069]/g, "")
|
|
88
|
+
.replace(/[\u0000-\u001F\u007F-\u009F\u2028\u2029]+/g, " ")
|
|
89
|
+
.trim();
|
|
90
|
+
const cps = Array.from(cleaned);
|
|
91
|
+
return cps.length > 26 ? cps.slice(0, 25).join("") + "~" : cleaned;
|
|
92
|
+
};
|
|
93
|
+
const add = (kind, ref, sample, tool, previewOverride) => {
|
|
94
|
+
if (sample.length === 0) return;
|
|
95
|
+
out.push({
|
|
96
|
+
kind,
|
|
97
|
+
ref,
|
|
98
|
+
chars: sample.length,
|
|
99
|
+
tokensEstimate: estimateTokensLocal(sample),
|
|
100
|
+
tool: tool || "",
|
|
101
|
+
preview: previewOverride !== undefined ? previewOverride : previewOf(sample),
|
|
102
|
+
});
|
|
103
|
+
};
|
|
104
|
+
// Human clock ref: "21:04" from the message's own creation time — far more
|
|
105
|
+
// actionable than an array ordinal. Falls back to "#N" when time is absent.
|
|
106
|
+
const hhmm = (ms) => {
|
|
107
|
+
if (typeof ms !== "number" || !Number.isFinite(ms) || ms <= 0) return "";
|
|
108
|
+
try {
|
|
109
|
+
const d = new Date(ms);
|
|
110
|
+
if (Number.isNaN(d.getTime())) return "";
|
|
111
|
+
const pad = (x) => String(x).padStart(2, "0");
|
|
112
|
+
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
113
|
+
} catch {
|
|
114
|
+
return "";
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
for (let i = 0; i < messages.length; i++) {
|
|
118
|
+
const m = asRecord(messages[i]);
|
|
119
|
+
if (!m) continue;
|
|
120
|
+
const timeRec = asRecord(m.time);
|
|
121
|
+
const ref = hhmm(timeRec && timeRec.created) || `#${i + 1}`;
|
|
122
|
+
const type = typeof m.type === "string" ? m.type : "";
|
|
123
|
+
if (type === "user") {
|
|
124
|
+
if (typeof m.text === "string") add("user.text", ref, m.text);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (type !== "assistant") continue;
|
|
128
|
+
const content = Array.isArray(m.content) ? m.content : [];
|
|
129
|
+
let text = "";
|
|
130
|
+
let rsn = "";
|
|
131
|
+
for (const rawPart of content) {
|
|
132
|
+
const part = asRecord(rawPart);
|
|
133
|
+
if (!part) continue;
|
|
134
|
+
const pt = typeof part.type === "string" ? part.type : "";
|
|
135
|
+
if (pt === "text") {
|
|
136
|
+
if (typeof part.text === "string") text += part.text;
|
|
137
|
+
} else if (pt === "reasoning") {
|
|
138
|
+
if (typeof part.text === "string") rsn += part.text;
|
|
139
|
+
} else if (pt === "tool") {
|
|
140
|
+
const state = asRecord(part.state);
|
|
141
|
+
if (!state) continue;
|
|
142
|
+
// Per-tool-part contributors: each tool call carries its own name and
|
|
143
|
+
// measured size (one message can hold several tool calls). The raw
|
|
144
|
+
// args JSON is measured but NOT previewed — the tool name is the
|
|
145
|
+
// useful identifier; result text previews fine.
|
|
146
|
+
const toolName =
|
|
147
|
+
typeof part.name === "string" && part.name ? safeText(part.name) : "";
|
|
148
|
+
let toolArgs = "";
|
|
149
|
+
let result = "";
|
|
150
|
+
if (typeof state.input === "string") {
|
|
151
|
+
toolArgs += state.input;
|
|
152
|
+
} else if (state.input != null) {
|
|
153
|
+
try {
|
|
154
|
+
toolArgs += JSON.stringify(state.input) || "";
|
|
155
|
+
} catch {
|
|
156
|
+
/* unstringifiable input: skip its chars */
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const c = state.content;
|
|
160
|
+
if (Array.isArray(c)) {
|
|
161
|
+
for (const rawItem of c) {
|
|
162
|
+
const item = asRecord(rawItem);
|
|
163
|
+
if (item && item.type === "text" && typeof item.text === "string")
|
|
164
|
+
result += item.text;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
add("asst.tool", ref, toolArgs, toolName, ""); // raw args: no preview
|
|
168
|
+
add("asst.result", ref, result, toolName);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
add("asst.text", ref, text);
|
|
172
|
+
add("asst.rsn", ref, rsn);
|
|
173
|
+
}
|
|
174
|
+
return out.sort((a, b) => b.tokensEstimate - a.tokensEstimate);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Human line for one measured contributor: role in full words, the message's
|
|
178
|
+
// own clock time, the bucket (with the tool name for tool buckets), the token
|
|
179
|
+
// estimate, and a bounded single-line preview. Never contains a full session id
|
|
180
|
+
// or file paths.
|
|
181
|
+
function contributorLine(c, rank) {
|
|
182
|
+
const dot = c.kind.indexOf(".");
|
|
183
|
+
const who = dot >= 0 ? c.kind.slice(0, dot) : c.kind;
|
|
184
|
+
const bucket = dot >= 0 ? c.kind.slice(dot + 1) : "";
|
|
185
|
+
const role = who === "asst" ? "assistant" : who;
|
|
186
|
+
const what =
|
|
187
|
+
bucket === "rsn"
|
|
188
|
+
? "reasoning"
|
|
189
|
+
: bucket === "tool"
|
|
190
|
+
? "tool args"
|
|
191
|
+
: bucket === "result"
|
|
192
|
+
? "tool result"
|
|
193
|
+
: "";
|
|
194
|
+
const tool = c.tool ? ` (${c.tool})` : "";
|
|
195
|
+
const whatFull = what ? `${what}${tool}` : "";
|
|
196
|
+
const head = `${rank}. ${role} ${c.ref}${whatFull ? ` ${whatFull}` : ""} \u2014 ${c.tokensEstimate}`;
|
|
197
|
+
return c.preview ? `${head} "${c.preview}"` : head;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
// Redacted export helpers (pure)
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
// Filename for a redacted export snapshot:
|
|
205
|
+
// context-<id8>-<yyyymmdd-HHMMSSmmm>.json
|
|
206
|
+
function exportFileName(sessionID, now) {
|
|
207
|
+
const id8 = String(sessionID).slice(0, 8);
|
|
208
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
209
|
+
const stamp =
|
|
210
|
+
`${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` +
|
|
211
|
+
`-${pad(now.getHours())}${pad(now.getMinutes())}` +
|
|
212
|
+
`${pad(now.getSeconds())}${String(now.getMilliseconds()).padStart(3, "0")}`;
|
|
213
|
+
return `context-${id8}-${stamp}.json`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Build the redacted export payload. All IO (mkdirSync/writeFileSync) and
|
|
217
|
+
// toasts stay in tui.tsx as the thin exportRedacted() wrapper.
|
|
218
|
+
//
|
|
219
|
+
// Fields: generatedAt, session{id8-prefixed id, model, providerID},
|
|
220
|
+
// family[role/model/ctx/usable/limit/pct/updated], contributors[kind/tool/ref/
|
|
221
|
+
// chars/tokensEstimate]. NO message text, NO full session id, NO tool output
|
|
222
|
+
// content, NO contributor preview text.
|
|
223
|
+
//
|
|
224
|
+
// panelDenom / panelRole are passed as parameters so this file stays pure and
|
|
225
|
+
// tui.tsx controls their source.
|
|
226
|
+
function buildRedactedPayload({ sessionID, root, rows, contributors, now, panelDenom, panelRole, num }) {
|
|
227
|
+
const id8 = String(sessionID).slice(0, 8);
|
|
228
|
+
return {
|
|
229
|
+
generatedAt: now.toISOString(),
|
|
230
|
+
session: {
|
|
231
|
+
id: `${id8}...`,
|
|
232
|
+
model: safeText(root && root.model != null ? root.model : ""),
|
|
233
|
+
providerID: safeText(root && root.providerID != null ? root.providerID : ""),
|
|
234
|
+
},
|
|
235
|
+
family: rows.map((e, i) => {
|
|
236
|
+
const d = panelDenom(e);
|
|
237
|
+
const ctxVal = num(e && e.ctx);
|
|
238
|
+
return {
|
|
239
|
+
role: panelRole(e, i === 0),
|
|
240
|
+
model: safeText(e && e.model != null ? e.model : ""),
|
|
241
|
+
ctx: ctxVal,
|
|
242
|
+
usable: e && e.usable != null ? num(e.usable) : null,
|
|
243
|
+
limit: e && e.limit != null ? num(e.limit) : null,
|
|
244
|
+
pct: d > 0 ? Math.round((ctxVal / d) * 100) : null,
|
|
245
|
+
updated: safeText(e && e.updatedAt != null ? e.updatedAt : ""),
|
|
246
|
+
};
|
|
247
|
+
}),
|
|
248
|
+
contributors: contributors.map((c) => ({
|
|
249
|
+
kind: c.kind,
|
|
250
|
+
tool: c.tool || null,
|
|
251
|
+
ref: c.ref,
|
|
252
|
+
chars: c.chars,
|
|
253
|
+
tokensEstimate: c.tokensEstimate,
|
|
254
|
+
})),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
// Panel helpers (pure, shared with tui.tsx)
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
// Per-row denominator with the same fallback cascade as the server table:
|
|
263
|
+
// usable, else limit, else 0.
|
|
264
|
+
function panelDenom(e) {
|
|
265
|
+
const u = e && typeof e.usable === "number" && e.usable > 0 ? e.usable : 0;
|
|
266
|
+
if (u > 0) return u;
|
|
267
|
+
const l = e && typeof e.limit === "number" && e.limit > 0 ? e.limit : 0;
|
|
268
|
+
return l;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Role label for a state entry: "main" for root, "sub" or "sub:<agent>" for
|
|
272
|
+
// descendants.
|
|
273
|
+
function panelRole(e, isMain) {
|
|
274
|
+
if (isMain) return "main";
|
|
275
|
+
return e && e.agent ? `sub:${safeText(e.agent)}` : "sub";
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export {
|
|
279
|
+
estimateTokensLocal,
|
|
280
|
+
asRecord,
|
|
281
|
+
safeText,
|
|
282
|
+
measureContributors,
|
|
283
|
+
contributorLine,
|
|
284
|
+
exportFileName,
|
|
285
|
+
buildRedactedPayload,
|
|
286
|
+
panelDenom,
|
|
287
|
+
panelRole,
|
|
288
|
+
};
|
package/lib/limits.js
CHANGED
|
@@ -6,6 +6,16 @@
|
|
|
6
6
|
// client.config.providers(), ensureModelLimits for V2 via ctx.model.list()).
|
|
7
7
|
// hydrateModelLimitsFromState() seeds the maps from the shared state.json
|
|
8
8
|
// snapshot via ./state.js. Extracted 1:1 (code + WHY comments) from index.js.
|
|
9
|
+
//
|
|
10
|
+
// Compaction ceiling (v2.0.23): opencode's core calculateCeiling() in
|
|
11
|
+
// packages/core/src/session/compaction.ts derives the auto-compaction trigger
|
|
12
|
+
// ceiling as window - reserve, where window = limit.input || limit.context
|
|
13
|
+
// (null/zero falls through to the context window) and
|
|
14
|
+
// reserve = max(floor(window * 0.1), window >= 32_000 ? 16_000 : 0)
|
|
15
|
+
// A compaction.buffer configured in opencode itself is NOT visible to the
|
|
16
|
+
// plugin (the plugin cannot read opencode's internal compaction config); the
|
|
17
|
+
// env var OPENCODE_CONTEXT_INDICATOR_COMPACT_BUFFER overrides the reserve for
|
|
18
|
+
// testing and power users only (positive finite integer <= 1e9, ignore invalid).
|
|
9
19
|
|
|
10
20
|
import { finitePos, withTimeout } from "./estimate.js";
|
|
11
21
|
import { logErr, readStateSnapshot } from "./state.js";
|
|
@@ -67,32 +77,73 @@ function getModelLimit(providerID, modelID) {
|
|
|
67
77
|
return typeof rec.context === "number" && rec.context > 0 ? rec.context : null;
|
|
68
78
|
}
|
|
69
79
|
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
80
|
+
// Compute the reserve for a given window, matching opencode v2.0.23
|
|
81
|
+
// packages/core/src/session/compaction.ts calculateCeiling(). Returns null when
|
|
82
|
+
// window is not a finite positive number.
|
|
83
|
+
function reserveFor(window, bufferOverride) {
|
|
84
|
+
if (
|
|
85
|
+
typeof window !== "number" ||
|
|
86
|
+
!Number.isFinite(window) ||
|
|
87
|
+
window <= 0
|
|
88
|
+
) return null;
|
|
89
|
+
// bufferOverride: accept only finite, non-negative integers <= 1e9.
|
|
90
|
+
if (
|
|
91
|
+
typeof bufferOverride === "number" &&
|
|
92
|
+
Number.isFinite(bufferOverride) &&
|
|
93
|
+
bufferOverride >= 0 &&
|
|
94
|
+
bufferOverride <= 1e9
|
|
95
|
+
) {
|
|
96
|
+
return bufferOverride;
|
|
97
|
+
}
|
|
98
|
+
// Default reserve formula (mirror of opencode core):
|
|
99
|
+
// max(floor(window * 0.1), window >= 32_000 ? 16_000 : 0)
|
|
100
|
+
return Math.max(
|
|
101
|
+
Math.floor(window * 0.1),
|
|
102
|
+
window >= 32_000 ? 16_000 : 0,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Native opencode compaction ceiling (v2.0.23 calculateCeiling):
|
|
107
|
+
// window = limit.input || limit.context (zero/null falls through to context)
|
|
108
|
+
// reserve = reserveFor(window) [or bufferOverride when provided]
|
|
109
|
+
// usable = window - reserve
|
|
110
|
+
// Returns { usable, limit: window, maxOutput, reserved } or null when not
|
|
111
|
+
// derivable (callers then degrade to count / limit.context, or no percentage).
|
|
112
|
+
// The per-model compaction.reserved and the old min(20000, maxOutput) no longer
|
|
113
|
+
// participate in this calculation; opencode's internal buffer is not visible.
|
|
114
|
+
function getUsableContext(providerID, modelID, bufferOverride) {
|
|
77
115
|
const rec = getModelLimitRecord(providerID, modelID);
|
|
78
116
|
if (!rec) return null;
|
|
79
117
|
const context =
|
|
80
118
|
typeof rec.context === "number" && rec.context > 0 ? rec.context : null;
|
|
81
119
|
const input =
|
|
82
120
|
typeof rec.input === "number" && rec.input > 0 ? rec.input : null;
|
|
121
|
+
if (input == null && context == null) return null;
|
|
122
|
+
// Mirror core's limit.input || limit.context (zero/null falls through to context).
|
|
123
|
+
const window = input != null && input > 0 ? input : context;
|
|
124
|
+
if (window == null || !(window > 0)) return null;
|
|
125
|
+
|
|
126
|
+
// When bufferOverride is omitted, read it from the env var once per call.
|
|
127
|
+
let resolvedBuffer = bufferOverride;
|
|
128
|
+
if (resolvedBuffer === undefined) {
|
|
129
|
+
const raw = process.env.OPENCODE_CONTEXT_INDICATOR_COMPACT_BUFFER;
|
|
130
|
+
// Strict digits-only: reject "5000abc", "5e3", " 5000", "", "-1" and other
|
|
131
|
+
// partial parses (parseInt/parseFloat stop at the first non-digit).
|
|
132
|
+
if (typeof raw === "string" && /^\d+$/.test(raw)) {
|
|
133
|
+
const parsed = parseInt(raw, 10);
|
|
134
|
+
if (Number.isFinite(parsed) && parsed >= 0 && parsed <= 1e9) {
|
|
135
|
+
resolvedBuffer = parsed;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const reserve = reserveFor(window, resolvedBuffer);
|
|
141
|
+
if (reserve == null) return null;
|
|
142
|
+
const usable = window - reserve;
|
|
143
|
+
if (usable <= 0) return null;
|
|
83
144
|
const maxOutput =
|
|
84
145
|
typeof rec.output === "number" && rec.output > 0 ? rec.output : 0;
|
|
85
|
-
|
|
86
|
-
// Mirrors opencode overflow.ts: reserved = min(20000, maxOutput), i.e. 0 when
|
|
87
|
-
// the model declares no output limit (NOT a 20000 default — that would
|
|
88
|
-
// understate the usable window).
|
|
89
|
-
const reserved =
|
|
90
|
-
typeof rec.reserved === "number" && rec.reserved >= 0
|
|
91
|
-
? rec.reserved
|
|
92
|
-
: Math.min(20000, maxOutput);
|
|
93
|
-
const usable = input != null ? input - reserved : context - maxOutput;
|
|
94
|
-
if (!(usable > 0)) return null;
|
|
95
|
-
return { usable, limit: context, maxOutput, reserved };
|
|
146
|
+
return { usable, limit: window, maxOutput, reserved: reserve };
|
|
96
147
|
}
|
|
97
148
|
|
|
98
149
|
// Store a model limit record under `<providerID>:<modelID>`. Written by
|
|
@@ -366,13 +417,18 @@ function hydrateModelLimitsFromState() {
|
|
|
366
417
|
}
|
|
367
418
|
}
|
|
368
419
|
|
|
369
|
-
// Build the record:
|
|
370
|
-
//
|
|
420
|
+
// Build the record: seed the raw window (context, preferring limit when
|
|
421
|
+
// present) so the NEW formula derives the ceiling fresh on every call.
|
|
422
|
+
// The old precise-usable-as-input seeding (which caused double-reserve
|
|
423
|
+
// under the new formula) is retired: getUsableContext is now a pure
|
|
424
|
+
// function of the window and needs no pre-derived state.
|
|
371
425
|
let rec = null;
|
|
372
|
-
if (best.
|
|
373
|
-
rec = { context: best.limit, input: best.usable, output: null, reserved: 0 };
|
|
374
|
-
} else if (best.limit != null) {
|
|
426
|
+
if (best.limit != null) {
|
|
375
427
|
rec = { context: best.limit, input: null, output: null, reserved: null };
|
|
428
|
+
} else if (best.usable != null) {
|
|
429
|
+
// No limit in snapshot: fall back to persisted usable as a best-effort
|
|
430
|
+
// window (it was the ceiling before the formula change, close enough).
|
|
431
|
+
rec = { context: best.usable, input: null, output: null, reserved: null };
|
|
376
432
|
}
|
|
377
433
|
if (rec == null) continue;
|
|
378
434
|
modelLimits.set(key, rec);
|
|
@@ -396,6 +452,7 @@ export {
|
|
|
396
452
|
modelKey,
|
|
397
453
|
getModelLimit,
|
|
398
454
|
getUsableContext,
|
|
455
|
+
reserveFor,
|
|
399
456
|
setModelLimit,
|
|
400
457
|
ensureProviderLimits,
|
|
401
458
|
ensureModelLimits,
|
package/lib/state.js
CHANGED
|
@@ -331,14 +331,23 @@ function writeStateFile(
|
|
|
331
331
|
}
|
|
332
332
|
try {
|
|
333
333
|
const bd = breakdownCache.get(sessionID) || null;
|
|
334
|
-
const limit = getModelLimit(providerID, modelID);
|
|
335
334
|
const usableInfo = getUsableContext(providerID, modelID);
|
|
335
|
+
// Persist the EFFECTIVE window (limit.input || limit.context — the same
|
|
336
|
+
// window the ceiling was derived from), not the raw context window: on a
|
|
337
|
+
// cold instance hydrateModelLimitsFromState seeds context = limit, so a
|
|
338
|
+
// mismatched limit would re-derive a WRONG (too large) ceiling for models
|
|
339
|
+
// where limit.input < limit.context.
|
|
340
|
+
const limit =
|
|
341
|
+
usableInfo != null && usableInfo.limit != null
|
|
342
|
+
? usableInfo.limit
|
|
343
|
+
: getModelLimit(providerID, modelID);
|
|
336
344
|
const denom =
|
|
337
345
|
usableInfo != null
|
|
338
346
|
? usableInfo.usable
|
|
339
347
|
: limit != null && limit > 0
|
|
340
348
|
? limit
|
|
341
349
|
: null;
|
|
350
|
+
const reserve = usableInfo != null ? usableInfo.reserved : null;
|
|
342
351
|
const total =
|
|
343
352
|
typeof ctxTokens === "number" && ctxTokens > 0
|
|
344
353
|
? ctxTokens
|
|
@@ -433,6 +442,12 @@ function writeStateFile(
|
|
|
433
442
|
: sameModelAsPrev && finitePos(prevEntry.usable)
|
|
434
443
|
? prevEntry.usable
|
|
435
444
|
: null,
|
|
445
|
+
reserve:
|
|
446
|
+
reserve != null
|
|
447
|
+
? reserve
|
|
448
|
+
: sameModelAsPrev && finitePos(prevEntry.reserve)
|
|
449
|
+
? prevEntry.reserve
|
|
450
|
+
: null,
|
|
436
451
|
limit:
|
|
437
452
|
limit != null
|
|
438
453
|
? limit
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-context-indicator",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"description": "Real-time OpenCode context-window usage indicator: per-category token breakdown written to a log file, plus an optional TUI sidebar. Dual V1 + V2 plugin entry.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -56,11 +56,11 @@
|
|
|
56
56
|
}
|
|
57
57
|
},
|
|
58
58
|
"dependencies": {
|
|
59
|
-
"@opentui/solid": "0.5.
|
|
59
|
+
"@opentui/solid": "0.5.16"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
62
|
"@opencode/plugin": "^2.0.19",
|
|
63
|
-
"@opentui/core": "0.5.
|
|
63
|
+
"@opentui/core": "0.5.16",
|
|
64
64
|
"esbuild": "^0.28.2",
|
|
65
65
|
"node-pty": "^1.1.0",
|
|
66
66
|
"solid-js": "^1.9.12"
|
package/tui.tsx
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
*
|
|
28
28
|
* Runtime requirements: @opentui/core and solid-js are optional peers resolved
|
|
29
29
|
* by OpenCode at runtime. @opentui/solid is a pinned direct dependency (exact
|
|
30
|
-
* 0.5.
|
|
30
|
+
* 0.5.16): OpenCode's TUI loader does not expose a host instance of it, so for
|
|
31
31
|
* npm-installed plugins the JSX pragma would otherwise fail to resolve
|
|
32
32
|
* `@opentui/solid/jsx-runtime` (upstream opencode issue #33884).
|
|
33
33
|
*
|
|
@@ -47,6 +47,23 @@ import {mkdirSync, readFileSync, writeFileSync} from "node:fs"
|
|
|
47
47
|
import {tmpdir} from "node:os"
|
|
48
48
|
import {basename, join} from "node:path"
|
|
49
49
|
import {fileURLToPath} from "node:url"
|
|
50
|
+
type Contributor = {
|
|
51
|
+
kind: string
|
|
52
|
+
ref: string
|
|
53
|
+
chars: number
|
|
54
|
+
tokensEstimate: number
|
|
55
|
+
tool: string
|
|
56
|
+
preview: string
|
|
57
|
+
}
|
|
58
|
+
import {
|
|
59
|
+
safeText,
|
|
60
|
+
measureContributors,
|
|
61
|
+
contributorLine,
|
|
62
|
+
exportFileName,
|
|
63
|
+
buildRedactedPayload,
|
|
64
|
+
panelDenom,
|
|
65
|
+
panelRole,
|
|
66
|
+
} from "./lib/contributors.js"
|
|
50
67
|
|
|
51
68
|
const STATE_FILE =
|
|
52
69
|
process.env.OPENCODE_CONTEXT_INDICATOR_STATE_FILE ||
|
|
@@ -92,6 +109,7 @@ type StateEntry = {
|
|
|
92
109
|
ctx?: number
|
|
93
110
|
input?: number
|
|
94
111
|
usable?: number | null
|
|
112
|
+
reserve?: number | null
|
|
95
113
|
limit?: number | null
|
|
96
114
|
reasoning?: number
|
|
97
115
|
categories?: Categories
|
|
@@ -202,24 +220,6 @@ function collectFamily(
|
|
|
202
220
|
return out
|
|
203
221
|
}
|
|
204
222
|
|
|
205
|
-
// One-line sanitisation for untrusted labels (agent / model) rendered in the
|
|
206
|
-
// panel: drop zero-width + bidi control chars, collapse C0/C1 controls (incl.
|
|
207
|
-
// CR/LF/TAB) and the line/paragraph separators to spaces, then cap the result at
|
|
208
|
-
// 32 code points (code-point safe). Only labels are ever rendered — never
|
|
209
|
-
// message content.
|
|
210
|
-
function safeText(v: unknown): string {
|
|
211
|
-
try {
|
|
212
|
-
const s = v == null ? "" : String(v)
|
|
213
|
-
const cleaned = s
|
|
214
|
-
.replace(/[\u200B-\u200F\u202A-\u202E\u2066-\u2069]/g, "")
|
|
215
|
-
.replace(/[\u0000-\u001F\u007F-\u009F\u2028\u2029]/g, " ")
|
|
216
|
-
const cps = Array.from(cleaned)
|
|
217
|
-
return cps.length > 32 ? `${cps.slice(0, 31).join("")}~` : cleaned
|
|
218
|
-
} catch {
|
|
219
|
-
return ""
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
223
|
function updatedAt(iso: string | undefined): string {
|
|
224
224
|
if (!iso) return "?"
|
|
225
225
|
try {
|
|
@@ -250,188 +250,6 @@ function fit(s: string, w: number): string {
|
|
|
250
250
|
// see them — which is what makes the residual `other` explicable.
|
|
251
251
|
// ---------------------------------------------------------------------------
|
|
252
252
|
|
|
253
|
-
type Contributor = {
|
|
254
|
-
kind: string
|
|
255
|
-
ref: string
|
|
256
|
-
chars: number
|
|
257
|
-
tokensEstimate: number
|
|
258
|
-
// Tool name for the tool-args / tool-result buckets (e.g. "bash"), else "".
|
|
259
|
-
tool: string
|
|
260
|
-
// First characters of THIS bucket's text, control chars stripped. Rendered
|
|
261
|
-
// on the user's own screen only — exportRedacted() never includes it.
|
|
262
|
-
preview: string
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
// Local copy of the token-estimate core (estimateTokens in lib/estimate.js).
|
|
266
|
-
// tui.tsx is loaded standalone and must NOT import lib/*, so the ~20-line
|
|
267
|
-
// three-density unicode heuristic is duplicated on purpose: Cyrillic ~2.5,
|
|
268
|
-
// CJK ~1.5, latin/ASCII ~4 chars/token. Heuristic, not a tokenizer.
|
|
269
|
-
function estimateTokensLocal(s: string): number {
|
|
270
|
-
let other = 0
|
|
271
|
-
let cyr = 0
|
|
272
|
-
let cjk = 0
|
|
273
|
-
for (let i = 0; i < s.length; i++) {
|
|
274
|
-
const cp = s.codePointAt(i) as number
|
|
275
|
-
if (cp > 0xffff) i++ // astral pair: consume the low surrogate
|
|
276
|
-
if (cp < 0x80) other++
|
|
277
|
-
else if (
|
|
278
|
-
(cp >= 0x0400 && cp <= 0x04ff) || // Cyrillic
|
|
279
|
-
(cp >= 0x0500 && cp <= 0x052f) || // Cyrillic Supplement
|
|
280
|
-
(cp >= 0x2de0 && cp <= 0x2dff) || // Cyrillic Extended-A
|
|
281
|
-
(cp >= 0xa640 && cp <= 0xa69f) || // Cyrillic Extended-B
|
|
282
|
-
(cp >= 0x1c80 && cp <= 0x1c8f) // Cyrillic Extended-C
|
|
283
|
-
)
|
|
284
|
-
cyr++
|
|
285
|
-
else if (
|
|
286
|
-
(cp >= 0x4e00 && cp <= 0x9fff) || // CJK Unified Ideographs
|
|
287
|
-
(cp >= 0x3400 && cp <= 0x4dbf) || // CJK Extension A
|
|
288
|
-
(cp >= 0xf900 && cp <= 0xfaff) || // CJK Compatibility Ideographs
|
|
289
|
-
(cp >= 0x3040 && cp <= 0x30ff) || // Hiragana + Katakana
|
|
290
|
-
(cp >= 0xac00 && cp <= 0xd7af) || // Hangul Syllables
|
|
291
|
-
(cp >= 0x3000 && cp <= 0x303f) || // CJK Symbols and Punctuation
|
|
292
|
-
(cp >= 0xff00 && cp <= 0xffef) // Halfwidth/Fullwidth Forms
|
|
293
|
-
)
|
|
294
|
-
cjk++
|
|
295
|
-
else other++
|
|
296
|
-
}
|
|
297
|
-
return Math.round(other / 4 + cyr / 2.5 + cjk / 1.5)
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// Live message data is untrusted: coerce every field defensively, never throw.
|
|
301
|
-
function asRecord(v: unknown): Record<string, unknown> | null {
|
|
302
|
-
return v && typeof v === "object" ? (v as Record<string, unknown>) : null
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
// Char counts per (message, bucket). `asst.text` = assistant text,
|
|
306
|
-
// `asst.rsn` = reasoning, `asst.tool` = tool CALL arguments (streaming raw
|
|
307
|
-
// string or JSON.stringify of the input object), `asst.result` = tool RESULT
|
|
308
|
-
// outputs (text content of completed/error tool states). Sorted descending by
|
|
309
|
-
// estimated tokens.
|
|
310
|
-
function measureContributors(messages: readonly unknown[]): Contributor[] {
|
|
311
|
-
const out: Contributor[] = []
|
|
312
|
-
// Single-line, bounded preview of the bucket text: control chars (incl.
|
|
313
|
-
// CR/LF/TAB and line separators) collapse to spaces, bidi/zero-width stripped,
|
|
314
|
-
// capped at 26 code points with a "~" tail. Local rendering only. An explicit
|
|
315
|
-
// override (e.g. "" for raw tool-args JSON) suppresses the preview.
|
|
316
|
-
const previewOf = (sample: string): string => {
|
|
317
|
-
const cleaned = sample
|
|
318
|
-
.replace(/[\u200B-\u200F\u202A-\u202E\u2066-\u2069]/g, "")
|
|
319
|
-
.replace(/[\u0000-\u001F\u007F-\u009F\u2028\u2029]+/g, " ")
|
|
320
|
-
.trim()
|
|
321
|
-
const cps = Array.from(cleaned)
|
|
322
|
-
return cps.length > 26 ? `${cps.slice(0, 25).join("")}~` : cleaned
|
|
323
|
-
}
|
|
324
|
-
const add = (
|
|
325
|
-
kind: string,
|
|
326
|
-
ref: string,
|
|
327
|
-
sample: string,
|
|
328
|
-
tool = "",
|
|
329
|
-
previewOverride?: string,
|
|
330
|
-
) => {
|
|
331
|
-
if (sample.length === 0) return
|
|
332
|
-
out.push({
|
|
333
|
-
kind,
|
|
334
|
-
ref,
|
|
335
|
-
chars: sample.length,
|
|
336
|
-
tokensEstimate: estimateTokensLocal(sample),
|
|
337
|
-
tool,
|
|
338
|
-
preview: previewOverride ?? previewOf(sample),
|
|
339
|
-
})
|
|
340
|
-
}
|
|
341
|
-
// Human clock ref: "21:04" from the message's own creation time — far more
|
|
342
|
-
// actionable than an array ordinal. Falls back to "#N" when time is absent.
|
|
343
|
-
const hhmm = (ms: unknown): string => {
|
|
344
|
-
if (typeof ms !== "number" || !Number.isFinite(ms) || ms <= 0) return ""
|
|
345
|
-
try {
|
|
346
|
-
const d = new Date(ms)
|
|
347
|
-
if (Number.isNaN(d.getTime())) return ""
|
|
348
|
-
const pad = (x: number) => String(x).padStart(2, "0")
|
|
349
|
-
return `${pad(d.getHours())}:${pad(d.getMinutes())}`
|
|
350
|
-
} catch {
|
|
351
|
-
return ""
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
for (let i = 0; i < messages.length; i++) {
|
|
355
|
-
const m = asRecord(messages[i])
|
|
356
|
-
if (!m) continue
|
|
357
|
-
const timeRec = asRecord(m.time)
|
|
358
|
-
const ref = hhmm(timeRec?.created) || `#${i + 1}`
|
|
359
|
-
const type = typeof m.type === "string" ? m.type : ""
|
|
360
|
-
if (type === "user") {
|
|
361
|
-
if (typeof m.text === "string") add("user.text", ref, m.text)
|
|
362
|
-
continue
|
|
363
|
-
}
|
|
364
|
-
if (type !== "assistant") continue
|
|
365
|
-
const content = Array.isArray(m.content) ? m.content : []
|
|
366
|
-
let text = ""
|
|
367
|
-
let rsn = ""
|
|
368
|
-
for (const rawPart of content) {
|
|
369
|
-
const part = asRecord(rawPart)
|
|
370
|
-
if (!part) continue
|
|
371
|
-
const pt = typeof part.type === "string" ? part.type : ""
|
|
372
|
-
if (pt === "text") {
|
|
373
|
-
if (typeof part.text === "string") text += part.text
|
|
374
|
-
} else if (pt === "reasoning") {
|
|
375
|
-
if (typeof part.text === "string") rsn += part.text
|
|
376
|
-
} else if (pt === "tool") {
|
|
377
|
-
const state = asRecord(part.state)
|
|
378
|
-
if (!state) continue
|
|
379
|
-
// Per-tool-part contributors: each tool call carries its own name and
|
|
380
|
-
// measured size (one message can hold several tool calls). The raw
|
|
381
|
-
// args JSON is measured but NOT previewed — the tool name is the
|
|
382
|
-
// useful identifier; result text previews fine.
|
|
383
|
-
const toolName =
|
|
384
|
-
typeof part.name === "string" && part.name ? safeText(part.name) : ""
|
|
385
|
-
let tool = ""
|
|
386
|
-
let result = ""
|
|
387
|
-
if (typeof state.input === "string") {
|
|
388
|
-
tool += state.input
|
|
389
|
-
} else if (state.input != null) {
|
|
390
|
-
try {
|
|
391
|
-
tool += JSON.stringify(state.input) ?? ""
|
|
392
|
-
} catch {
|
|
393
|
-
/* unstringifiable input: skip its chars */
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
const c = state.content
|
|
397
|
-
if (Array.isArray(c)) {
|
|
398
|
-
for (const rawItem of c) {
|
|
399
|
-
const item = asRecord(rawItem)
|
|
400
|
-
if (item && item.type === "text" && typeof item.text === "string")
|
|
401
|
-
result += item.text
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
add("asst.tool", ref, tool, toolName, "") // raw args JSON: no preview
|
|
405
|
-
add("asst.result", ref, result, toolName)
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
add("asst.text", ref, text)
|
|
409
|
-
add("asst.rsn", ref, rsn)
|
|
410
|
-
}
|
|
411
|
-
return out.sort((a, b) => b.tokensEstimate - a.tokensEstimate)
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
// Human line for one measured contributor: role in full words, the message's
|
|
415
|
-
// own clock time, the bucket (with the tool name for tool buckets), the token
|
|
416
|
-
// estimate, and a bounded single-line preview of THIS bucket only. Never
|
|
417
|
-
// contains a full session id or file paths.
|
|
418
|
-
function contributorLine(c: Contributor, rank: number): string {
|
|
419
|
-
const [who, bucket] = c.kind.split(".")
|
|
420
|
-
const role = who === "asst" ? "assistant" : who
|
|
421
|
-
const what =
|
|
422
|
-
bucket === "rsn"
|
|
423
|
-
? "reasoning"
|
|
424
|
-
: bucket === "tool"
|
|
425
|
-
? "tool args"
|
|
426
|
-
: bucket === "result"
|
|
427
|
-
? "tool result"
|
|
428
|
-
: ""
|
|
429
|
-
const tool = c.tool ? ` (${c.tool})` : ""
|
|
430
|
-
const whatFull = what ? `${what}${tool}` : ""
|
|
431
|
-
const head = `${rank}. ${role} ${c.ref}${whatFull ? ` ${whatFull}` : ""} \u2014 ${fmt(c.tokensEstimate)}`
|
|
432
|
-
return c.preview ? `${head} "${c.preview}"` : head
|
|
433
|
-
}
|
|
434
|
-
|
|
435
253
|
// The TUI message data API is optional across host versions. Log a single hint
|
|
436
254
|
// (never once per poll) when it is unavailable and hide the whole section.
|
|
437
255
|
let warnedMessagesUnavailable = false
|
|
@@ -529,20 +347,6 @@ function panelLayout(panelWidth: number): {
|
|
|
529
347
|
return { roleW, modelW, ctxW, updW, gutter: " ".repeat(GUTTER) }
|
|
530
348
|
}
|
|
531
349
|
|
|
532
|
-
// Per-row denominator with the same fallback cascade as the server table:
|
|
533
|
-
// usable, else limit, else none.
|
|
534
|
-
function panelDenom(e: StateEntry | undefined): number {
|
|
535
|
-
const u = num(e?.usable)
|
|
536
|
-
if (u > 0) return u
|
|
537
|
-
const l = num(e?.limit)
|
|
538
|
-
return l > 0 ? l : 0
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
function panelRole(e: StateEntry, isMain: boolean): string {
|
|
542
|
-
if (isMain) return "main"
|
|
543
|
-
return e.agent ? `sub:${safeText(e.agent)}` : "sub"
|
|
544
|
-
}
|
|
545
|
-
|
|
546
350
|
// Full session panel: header + one row per session in the family (root plus
|
|
547
351
|
// subagent descendants). It reads the same state.json bridge as the sidebar via
|
|
548
352
|
// the shared 1 s tick, so both surfaces refresh on ONE poll. `panel.sessionID`
|
|
@@ -655,46 +459,20 @@ function BreakdownPanel(props: {
|
|
|
655
459
|
function exportRedacted(): void {
|
|
656
460
|
let exportedName: string | null = null
|
|
657
461
|
try {
|
|
658
|
-
const r = data().root
|
|
659
|
-
const id8 = String(props.panel.sessionID).slice(0, 8)
|
|
660
462
|
const now = new Date()
|
|
661
|
-
const
|
|
662
|
-
const
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
},
|
|
673
|
-
family: data().rows.map((e, i) => {
|
|
674
|
-
const d = panelDenom(e)
|
|
675
|
-
return {
|
|
676
|
-
// Positional main-row detection (index 0), same as the render —
|
|
677
|
-
// state.json is untrusted input and a legacy entry may lack
|
|
678
|
-
// `sessionID`, which would mislabel main as sub.
|
|
679
|
-
role: panelRole(e, i === 0),
|
|
680
|
-
model: safeText(e.model ?? ""),
|
|
681
|
-
ctx: num(e.ctx),
|
|
682
|
-
usable: e.usable == null ? null : num(e.usable),
|
|
683
|
-
limit: e.limit == null ? null : num(e.limit),
|
|
684
|
-
pct: d > 0 ? Math.round((num(e.ctx) / d) * 100) : null,
|
|
685
|
-
updated: safeText(e.updatedAt ?? ""),
|
|
686
|
-
}
|
|
687
|
-
}),
|
|
688
|
-
contributors: contributors().map((c) => ({
|
|
689
|
-
kind: c.kind,
|
|
690
|
-
tool: c.tool || null,
|
|
691
|
-
ref: c.ref,
|
|
692
|
-
chars: c.chars,
|
|
693
|
-
tokensEstimate: c.tokensEstimate,
|
|
694
|
-
})),
|
|
695
|
-
}
|
|
463
|
+
const name = exportFileName(props.panel.sessionID, now)
|
|
464
|
+
const payload = buildRedactedPayload({
|
|
465
|
+
sessionID: props.panel.sessionID,
|
|
466
|
+
root: data().root,
|
|
467
|
+
rows: data().rows,
|
|
468
|
+
contributors: contributors(),
|
|
469
|
+
now,
|
|
470
|
+
panelDenom,
|
|
471
|
+
panelRole,
|
|
472
|
+
num,
|
|
473
|
+
})
|
|
696
474
|
const dir = join(tmpdir(), "opencode-context-indicator", "exports")
|
|
697
|
-
const file = join(dir,
|
|
475
|
+
const file = join(dir, name)
|
|
698
476
|
mkdirSync(dir, { recursive: true })
|
|
699
477
|
writeFileSync(file, JSON.stringify(payload, null, 2), "utf8")
|
|
700
478
|
exportedName = basename(file)
|
|
@@ -786,24 +564,37 @@ function BreakdownPanel(props: {
|
|
|
786
564
|
{data().rows.map((e, i) => (
|
|
787
565
|
<text fg={muted()}>{row(e, i === 0)}</text>
|
|
788
566
|
))}
|
|
789
|
-
</Show>
|
|
790
|
-
<Show when={messages().available}>
|
|
791
|
-
<text fg={base()}>{"largest contributors (measured)"}</text>
|
|
792
567
|
<Show
|
|
793
|
-
when={
|
|
794
|
-
|
|
568
|
+
when={
|
|
569
|
+
(data().root?.usable ?? 0) > 0
|
|
570
|
+
}
|
|
795
571
|
>
|
|
796
|
-
{
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
572
|
+
<text fg={muted()}>
|
|
573
|
+
{" compact at " +
|
|
574
|
+
fmt(num(data().root?.usable)) +
|
|
575
|
+
(num(data().root?.reserve) > 0
|
|
576
|
+
? " (" + fmt(num(data().root?.reserve)) + " reserve)"
|
|
577
|
+
: "")}
|
|
578
|
+
</text>
|
|
579
|
+
</Show>
|
|
580
|
+
<Show when={messages().available}>
|
|
581
|
+
<text fg={base()}>{"largest contributors (measured)"}</text>
|
|
582
|
+
<Show
|
|
583
|
+
when={contributors().length > 0}
|
|
584
|
+
fallback={<text fg={muted()}>{" no messages yet"}</text>}
|
|
585
|
+
>
|
|
586
|
+
{contributors()
|
|
587
|
+
.slice(0, 5)
|
|
588
|
+
.map((c, i) => (
|
|
589
|
+
<text fg={muted()}>{contributorLine(c, i + 1)}</text>
|
|
590
|
+
))}
|
|
591
|
+
</Show>
|
|
592
|
+
<text fg={muted()}>
|
|
593
|
+
{
|
|
594
|
+
"measured != server categories (tool outputs counted here; server categories exclude them)"
|
|
595
|
+
}
|
|
596
|
+
</text>
|
|
801
597
|
</Show>
|
|
802
|
-
<text fg={muted()}>
|
|
803
|
-
{
|
|
804
|
-
"measured != server categories (tool outputs counted here; server categories exclude them)"
|
|
805
|
-
}
|
|
806
|
-
</text>
|
|
807
598
|
</Show>
|
|
808
599
|
</box>
|
|
809
600
|
)
|