opencode-plugin-context 0.1.0 → 1.1.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 +73 -18
  2. package/dist/tui.js +137 -79
  3. package/package.json +2 -3
package/README.md CHANGED
@@ -3,17 +3,18 @@
3
3
  An OpenCode **TUI plugin** that replaces the built-in sidebar context block with a
4
4
  colored, segmented bar of the current session's **context-window usage**.
5
5
 
6
- The whole bar is the model's context window. Colors show what's using it
7
- **cached** prompt, **prompt** (uncached input incl. cache writes), **thinking**
8
- (reasoning tokens), **output**, and the model's **reserved output** headroom —
9
- plus the numbers you already track: total tokens used and money spent.
6
+ The whole bar is the model's context window. By default it shows the real
7
+ provider-reported buckets — **cached** prompt, **prompt** (uncached input incl.
8
+ cache writes), **thinking** (reasoning tokens), **output**, and the model's
9
+ **reserved output** headroom — plus the numbers you already track: total tokens
10
+ used and money spent.
10
11
 
11
12
  ```
12
13
  Context
13
14
  ━━━━━━━━━━━━━━━━━ 69%
14
- 138k / 200k tokens
15
+ 138K / 200K tokens
15
16
  $0.04 spent
16
- c40kp90kt5ko3kr5kf57k
17
+ c40Kp90Kt5Ko3Kr6Kf62K
17
18
  ```
18
19
 
19
20
  One color-coded legend row follows the bar — `▍` marker in the segment's color,
@@ -22,18 +23,67 @@ then a muted letter + count. Colors follow the active theme:
22
23
  | Segment | Legend | Theme color | Default look |
23
24
  | ------------------ | ------ | ----------- | --------------------- |
24
25
  | cached input | `c` | `success` | green |
25
- | prompt (uncached input, incl. cache writes) | `p` | `accent` | blue |
26
+ | prompt (uncached input, incl. cache writes) | `p` | `accent` | blue |
26
27
  | thinking (reasoning tokens) | `t` | `warning` | amber |
27
28
  | output | `o` | `info` | cyan |
28
29
  | reserved output | `r` | `textMuted` | grey |
29
30
  | free space | `f` | `text` | white / default text |
30
31
 
32
+ With `estimate: true` the `prompt` bucket is split into **user** input, **tool**
33
+ calls + results (incl. MCP) and **system** (the remainder), shown as two legend
34
+ rows (used buckets, then reserved/free):
35
+
36
+ ```
37
+ Context
38
+ ━━━━━━━━━━━━━━━━━ 69%
39
+ ▍c40K ▍u25K ▍m15K ▍s70K ▍t5K ▍o3K
40
+ ▍r6K ▍f62K
41
+ 138K / 200K tokens
42
+ $0.04 spent
43
+ ```
44
+
45
+ | Segment | Legend | Theme color | Default look |
46
+ | ------------------ | ------ | ----------- | --------------------- |
47
+ | cached input | `c` | `success` | green |
48
+ | user input (est.) | `u` | `info` | cyan |
49
+ | tool calls + results (est., incl. MCP) | `m` | `accent` | blue |
50
+ | system prompt + tool definitions (rest) | `s` | `warning` | amber |
51
+ | thinking (reasoning tokens) | `t` | `secondary` | purple |
52
+ | output | `o` | `text` | white |
53
+ | reserved output | `r` | `textMuted` | grey |
54
+ | free space | `f` | `borderSubtle` | faint |
55
+
56
+ `u`/`m` are **estimates** — opencode's own chars/4 heuristic
57
+ (`Token.estimate`, used for compaction) applied to the visible message parts.
58
+ `c`/`s`/`t`/`o` are the real provider-reported buckets. `s` is the remainder of
59
+ the prompt bucket after `u` + `m` (the actual system prompt and tool definitions
60
+ aren't exposed by opencode's plugin API).
61
+
31
62
  The bar spans the whole context window and fills the sidebar column: colored
