opencode-plugin-context 1.1.2 → 1.2.0

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.
Files changed (3) hide show
  1. package/README.md +21 -1
  2. package/dist/tui.js +173 -144
  3. package/package.json +5 -5
package/README.md CHANGED
@@ -14,9 +14,10 @@ Context
14
14
  ━━━━━━━━━━━━━━━━━ 69%
15
15
  138K / 200K tokens
16
16
  $0.04 spent
17
- ▍c40K ▍p90K ▍t5K ▍o3K ▍r6K ▍f62K
17
+ 62.4 TPS · avg 48.1 · 23s
18
18
  ```
19
19
 
20
+
20
21
  One color-coded legend row follows the bar — `▍` marker in the segment's color,
21
22
  then a muted letter + count. Colors follow the active theme:
22
23
 
@@ -66,6 +67,25 @@ always reaches full width. A very small segment may not fill a single bar cell
66
67
  always visible in the legend. Percent is colored like the usage plugin: green
67
68
  `<50%`, amber `50–74%`, orange `75–99%`, red `100%`.
68
69
 
70
+ ## Throughput (TPS)
71
+
72
+ While the assistant streams, a live tokens-per-second line is appended below the
73
+ totals:
74
+
75
+ ```
76
+ 62.4 TPS · avg 48.1 · 23s
77
+ ```
78
+
79
+ - **instant** — smoothed rate over a 1s rolling window, colored by speed: red
80
+ `<10`, amber `10–49`, green `≥50`. Shown only while tokens are arriving.
81
+ - **avg** — tokens / active generation span (first → last token), so idle time
82
+ doesn't drag it down. Shown once the stream goes quiet.
83
+ - **elapsed** — active generation span.
84
+
85
+ Counts come from `session.text.delta` / `session.reasoning.delta`, estimated with
86
+ the same chars/4 heuristic used elsewhere (sub-token deltas are carried over), so
87
+ it tracks the live stream rather than the provider's final totals.
88
+
69
89
  ## Configuration
70
90
 
71
91
  All options are optional. Plugin entry in `tui.json`:
package/dist/tui.js CHANGED
@@ -21,27 +21,7 @@ function num(v) {
21
21
  return typeof v === "number" && Number.isFinite(v) && v > 0 ? Math.round(v) : 0;
22
22
  }
23
23
  function estimateTokens(input) {
24
- return Math.max(0, Math.ceil(input.length / 4));
25
- }
26
- function formatCompactTokens(tokens) {
27
- if (tokens <= 0) return "0";
28
- const k = Math.round(tokens / 1e3);
29
- return `${Math.max(1, k)}k`;
30
- }
31
- function scaleToPrompt(raw, prompt) {
32
- const estimated = raw.system + raw.user + raw.assistant + raw.tool;
33
- if (estimated <= prompt) {
34
- return { ...raw, other: prompt - estimated };
35
- }
36
- const scale = prompt / estimated;
37
- const scaled = {
38
- system: Math.floor(raw.system * scale),
39
- user: Math.floor(raw.user * scale),
40
- assistant: Math.floor(raw.assistant * scale),
41
- tool: Math.floor(raw.tool * scale)
42
- };
43
- const total = scaled.system + scaled.user + scaled.assistant + scaled.tool;
44
- return { ...scaled, other: Math.max(0, prompt - total) };
24
+ return Math.max(0, Math.round(input.length / 4));
45
25
  }
46
26
  function computeContext(counts, limits, cost = 0, estimates, exclude = []) {
47
27
  const { input, output, reasoning, cacheRead, cacheWrite } = counts;
@@ -50,66 +30,105 @@ function computeContext(counts, limits, cost = 0, estimates, exclude = []) {
50
30
  const reserved = limits && limits.output > 0 ? Math.max(0, limits.output - output) : 0;
51
31
  const free = window > 0 ? Math.max(0, window - used - reserved) : 0;
52
32
  const prompt = input + cacheWrite;
53
- const normalizeExclude = (id) => {
54
- if (id === "tools" || id === "tool") return exclude.includes("tools") || exclude.includes("tool");
55
- return exclude.includes(id);
56
- };
57
- const excluded = (id) => normalizeExclude(id);
58
- let raw;
59
- if (estimates) {
60
- const hasWebCats = estimates.system !== void 0 || estimates.assistant !== void 0 || estimates.tool !== void 0;
61
- if (hasWebCats) {
62
- const rawTokens = {
63
- system: Math.max(0, estimates.system ?? 0),
64
- user: Math.max(0, estimates.user ?? 0),
65
- assistant: Math.max(0, estimates.assistant ?? 0),
66
- tool: Math.max(0, estimates.tool ?? estimates.tools ?? 0)
67
- };
68
- const scaled = scaleToPrompt(rawTokens, prompt);
69
- raw = [
70
- { id: "cached", tokens: cacheRead },
71
- { id: "system", tokens: scaled.system },
72
- { id: "user", tokens: scaled.user },
73
- { id: "assistant", tokens: scaled.assistant },
74
- { id: "tool", tokens: scaled.tool },
75
- { id: "other", tokens: scaled.other },
76
- { id: "think", tokens: reasoning },
77
- { id: "out", tokens: output },
78
- { id: "reserved", tokens: reserved },
79
- { id: "free", tokens: free }
80
- ];
81
- } else {
82
- raw = [
83
- { id: "cached", tokens: cacheRead },
84
- { id: "user", tokens: estimates.user },
85
- { id: "tools", tokens: estimates.tools },
86
- { id: "system", tokens: Math.max(0, prompt - estimates.user - estimates.tools) },
87
- { id: "think", tokens: reasoning },
88
- { id: "out", tokens: output },
89
- { id: "reserved", tokens: reserved },
90
- { id: "free", tokens: free }
91
- ];
92
- }
93
- } else {
94
- raw = [
95
- { id: "cached", tokens: cacheRead },
96
- { id: "prompt", tokens: prompt },
97
- { id: "think", tokens: reasoning },
98
- { id: "out", tokens: output },
99
- { id: "reserved", tokens: reserved },
100
- { id: "free", tokens: free }
101
- ];
102
- }
103
- const filtered = raw.filter((segment) => segment.tokens > 0 && !excluded(segment.id));
33
+ const raw = estimates ? [
34
+ { id: "cached", tokens: cacheRead },
35
+ { id: "user", tokens: estimates.user },
36
+ { id: "tools", tokens: estimates.tools },
37
+ { id: "system", tokens: Math.max(0, prompt - estimates.user - estimates.tools) },
38
+ { id: "think", tokens: reasoning },
39
+ { id: "out", tokens: output },
40
+ { id: "reserved", tokens: reserved },
41
+ { id: "free", tokens: free }
42
+ ] : [
43
+ { id: "cached", tokens: cacheRead },
44
+ { id: "prompt", tokens: prompt },
45
+ { id: "think", tokens: reasoning },
46
+ { id: "out", tokens: output },
47
+ { id: "reserved", tokens: reserved },
48
+ { id: "free", tokens: free }
49
+ ];
104
50
  return {
105
51
  used,
106
52
  window,
107
53
  percent: window > 0 ? Math.min(100, Math.round(used / window * 100)) : 0,
108
- segments: filtered,
54
+ segments: raw.filter((segment) => segment.tokens > 0 && !exclude.includes(segment.id)),
109
55
  cost,
110
56
  known: window > 0
111
57
  };
112
58
  }
59
+ var MIN_WINDOW_SECONDS = 0.3;
60
+ var MAX_INITIAL_TPS = 100;
61
+ function createTpsTracker(options = {}) {
62
+ const windowMs = options.windowMs && options.windowMs > 0 ? options.windowMs : 1e3;
63
+ const halfLifeMs = options.halfLifeMs && options.halfLifeMs > 0 ? options.halfLifeMs : 250;
64
+ let samples = [];
65
+ let total = 0;
66
+ let start = -1;
67
+ let last = -1;
68
+ let smoothed = 0;
69
+ let smoothedAt = 0;
70
+ let hasSmoothed = false;
71
+ function raw(now) {
72
+ if (samples.length === 0) return 0;
73
+ const cutoff = now - windowMs;
74
+ let tokens = 0;
75
+ let oldest = now;
76
+ for (const sample of samples) {
77
+ if (sample.t >= cutoff) {
78
+ tokens += sample.count;
79
+ if (sample.t < oldest) oldest = sample.t;
80
+ }
81
+ }
82
+ if (tokens === 0) return 0;
83
+ return tokens / Math.max((now - oldest) / 1e3, MIN_WINDOW_SECONDS);
84
+ }
85
+ return {
86
+ record(count, timestamp) {
87
+ if (!(count > 0)) return;
88
+ const t = timestamp ?? Date.now();
89
+ if (start < 0) start = t;
90
+ if (t > last) last = t;
91
+ total += count;
92
+ samples.push({ t, count });
93
+ const cutoff = t - windowMs;
94
+ let drop = 0;
95
+ while (drop < samples.length && samples[drop].t < cutoff) drop++;
96
+ if (drop > 0) samples = samples.slice(drop);
97
+ const value = raw(t);
98
+ if (!hasSmoothed) {
99
+ smoothed = Math.min(value, MAX_INITIAL_TPS);
100
+ hasSmoothed = true;
101
+ } else {
102
+ const dt = Math.max(1, t - smoothedAt);
103
+ const alpha = Math.exp(-Math.LN2 * dt / halfLifeMs);
104
+ smoothed = alpha * smoothed + (1 - alpha) * value;
105
+ }
106
+ smoothedAt = t;
107
+ },
108
+ instant() {
109
+ if (!hasSmoothed || Date.now() - last > windowMs) return 0;
110
+ return smoothed;
111
+ },
112
+ average() {
113
+ return start < 0 ? 0 : total / Math.max((last - start) / 1e3, MIN_WINDOW_SECONDS);
114
+ },
115
+ total() {
116
+ return total;
117
+ },
118
+ elapsed() {
119
+ return start < 0 ? 0 : last - start;
120
+ },
121
+ reset() {
122
+ samples = [];
123
+ total = 0;
124
+ start = -1;
125
+ last = -1;
126
+ smoothed = 0;
127
+ smoothedAt = 0;
128
+ hasSmoothed = false;
129
+ }
130
+ };
131
+ }
113
132
  function segmentBar(segments, window, width, exclude = []) {
114
133
  if (window <= 0 || width <= 0) return [];
115
134
  let remaining = width;
@@ -130,11 +149,8 @@ var VALID_SEGMENT_IDS = [
130
149
  "cached",
131
150
  "user",
132
151
  "tools",
133
- "tool",
134
152
  "system",
135
153
  "prompt",
136
- "assistant",
137
- "other",
138
154
  "think",
139
155
  "out",
140
156
  "reserved",
@@ -155,11 +171,8 @@ var SEGMENT_LABEL = {
155
171
  cached: "c",
156
172
  user: "u",
157
173
  tools: "m",
158
- tool: "m",
159
174
  system: "s",
160
175
  prompt: "p",
161
- assistant: "a",
162
- other: "x",
163
176
  think: "t",
164
177
  out: "o",
165
178
  reserved: "r",
@@ -167,7 +180,7 @@ var SEGMENT_LABEL = {
167
180
  };
168
181
  var money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
169
182
  var intFmt = new Intl.NumberFormat("en-US");
170
- var compact = (n) => formatCompactTokens(n);
183
+ var compactFmt = new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 });
171
184
  var plugin = {
172
185
  id: "opencode-plugin-context",
173
186
  tui: async (api, rawOptions) => {
@@ -177,82 +190,84 @@ var plugin = {
177
190
  setRenderTick((n) => n + 1);
178
191
  api.renderer.requestRender();
179
192
  };
193
+ const trackers = /* @__PURE__ */ new Map();
194
+ const carries = /* @__PURE__ */ new Map();
195
+ const trackerFor = (sessionId) => {
196
+ let tracker = trackers.get(sessionId);
197
+ if (!tracker) {
198
+ tracker = createTpsTracker();
199
+ trackers.set(sessionId, tracker);
200
+ }
201
+ return tracker;
202
+ };
203
+ const onDelta = (sessionId, text, at) => {
204
+ const carry = (carries.get(sessionId) ?? 0) + text.length;
205
+ const tokens = Math.floor(carry / 4);
206
+ carries.set(sessionId, carry - tokens * 4);
207
+ if (tokens > 0) trackerFor(sessionId).record(tokens, at);
208
+ throttledRepaint();
209
+ };
210
+ let lastRepaint = 0;
211
+ let pendingRepaint;
212
+ const throttledRepaint = () => {
213
+ const wait = lastRepaint + 50 - Date.now();
214
+ if (wait <= 0) {
215
+ lastRepaint = Date.now();
216
+ repaint();
217
+ } else if (pendingRepaint === void 0) {
218
+ pendingRepaint = setTimeout(() => {
219
+ pendingRepaint = void 0;
220
+ lastRepaint = Date.now();
221
+ repaint();
222
+ }, wait);
223
+ }
224
+ };
180
225
  const unsubs = [
181
- api.event.on("message.updated", repaint),
182
- api.event.on("message.part.updated", repaint),
183
- api.event.on("message.part.removed", repaint),
184
- api.event.on("message.removed", repaint),
185
- api.event.on("session.updated", repaint),
186
- api.event.on("session.compacted", repaint),
226
+ api.event.on("session.created", repaint),
187
227
  api.event.on("session.status", repaint),
188
- api.event.on("session.idle", repaint)
228
+ api.event.on("session.idle", repaint),
229
+ api.event.on("session.text.delta", (event) => onDelta(event.data.sessionID, event.data.delta, event.created)),
230
+ api.event.on("session.reasoning.delta", (event) => onDelta(event.data.sessionID, event.data.delta, event.created))
189
231
  ];
190
232
  const repaintTimer = setInterval(repaint, 2e3);
191
233
  api.lifecycle.onDispose(() => {
192
234
  for (const unsub of unsubs) unsub();
193
235
  clearInterval(repaintTimer);
236
+ if (pendingRepaint !== void 0) clearTimeout(pendingRepaint);
194
237
  });
195
238
  api.slots.register({
196
239
  order: SLOT_ORDER,
197
240
  slots: {
198
241
  sidebar_content(_ctx, props) {
199
242
  getRenderTick();
200
- return renderPanel(api, props.session_id, config);
243
+ return renderPanel(api, props.session_id, config, trackers.get(props.session_id));
201
244
  }
202
245
  }
203
246
  });
204
247
  }
205
248
  };
206
249
  function collectEstimates(api, sessionId) {
207
- let system = 0;
208
250
  let user = 0;
209
- let assistant = 0;
210
- let tool = 0;
211
- let systemPrompt;
212
- const messages = [...api.state.session.messages(sessionId)];
213
- for (let i = messages.length - 1; i >= 0; i--) {
214
- const s = messages[i]?.system?.trim();
215
- if (s) {
216
- systemPrompt = s;
217
- break;
218
- }
219
- }
220
- if (systemPrompt) system += estimateTokens(systemPrompt);
221
- for (const message of messages) {
251
+ let tools = 0;
252
+ for (const message of api.state.session.messages(sessionId)) {
222
253
  const role = message.role;
223
254
  try {
224
255
  for (const part of api.state.part(message.id)) {
225
256
  const p = part;
226
- if (role === "user") {
227
- if (p.type === "text") user += estimateTokens(p.text ?? "");
228
- else if (p.type === "file") user += estimateTokens(p.source?.text?.value ?? "");
229
- else if (p.type === "agent") user += estimateTokens(p.source?.value ?? "");
230
- } else if (role === "assistant") {
231
- if (p.type === "text" || p.type === "reasoning") {
232
- assistant += estimateTokens(p.text ?? "");
233
- } else if (p.type === "tool") {
234
- const state = p.state;
235
- if (!state) continue;
236
- const inputKeys = state.input && typeof state.input === "object" ? Object.keys(state.input).length : 0;
237
- const inputChars = inputKeys * 16;
238
- let out = "";
239
- if (state.status === "pending") out = state.raw ?? "";
240
- else if (state.status === "completed") out = typeof state.output === "string" ? state.output : "";
241
- else if (state.status === "error") out = typeof state.error === "string" ? state.error : "";
242
- else {
243
- if (typeof state.output === "string") out = state.output;
244
- else if (typeof state.error === "string") out = state.error;
245
- else if (typeof state.raw === "string") out = state.raw;
246
- }
247
- const combined = "x".repeat(inputChars) + out;
248
- tool += estimateTokens(combined);
249
- }
257
+ if (p.type === "text" && role === "user") {
258
+ user += estimateTokens(p.text ?? "");
259
+ } else if (p.type === "tool") {
260
+ const state = p.state;
261
+ if (!state) continue;
262
+ if (state.input !== void 0) tools += estimateTokens(JSON.stringify(state.input));
263
+ if (typeof state.output === "string") tools += estimateTokens(state.output);
264
+ else if (typeof state.error === "string") tools += estimateTokens(state.error);
250
265
  }
251
266
  }
252
267
  } catch {
253
268
  }
254
269
  }
255
- return { system, user, assistant, tool, tools: tool };
270
+ return { user, tools };
256
271
  }
257
272
  function sessionUsage(api, sessionId, config) {
258
273
  const messages = api.state.session.messages(sessionId);
@@ -284,7 +299,7 @@ function sessionUsage(api, sessionId, config) {
284
299
  const estimates = config.estimate ? collectEstimates(api, sessionId) : void 0;
285
300
  return computeContext(counts, limits, cost, estimates, config.exclude);
286
301
  }
287
- function renderPanel(api, sessionId, config) {
302
+ function renderPanel(api, sessionId, config, tps) {
288
303
  const theme = api.theme.current;
289
304
  const usage = sessionUsage(api, sessionId, config);
290
305
  const header = /* @__PURE__ */ jsx("text", { fg: theme.text, attributes: BOLD, children: "Context" });
@@ -312,14 +327,14 @@ function renderPanel(api, sessionId, config) {
312
327
  /* @__PURE__ */ jsx("text", { fg: segmentColor(item.id, theme, true), children: "\u258D" }),
313
328
  /* @__PURE__ */ jsxs("text", { fg: theme.textMuted, children: [
314
329
  SEGMENT_LABEL[item.id],
315
- compact(item.tokens)
330
+ compactFmt.format(item.tokens)
316
331
  ] })
317
332
  ] });
318
333
  }) });
319
334
  };
320
335
  const usedStart = 0;
321
- const usedIds = ["cached", "system", "user", "assistant", "tool", "other", "think", "out"];
322
- const budgetStart = usedIds.reduce((sum, id) => sum + (cellsById.get(id) ?? (id === "tool" ? cellsById.get("tools") ?? 0 : 0)), 0);
336
+ const usedIds = ["cached", "user", "tools", "system", "think", "out"];
337
+ const budgetStart = usedIds.reduce((sum, id) => sum + (cellsById.get(id) ?? 0), 0);
323
338
  const usedLegend = row(usedIds, usedStart);
324
339
  const budgetLegend = row(["reserved", "free"], budgetStart);
325
340
  if (usedLegend) lines.push(usedLegend);
@@ -335,7 +350,7 @@ function renderPanel(api, sessionId, config) {
335
350
  /* @__PURE__ */ jsx("text", { fg: segmentColor(cell.id, theme, false), children: "\u258D" }),
336
351
  /* @__PURE__ */ jsxs("text", { fg: theme.textMuted, children: [
337
352
  SEGMENT_LABEL[cell.id],
338
- compact(tokenById.get(cell.id) ?? 0)
353
+ compactFmt.format(tokenById.get(cell.id) ?? 0)
339
354
  ] })
340
355
  ] });
341
356
  }) })
@@ -352,8 +367,30 @@ function renderPanel(api, sessionId, config) {
352
367
  if (usage.cost > 0) {
353
368
  lines.push(/* @__PURE__ */ jsx("text", { fg: theme.textMuted, children: `${money.format(usage.cost)} spent` }));
354
369
  }
370
+ if (tps && tps.total() > 0) {
371
+ const instant = tps.instant();
372
+ const live = instant > 0.05;
373
+ const avg = tps.average();
374
+ lines.push(
375
+ /* @__PURE__ */ jsxs("box", { flexDirection: "row", children: [
376
+ live ? /* @__PURE__ */ jsx("text", { fg: speedColor(instant, theme), children: `${instant.toFixed(1)} TPS` }) : null,
377
+ /* @__PURE__ */ jsx("text", { fg: theme.textMuted, children: live ? ` \xB7 avg ${avg.toFixed(1)} \xB7 ${formatDuration(tps.elapsed())}` : `avg ${avg.toFixed(1)} TPS \xB7 ${formatDuration(tps.elapsed())}` })
378
+ ] })
379
+ );
380
+ }
355
381
  return /* @__PURE__ */ jsx("box", { width: "100%", flexDirection: "column", children: lines });
356
382
  }
383
+ function formatDuration(ms) {
384
+ const seconds = ms / 1e3;
385
+ if (seconds < 60) return `${seconds.toFixed(1)}s`;
386
+ const whole = Math.round(seconds);
387
+ return `${Math.floor(whole / 60)}m${String(whole % 60).padStart(2, "0")}s`;
388
+ }
389
+ function speedColor(tps, theme) {
390
+ if (tps < 10) return theme.error;
391
+ if (tps < 50) return theme.warning;
392
+ return theme.success;
393
+ }
357
394
  function segmentColor(id, theme, estimate) {
358
395
  const base = {
359
396
  cached: theme.success,
@@ -364,23 +401,15 @@ function segmentColor(id, theme, estimate) {
364
401
  free: theme.text,
365
402
  user: theme.accent,
366
403
  tools: theme.accent,
367
- tool: theme.accent,
368
- system: theme.accent,
369
- assistant: theme.accent,
370
- other: theme.accent
404
+ system: theme.accent
371
405
  };
372
406
  if (!estimate) return base[id];
373
407
  const est = {
374
- system: theme.info,
375
- user: theme.success,
376
- assistant: theme.secondary,
377
- tool: theme.warning,
378
- tools: theme.warning,
379
- other: theme.textMuted,
380
- cached: theme.success,
408
+ user: theme.info,
409
+ tools: theme.accent,
410
+ system: theme.warning,
381
411
  think: theme.secondary,
382
412
  out: theme.text,
383
- reserved: theme.textMuted,
384
413
  free: theme.borderSubtle
385
414
  };
386
415
  return est[id] ?? base[id];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "opencode-plugin-context",
4
- "version": "1.1.2",
4
+ "version": "1.2.0",
5
5
  "description": "OpenCode TUI plugin that renders the session's context-window usage as a colored, segmented bar (cached / prompt / thinking / output / reserved output) in the sidebar",
6
6
  "type": "module",
7
7
  "exports": {
@@ -40,14 +40,14 @@
40
40
  "access": "public"
41
41
  },
42
42
  "peerDependencies": {
43
- "@opencode-ai/plugin": ">=1.14.0",
43
+ "@opencode-ai/plugin": ">=0.0.0-next-17444",
44
44
  "@opentui/core": ">=0.2.9",
45
45
  "@opentui/solid": ">=0.2.9"
46
46
  },
47
47
  "devDependencies": {
48
- "@opencode-ai/plugin": "1.18.23",
49
- "@opentui/core": "0.5.8",
50
- "@opentui/solid": "0.5.8",
48
+ "@opencode-ai/plugin": "0.0.0-next-17444",
49
+ "@opentui/core": "0.5.11",
50
+ "@opentui/solid": "0.5.11",
51
51
  "@types/node": "^26.2.0",
52
52
  "esbuild": "^0.28.0",
53
53
  "solid-js": "1.9.12",