opencode-plugin-context 1.1.1 → 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 +134 -9
  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
@@ -56,6 +56,79 @@ function computeContext(counts, limits, cost = 0, estimates, exclude = []) {
56
56
  known: window > 0
57
57
  };
58
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
+ }
59
132
  function segmentBar(segments, window, width, exclude = []) {
60
133
  if (window <= 0 || width <= 0) return [];
61
134
  let remaining = width;
@@ -117,27 +190,57 @@ var plugin = {
117
190
  setRenderTick((n) => n + 1);
118
191
  api.renderer.requestRender();
119
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
+ };
120
225
  const unsubs = [
121
- api.event.on("message.updated", repaint),
122
- api.event.on("message.part.updated", repaint),
123
- api.event.on("message.part.removed", repaint),
124
- api.event.on("message.removed", repaint),
125
- api.event.on("session.updated", repaint),
126
- api.event.on("session.compacted", repaint),
226
+ api.event.on("session.created", repaint),
127
227
  api.event.on("session.status", repaint),
128
- 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))
129
231
  ];
130
232
  const repaintTimer = setInterval(repaint, 2e3);
131
233
  api.lifecycle.onDispose(() => {
132
234
  for (const unsub of unsubs) unsub();
133
235
  clearInterval(repaintTimer);
236
+ if (pendingRepaint !== void 0) clearTimeout(pendingRepaint);
134
237
  });
135
238
  api.slots.register({
136
239
  order: SLOT_ORDER,
137
240
  slots: {
138
241
  sidebar_content(_ctx, props) {
139
242
  getRenderTick();
140
- return renderPanel(api, props.session_id, config);
243
+ return renderPanel(api, props.session_id, config, trackers.get(props.session_id));
141
244
  }
142
245
  }
143
246
  });
@@ -196,7 +299,7 @@ function sessionUsage(api, sessionId, config) {
196
299
  const estimates = config.estimate ? collectEstimates(api, sessionId) : void 0;
197
300
  return computeContext(counts, limits, cost, estimates, config.exclude);
198
301
  }
199
- function renderPanel(api, sessionId, config) {
302
+ function renderPanel(api, sessionId, config, tps) {
200
303
  const theme = api.theme.current;
201
304
  const usage = sessionUsage(api, sessionId, config);
202
305
  const header = /* @__PURE__ */ jsx("text", { fg: theme.text, attributes: BOLD, children: "Context" });
@@ -264,8 +367,30 @@ function renderPanel(api, sessionId, config) {
264
367
  if (usage.cost > 0) {
265
368
  lines.push(/* @__PURE__ */ jsx("text", { fg: theme.textMuted, children: `${money.format(usage.cost)} spent` }));
266
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
+ }
267
381
  return /* @__PURE__ */ jsx("box", { width: "100%", flexDirection: "column", children: lines });
268
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
+ }
269
394
  function segmentColor(id, theme, estimate) {
270
395
  const base = {
271
396
  cached: theme.success,
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.1",
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.18",
49
- "@opentui/core": "0.5.4",
50
- "@opentui/solid": "0.5.4",
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",