32
- cells for each segment in that order, then `free` fills the remainder with the
33
- default text color so the bar always reaches full width. A very small segment
34
- may not fill a single bar cell (e.g. 137 tokens in a 200k window is 0.07% of
35
- the bar) — its exact count is always visible in the legend. Percent is colored
36
- like the usage plugin: green `<50%`, amber `50–74%`, orange `75–99%`, red `100%`.
63
+ cells for each segment in that order, then `free` fills the remainder so the bar
64
+ always reaches full width. A very small segment may not fill a single bar cell
65
+ (e.g. 137 tokens in a 200K window is 0.07% of the bar) — its exact count is
66
+ always visible in the legend. Percent is colored like the usage plugin: green
67
+ `<50%`, amber `50–74%`, orange `75–99%`, red `100%`.
68
+
69
+ ## Configuration
70
+
71
+ All options are optional. Plugin entry in `tui.json`:
72
+
73
+ ```jsonc
74
+ {
75
+ "plugin_enabled": { "internal:sidebar-context": false },
76
+ "plugin": [["./context/tui.js", { "estimate": true, "exclude": ["system", "cached"] }]]
77
+ }
78
+ ```
79
+
80
+ | Option | Default | Description |
81
+ | ---------- | ------- | ---------------------------------------------------------- |
82
+ | `estimate` | `false` | split the prompt into `u`/`m`/`s` using char-count estimates. `true` replaces the single `p` (prompt) bucket with the estimated user/tool/system bars |
83
+ | `exclude` | `[]` | segment ids to drop from the bar + legend: `cached`, `user`, `tools`, `system`, `prompt`, `think`, `out`, `reserved`, `free` |
84
+
85
+ Excluded segments are removed from the visualization only; the tokens/cost and
86
+ percent lines still report the real totals.
37
87
 
38
88
  ## Requirements
39
89
 
@@ -91,17 +141,22 @@ Then register it in `~/.config/opencode/tui.json` and restart OpenCode:
91
141
  `limit.context` / `limit.output` from `api.state.provider` — the same source
92
142
  the built-in block uses.
93
143
  - **used** = input + output + reasoning + cache.read + cache.write (opencode's
94
- own total). Segments are never double-counted; `cache.write` folds into
95
- `prompt`, and **reserved output** shrinks as actual output grows.
144
+ own total). Segments are never double-counted; `cache.write` folds into the
145
+ prompt bucket, and **reserved output** shrinks as actual output grows.
146
+ - With `estimate: true`, the prompt bucket is split into `u`/`m`/`s` from
147
+ visible message parts; `s` is the remainder.
96
148
  - Cost comes from `session.cost`, falling back to summing assistant `cost`.
97
149
  - Repaints on `message.*` / `session.*` events plus a 2-second self-heal timer.
98
150
 
99
151
  ## What it deliberately does *not* show
100
152
 
101
- The plugin API exposes only aggregate token buckets not how the prompt splits
102
- into system instructions vs tool definitions vs user messages the way GitHub
103
- Copilot's context meter does. Splitting those would mean guessing from character
104
- counts, so this bar shows the real buckets opencode tracks instead.
153
+ The real system prompt and tool definitions are built server-side and are **not**
154
+ exposed through opencode's plugin API only the aggregate token buckets and the
155
+ visible message parts are. The `u`/`m` split is therefore estimated from visible
156
+ parts (chars/4, the same heuristic opencode itself uses for compaction), `s` is
157
+ the remainder, and neither is a true token count the way GitHub Copilot's
158
+ context meter reports it. The `c`/`s`/`t`/`o`/`r` buckets come straight from the
159
+ provider's reported usage.
105
160
 
106
161
  ## Development
107
162
 
package/dist/tui.js CHANGED
@@ -1,5 +1,4 @@
1
- // src/tui.ts
2
- import { createElement, insert, setProp } from "@opentui/solid";
1
+ // src/tui.tsx
3
2
  import { createTextAttributes } from "@opentui/core";
