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.
- package/LICENSE +21 -0
- package/README.md +118 -0
- package/dist/tui.js +227 -0
- 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
|
+
}
|