opencode-plugin-context 0.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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +118 -0
  3. package/dist/tui.js +227 -0
  4. package/package.json +57 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 opencode-plugin-usage contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,118 @@
1
+ # opencode-plugin-context
2
+
3
+ An OpenCode **TUI plugin** that replaces the built-in sidebar context block with a
4
+ colored, segmented bar of the current session's **context-window usage**.
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.
10
+
11
+ ```
12
+ Context
13
+ ━━━━━━━━━━━━━━━━━ 69%
14
+ 138k / 200k tokens
15
+ $0.04 spent
16
+ ▍c40k ▍p90k ▍t5k ▍o3k ▍r5k ▍f57k
17
+ ```
18
+
19
+ One color-coded legend row follows the bar — `▍` marker in the segment's color,
20
+ then a muted letter + count. Colors follow the active theme:
21
+
22
+ | Segment | Legend | Theme color | Default look |
23
+ | ------------------ | ------ | ----------- | --------------------- |
24
+ | cached input | `c` | `success` | green |
25
+ | prompt (uncached input, incl. cache writes) | `p` | `accent` | blue |
26
+ | thinking (reasoning tokens) | `t` | `warning` | amber |
27
+ | output | `o` | `info` | cyan |
28
+ | reserved output | `r` | `textMuted` | grey |
29
+ | free space | `f` | `text` | white / default text |
30
+
31
+ 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%`.
37
+
38
+ ## Requirements
39
+
40
+ - OpenCode `>= 1.18.0`
41
+
42
+ ## Install
43
+
44
+ From npm:
45
+
46
+ ```sh
47
+ opencode plugin opencode-plugin-context --global --force
48
+ ```
49
+
50
+ The command installs the plugin **and registers it in
51
+ `~/.config/opencode/tui.json`** — no manual `plugin` entry needed.
52
+
53
+ Then disable the built-in block it replaces (it renders above yours) by adding
54
+ to `~/.config/opencode/tui.json`:
55
+
56
+ ```jsonc
57
+ {
58
+ "plugin_enabled": { "internal:sidebar-context": false }
59
+ }
60
+ ```
61
+
62
+ Restart OpenCode.
63
+
64
+ ## Local development
65
+
66
+ ```sh
67
+ git clone https://github.com/lhw/opencode-plugin-context
68
+ cd opencode-plugin-context
69
+ npm install
70
+ npm run dev:install # builds dist/tui.js and copies it into ~/.config/opencode/context/
71
+ ```
72
+
73
+ Then register it in `~/.config/opencode/tui.json` and restart OpenCode:
74
+
75
+ ```jsonc
76
+ {
77
+ "plugin_enabled": { "internal:sidebar-context": false },
78
+ "plugin": [["./context/tui.js", {}]]
79
+ }
80
+ ```
81
+
82
+ > The plugin must NOT live in the auto-discovered `~/.config/opencode/plugins/`
83
+ > directory — that is scanned for **server** plugins, and opencode rejects this
84
+ > TUI-only module there. TUI plugins are only loaded via `tui.json`.
85
+
86
+ ## How it works
87
+
88
+ - Renders into the `sidebar_content` slot (order `60`) via `@opentui/solid`.
89
+ - Reads the latest resolved assistant turn's token buckets
90
+ (`tokens.input/output/reasoning/cache.{read,write}`) and the model's
91
+ `limit.context` / `limit.output` from `api.state.provider` — the same source
92
+ the built-in block uses.
93
+ - **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.
96
+ - Cost comes from `session.cost`, falling back to summing assistant `cost`.
97
+ - Repaints on `message.*` / `session.*` events plus a 2-second self-heal timer.
98
+
99
+ ## What it deliberately does *not* show
100
+
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.
105
+
106
+ ## Development
107
+
108
+ ```sh
109
+ npm run typecheck # tsc --noEmit
110
+ npm test # pure-math self-checks (node, no deps)
111
+ npm run build # esbuild → dist/tui.js
112
+ npm run dev:install # build + install into ~/.config/opencode/context/
113
+ npm publish # runs typecheck + build + test first
114
+ ```
115
+
116
+ ## License
117
+
118
+ MIT
package/dist/tui.js ADDED
@@ -0,0 +1,227 @@
1
+ // src/tui.ts
2
+ import { createElement, insert, setProp } from "@opentui/solid";
3
+ import { createTextAttributes } from "@opentui/core";
4
+ import { createSignal } from "solid-js";
5
+
6
+ // src/context.ts
7
+ function tokensOf(m) {
8
+ const t = record(m?.tokens);
9
+ const cache = record(t.cache);
10
+ return {
11
+ input: num(t.input),
12
+ output: num(t.output),
13
+ reasoning: num(t.reasoning),
14
+ cacheRead: num(cache.read),
15
+ cacheWrite: num(cache.write)
16
+ };
17
+ }
18
+ function record(v) {
19
+ return typeof v === "object" && v !== null && !Array.isArray(v) ? v : {};
20
+ }
21
+ function num(v) {
22
+ return typeof v === "number" && Number.isFinite(v) && v > 0 ? Math.round(v) : 0;
23
+ }
24
+ function computeContext(counts, limits, cost = 0) {
25
+ const { input, output, reasoning, cacheRead, cacheWrite } = counts;
26
+ const used = input + cacheRead + cacheWrite + reasoning + output;
27
+ const window = limits && limits.context > 0 ? limits.context : 0;
28
+ const reserved = limits && limits.output > 0 ? Math.max(0, limits.output - output) : 0;
29
+ const free = window > 0 ? Math.max(0, window - used - reserved) : 0;
30
+ const raw = [
31
+ { id: "cached", tokens: cacheRead },
32
+ { id: "prompt", tokens: input + cacheWrite },
33
+ { id: "think", tokens: reasoning },
34
+ { id: "out", tokens: output },
35
+ { id: "reserved", tokens: reserved },
36
+ { id: "free", tokens: free }
37
+ ];
38
+ return {
39
+ used,
40
+ window,
41
+ percent: window > 0 ? Math.min(100, Math.round(used / window * 100)) : 0,
42
+ segments: raw.filter((segment) => segment.tokens > 0),
43
+ cost,
44
+ known: window > 0
45
+ };
46
+ }
47
+ function segmentBar(segments, window, width) {
48
+ if (window <= 0 || width <= 0) return [];
49
+ let remaining = width;
50
+ const out = [];
51
+ let i = 0;
52
+ for (; i < segments.length; i++) {
53
+ const segment = segments[i];
54
+ if (segment.id === "free") break;
55
+ const cells = Math.min(remaining, Math.round(segment.tokens / window * width));
56
+ if (cells > 0) out.push({ id: segment.id, cells });
57
+ remaining -= cells;
58
+ }
59
+ if (remaining > 0) out.push({ id: "free", cells: remaining });
60
+ return out;
61
+ }
62
+
63
+ // src/tui.ts
64
+ var BAR_WIDTH = 32;
65
+ var BOLD = createTextAttributes({ bold: true });
66
+ var SLOT_ORDER = 60;
67
+ var SEGMENT_LABEL = {
68
+ cached: "c",
69
+ prompt: "p",
70
+ think: "t",
71
+ out: "o",
72
+ reserved: "r",
73
+ free: "f"
74
+ };
75
+ var money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
76
+ var plugin = {
77
+ id: "opencode-plugin-context",
78
+ tui: async (api) => {
79
+ const [getRenderTick, setRenderTick] = createSignal(0);
80
+ const repaint = () => {
81
+ setRenderTick((n) => n + 1);
82
+ api.renderer.requestRender();
83
+ };
84
+ const unsubs = [
85
+ api.event.on("message.updated", repaint),
86
+ api.event.on("message.part.updated", repaint),
87
+ api.event.on("message.part.removed", repaint),
88
+ api.event.on("message.removed", repaint),
89
+ api.event.on("session.updated", repaint),
90
+ api.event.on("session.compacted", repaint),
91
+ api.event.on("session.status", repaint),
92
+ api.event.on("session.idle", repaint)
93
+ ];
94
+ const repaintTimer = setInterval(repaint, 2e3);
95
+ api.lifecycle.onDispose(() => {
96
+ for (const unsub of unsubs) unsub();
97
+ clearInterval(repaintTimer);
98
+ });
99
+ api.slots.register({
100
+ order: SLOT_ORDER,
101
+ slots: {
102
+ sidebar_content(_ctx, props) {
103
+ getRenderTick();
104
+ return renderPanel(api, props.session_id);
105
+ }
106
+ }
107
+ });
108
+ }
109
+ };
110
+ function sessionUsage(api, sessionId) {
111
+ const messages = api.state.session.messages(sessionId);
112
+ let last;
113
+ for (const message of messages) {
114
+ const m = message;
115
+ if (m.role === "assistant" && (m.tokens?.output ?? 0) > 0) {
116
+ last = {
117
+ tokens: m.tokens,
118
+ providerID: message.providerID,
119
+ modelID: message.modelID,
120
+ cost: message.cost
121
+ };
122
+ }
123
+ }
124
+ const session = api.state.session.get(sessionId);
125
+ const cost = session?.cost ?? messages.reduce((sum, message) => {
126
+ if (message.role === "assistant") sum += message.cost ?? 0;
127
+ return sum;
128
+ }, 0);
129
+ const counts = tokensOf(last);
130
+ let limits;
131
+ if (last?.providerID && last.modelID) {
132
+ const model = api.state.provider.find((p) => p.id === last.providerID)?.models[last.modelID];
133
+ if (model?.limit?.context) {
134
+ limits = { context: model.limit.context, output: model.limit.output ?? 0 };
135
+ }
136
+ }
137
+ return computeContext(counts, limits, cost);
138
+ }
139
+ function renderPanel(api, sessionId) {
140
+ const theme = api.theme.current;
141
+ const usage = sessionUsage(api, sessionId);
142
+ const header = [text({ fg: theme.text, attributes: BOLD }, ["Context"])];
143
+ const lines = [header];
144
+ const hasUsage = usage.used > 0;
145
+ if (usage.known && hasUsage) {
146
+ const bar = segmentBar(usage.segments, usage.window, BAR_WIDTH);
147
+ 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
+ ])
152
+ );
153
+ }
154
+ if (hasUsage) {
155
+ lines.push(
156
+ text({ fg: theme.textMuted }, [
157
+ `${formatInt(usage.used)} / ${usage.known ? formatInt(usage.window) : "--"} tokens`
158
+ ])
159
+ );
160
+ } else {
161
+ lines.push(text({ fg: theme.textMuted }, ["no assistant turns yet"]));
162
+ }
163
+ 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
+ );
175
+ }
176
+ return box({ width: "100%", flexDirection: "column" }, lines);
177
+ }
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
+ }
193
+ }
194
+ function tierColor(percent, theme) {
195
+ if (percent >= 100) return theme.error;
196
+ if (percent >= 75) return theme.warning;
197
+ if (percent >= 50) return theme.accent;
198
+ return theme.success;
199
+ }
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
+ var tui_default = plugin;
225
+ export {
226
+ tui_default as default
227
+ };
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/package.json",
3
+ "name": "opencode-plugin-context",
4
+ "version": "0.1.0",
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
+ "type": "module",
7
+ "exports": {
8
+ "./tui": {
9
+ "import": "./dist/tui.js"
10
+ }
11
+ },
12
+ "files": [
13
+ "dist"
14
+ ],
15
+ "engines": {
16
+ "opencode": ">=1.18.0"
17
+ },
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",
20
+ "dev:install": "npm run build && mkdir -p \"$HOME/.config/opencode/context\" && cp dist/tui.js \"$HOME/.config/opencode/context/tui.js\"",
21
+ "typecheck": "tsc --noEmit",
22
+ "test": "node scripts/check.mjs",
23
+ "prepublishOnly": "npm run typecheck && npm run build && npm test"
24
+ },
25
+ "keywords": [
26
+ "opencode",
27
+ "opencode-plugin",
28
+ "context",
29
+ "context-window",
30
+ "tokens",
31
+ "sidebar",
32
+ "tui"
33
+ ],
34
+ "license": "MIT",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/lhw/opencode-plugin-context.git"
38
+ },
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "peerDependencies": {
43
+ "@opencode-ai/plugin": ">=1.14.0",
44
+ "@opentui/core": ">=0.2.9",
45
+ "@opentui/solid": ">=0.2.9"
46
+ },
47
+ "devDependencies": {
48
+ "@opencode-ai/plugin": "1.18.18",
49
+ "@opentui/core": "0.5.4",
50
+ "@opentui/keymap": "0.5.4",
51
+ "@opentui/solid": "0.5.4",
52
+ "@types/node": "^26.2.0",
53
+ "esbuild": "^0.28.0",
54
+ "solid-js": "1.9.12",
55
+ "typescript": "^7.0.2"
56
+ }
57
+ }