4
3
  import { createSignal } from "solid-js";
5
4
 
@@ -21,15 +20,28 @@ function record(v) {
21
20
  function num(v) {
22
21
  return typeof v === "number" && Number.isFinite(v) && v > 0 ? Math.round(v) : 0;
23
22
  }
24
- function computeContext(counts, limits, cost = 0) {
23
+ function estimateTokens(input) {
24
+ return Math.max(0, Math.round(input.length / 4));
25
+ }
26
+ function computeContext(counts, limits, cost = 0, estimates, exclude = []) {
25
27
  const { input, output, reasoning, cacheRead, cacheWrite } = counts;
26
28
  const used = input + cacheRead + cacheWrite + reasoning + output;
27
29
  const window = limits && limits.context > 0 ? limits.context : 0;
28
30
  const reserved = limits && limits.output > 0 ? Math.max(0, limits.output - output) : 0;
29
31
  const free = window > 0 ? Math.max(0, window - used - reserved) : 0;
30
- const raw = [
32
+ const prompt = input + cacheWrite;
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
+ ] : [
31
43
  { id: "cached", tokens: cacheRead },
32
- { id: "prompt", tokens: input + cacheWrite },
44
+ { id: "prompt", tokens: prompt },
33
45
  { id: "think", tokens: reasoning },
34
46
  { id: "out", tokens: output },
35
47
  { id: "reserved", tokens: reserved },
@@ -39,33 +51,54 @@ function computeContext(counts, limits, cost = 0) {
39
51
  used,
40
52
  window,
41
53
  percent: window > 0 ? Math.min(100, Math.round(used / window * 100)) : 0,
42
- segments: raw.filter((segment) => segment.tokens > 0),
54
+ segments: raw.filter((segment) => segment.tokens > 0 && !exclude.includes(segment.id)),
43
55
  cost,
44
56
  known: window > 0
45
57
  };
46
58
  }
47
- function segmentBar(segments, window, width) {
59
+ function segmentBar(segments, window, width, exclude = []) {
48
60
  if (window <= 0 || width <= 0) return [];
49
61
  let remaining = width;
50
62
  const out = [];
51
- let i = 0;
52
- for (; i < segments.length; i++) {
53
- const segment = segments[i];
63
+ for (const segment of segments) {
54
64
  if (segment.id === "free") break;
55
65
  const cells = Math.min(remaining, Math.round(segment.tokens / window * width));
56
66
  if (cells > 0) out.push({ id: segment.id, cells });
57
67
  remaining -= cells;
58
68
  }
59
- if (remaining > 0) out.push({ id: "free", cells: remaining });
69
+ if (remaining > 0 && !exclude.includes("free")) out.push({ id: "free", cells: remaining });
60
70
  return out;
61
71
  }
62
72
 
63
- // src/tui.ts
73
+ // src/tui.tsx
74
+ import { jsx, jsxs } from "@opentui/solid/jsx-runtime";
75
+ var VALID_SEGMENT_IDS = [
76
+ "cached",
77
+ "user",
78
+ "tools",
79
+ "system",
80
+ "prompt",
81
+ "think",
82
+ "out",
83
+ "reserved",
84
+ "free"
85
+ ];
86
+ function normalizeOptions(raw) {
87
+ const obj = typeof raw === "object" && raw !== null && !Array.isArray(raw) ? raw : {};
88
+ const exclude = Array.isArray(obj.exclude) ? Array.from(new Set(obj.exclude.filter((id) => typeof id === "string" && VALID_SEGMENT_IDS.includes(id)))) : [];
89
+ return {
90
+ estimate: typeof obj.estimate === "boolean" ? obj.estimate : false,
91
+ exclude
92
+ };
93
+ }
64
94
  var BAR_WIDTH = 32;
65
95
  var BOLD = createTextAttributes({ bold: true });
66
96
  var SLOT_ORDER = 60;
67
97
  var SEGMENT_LABEL = {
68
98
  cached: "c",
99
+ user: "u",
100
+ tools: "m",
101
+ system: "s",
69
102
  prompt: "p",
70
103
  think: "t",
71
104
  out: "o",
@@ -73,9 +106,12 @@ var SEGMENT_LABEL = {
73
106
  free: "f"
74
107
  };
75
108
  var money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
109
+ var intFmt = new Intl.NumberFormat("en-US");
110
+ var compactFmt = new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 });
76
111
  var plugin = {
77
112
  id: "opencode-plugin-context",
78
- tui: async (api) => {
113
+ tui: async (api, rawOptions) => {
114
+ const config = normalizeOptions(rawOptions);
79
115
  const [getRenderTick, setRenderTick] = createSignal(0);
80
116
  const repaint = () => {
81
117
  setRenderTick((n) => n + 1);
@@ -101,13 +137,36 @@ var plugin = {
101
137
  slots: {
102
138
  sidebar_content(_ctx, props) {
103
139
  getRenderTick();
104
- return renderPanel(api, props.session_id);
140
+ return renderPanel(api, props.session_id, config);
105
141
  }
106
142
  }
107
143
  });
108
144
  }
109
145
  };
110
- function sessionUsage(api, sessionId) {
146
+ function collectEstimates(api, sessionId) {
147
+ let user = 0;
148
+ let tools = 0;
149
+ for (const message of api.state.session.messages(sessionId)) {
150
+ const role = message.role;
151
+ try {
152
+ for (const part of api.state.part(message.id)) {
153
+ const p = part;
154
+ if (p.type === "text" && role === "user") {
155
+ user += estimateTokens(p.text ?? "");
156
+ } else if (p.type === "tool") {
157
+ const state = p.state;
158
+ if (!state) continue;
159
+ if (state.input !== void 0) tools += estimateTokens(JSON.stringify(state.input));
160
+ if (typeof state.output === "string") tools += estimateTokens(state.output);
161
+ else if (typeof state.error === "string") tools += estimateTokens(state.error);
162
+ }
163
+ }
164
+ } catch {
165
+ }
166
+ }
167
+ return { user, tools };
168
+ }
169
+ function sessionUsage(api, sessionId, config) {
111
170
  const messages = api.state.session.messages(sessionId);
112
171
  let last;
113
172
  for (const message of messages) {
@@ -134,62 +193,85 @@ function sessionUsage(api, sessionId) {
134
193
  limits = { context: model.limit.context, output: model.limit.output ?? 0 };
135
194
  }
136
195
  }
137
- return computeContext(counts, limits, cost);
196
+ const estimates = config.estimate ? collectEstimates(api, sessionId) : void 0;
197
+ return computeContext(counts, limits, cost, estimates, config.exclude);
138
198
  }
139
- function renderPanel(api, sessionId) {
199
+ function renderPanel(api, sessionId, config) {
140
200
  const theme = api.theme.current;
141
- const usage = sessionUsage(api, sessionId);
142
- const header = [text({ fg: theme.text, attributes: BOLD }, ["Context"])];
201
+ const usage = sessionUsage(api, sessionId, config);
202
+ const header = /* @__PURE__ */ jsx("text", { fg: theme.text, attributes: BOLD, children: "Context" });
143
203
  const lines = [header];
144
204
  const hasUsage = usage.used > 0;
145
205
  if (usage.known && hasUsage) {
146
- const bar = segmentBar(usage.segments, usage.window, BAR_WIDTH);
206
+ const bar = segmentBar(usage.segments, usage.window, BAR_WIDTH, config.exclude);
147
207
  lines.push(
148
- box({ flexDirection: "row", justifyContent: "space-between" }, [
149
- box({ flexDirection: "row" }, bar.map((cell) => text({ fg: segmentColor(cell.id, theme) }, ["\u2501".repeat(cell.cells)]))),
150
- text({ fg: tierColor(usage.percent, theme) }, [` ${usage.percent}%`])
151
- ])
208
+ /* @__PURE__ */ jsxs("box", { flexDirection: "row", justifyContent: "space-between", children: [
209
+ /* @__PURE__ */ jsx("box", { flexDirection: "row", children: bar.map((cell) => /* @__PURE__ */ jsx("text", { fg: segmentColor(cell.id, theme, config.estimate), children: "\u2501".repeat(cell.cells) })) }),
210
+ /* @__PURE__ */ jsx("text", { fg: tierColor(usage.percent, theme), children: ` ${usage.percent}%` })
211
+ ] })
152
212
  );
213
+ if (config.estimate) {
214
+ const legend = (ids) => {
215
+ const entries = usage.segments.filter((segment) => ids.includes(segment.id));
216
+ if (entries.length === 0) return null;
217
+ return /* @__PURE__ */ jsx("box", { flexDirection: "row", children: entries.map((segment) => /* @__PURE__ */ jsxs("box", { flexDirection: "row", children: [
218
+ /* @__PURE__ */ jsx("text", { fg: segmentColor(segment.id, theme, true), children: "\u258D" }),
219
+ /* @__PURE__ */ jsxs("text", { fg: theme.textMuted, children: [
220
+ SEGMENT_LABEL[segment.id],
221
+ compactFmt.format(segment.tokens)
222
+ ] })
223
+ ] })) });
224
+ };
225
+ const usedLegend = legend(["cached", "user", "tools", "system", "think", "out"]);
226
+ const budgetLegend = legend(["reserved", "free"]);
227
+ if (usedLegend) lines.push(usedLegend);
228
+ if (budgetLegend) lines.push(budgetLegend);
229
+ } else {
230
+ lines.push(
231
+ /* @__PURE__ */ jsx("box", { flexDirection: "row", gap: 1, children: usage.segments.map((segment) => /* @__PURE__ */ jsxs("box", { flexDirection: "row", children: [
232
+ /* @__PURE__ */ jsx("text", { fg: segmentColor(segment.id, theme, false), children: "\u258D" }),
233
+ /* @__PURE__ */ jsxs("text", { fg: theme.textMuted, children: [
234
+ SEGMENT_LABEL[segment.id],
235
+ compactFmt.format(segment.tokens)
236
+ ] })
237
+ ] })) })
238
+ );
239
+ }
153
240
  }
154
241
  if (hasUsage) {
155
242
  lines.push(
156
- text({ fg: theme.textMuted }, [
157
- `${formatInt(usage.used)} / ${usage.known ? formatInt(usage.window) : "--"} tokens`
158
- ])
243
+ /* @__PURE__ */ jsx("text", { fg: theme.textMuted, children: `${intFmt.format(usage.used)} / ${usage.known ? intFmt.format(usage.window) : "--"} tokens` })
159
244
  );
160
245
  } else {
161
- lines.push(text({ fg: theme.textMuted }, ["no assistant turns yet"]));
246
+ lines.push(/* @__PURE__ */ jsx("text", { fg: theme.textMuted, children: "no assistant turns yet" }));
162
247
  }
163
248
  if (usage.cost > 0) {
164
- lines.push(text({ fg: theme.textMuted }, [`${money.format(usage.cost)} spent`]));
165
- }
166
- if (usage.known && hasUsage) {
167
- lines.push(
168
- box({ flexDirection: "row", gap: 1 }, usage.segments.map(
169
- (segment) => box({ flexDirection: "row" }, [
170
- text({ fg: segmentColor(segment.id, theme) }, ["\u258D"]),
171
- text({ fg: theme.textMuted }, [`${SEGMENT_LABEL[segment.id]}${formatCompact(segment.tokens)}`])
172
- ])
173
- ))
174
- );
249
+ lines.push(/* @__PURE__ */ jsx("text", { fg: theme.textMuted, children: `${money.format(usage.cost)} spent` }));
175
250
  }
176
- return box({ width: "100%", flexDirection: "column" }, lines);
251
+ return /* @__PURE__ */ jsx("box", { width: "100%", flexDirection: "column", children: lines });
177
252
  }
178
- function segmentColor(id, theme) {
179
- switch (id) {
180
- case "cached":
181
- return theme.success;
182
- case "prompt":
183
- return theme.accent;
184
- case "think":
185
- return theme.warning;
186
- case "out":
187
- return theme.info;
188
- case "reserved":
189
- return theme.textMuted;
190
- case "free":
191
- return theme.text;
192
- }
253
+ function segmentColor(id, theme, estimate) {
254
+ const base = {
255
+ cached: theme.success,
256
+ prompt: theme.accent,
257
+ think: theme.warning,
258
+ out: theme.info,
259
+ reserved: theme.textMuted,
260
+ free: theme.text,
261
+ user: theme.accent,
262
+ tools: theme.accent,
263
+ system: theme.accent
264
+ };
265
+ if (!estimate) return base[id];
266
+ const est = {
267
+ user: theme.info,
268
+ tools: theme.accent,
269
+ system: theme.warning,
270
+ think: theme.secondary,
271
+ out: theme.text,
272
+ free: theme.borderSubtle
273
+ };
274
+ return est[id] ?? base[id];
193
275
  }
194
276
  function tierColor(percent, theme) {
195
277
  if (percent >= 100) return theme.error;
@@ -197,30 +279,6 @@ function tierColor(percent, theme) {
197
279
  if (percent >= 50) return theme.accent;
198
280
  return theme.success;
199
281
  }
200
- function formatInt(value) {
201
- return new Intl.NumberFormat("en-US").format(value);
202
- }
203
- function formatCompact(value) {
204
- if (value >= 1e6) return `${(value / 1e6).toFixed(1).replace(/\.0$/, "")}M`;
205
- if (value >= 1e3) return `${Math.round(value / 1e3)}k`;
206
- return String(value);
207
- }
208
- function element(tag, props, children = []) {
209
- const node = createElement(tag);
210
- for (const [key, value] of Object.entries(props)) {
211
- if (value !== void 0) setProp(node, key, value);
212
- }
213
- for (const child of children) {
214
- if (child !== null && child !== void 0 && child !== false) insert(node, child);
215
- }
216
- return node;
217
- }
218
- function text(props, children = []) {
219
- return element("text", props, children);
220
- }
221
- function box(props, children = []) {
222
- return element("box", props, children);
223
- }
224
282
  var tui_default = plugin;
225
283
  export {
226
284
  tui_default as default
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": "0.1.0",
4
+ "version": "1.1.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": {
@@ -16,7 +16,7 @@
16
16
  "opencode": ">=1.18.0"
17
17
  },
18
18
  "scripts": {
19
- "build": "esbuild src/tui.ts --bundle --format=esm --platform=node --external:@opencode-ai/plugin --external:@opentui/solid --external:@opentui/core --external:solid-js --outfile=dist/tui.js",
19
+ "build": "esbuild src/tui.tsx --bundle --format=esm --platform=node --jsx=automatic --jsx-import-source=@opentui/solid --external:@opencode-ai/plugin --external:@opentui/solid --external:@opentui/core --external:solid-js --outfile=dist/tui.js",
20
20
  "dev:install": "npm run build && mkdir -p \"$HOME/.config/opencode/context\" && cp dist/tui.js \"$HOME/.config/opencode/context/tui.js\"",
21
21
  "typecheck": "tsc --noEmit",
22
22
  "test": "node scripts/check.mjs",
@@ -47,7 +47,6 @@
47
47
  "devDependencies": {
48
48
  "@opencode-ai/plugin": "1.18.18",
49
49
  "@opentui/core": "0.5.4",
50
- "@opentui/keymap": "0.5.4",
51
50
  "@opentui/solid": "0.5.4",
52
51
  "@types/node": "^26.2.0",
53
52
  "esbuild": "^0.28.0",