opencode-skill-audit-tui 0.4.0 → 0.4.2

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/README.md CHANGED
@@ -37,7 +37,7 @@ the other plugins you already use — one more line, nothing else:
37
37
  ```
38
38
 
39
39
  OpenCode downloads and installs the package on restart — no checkout, no
40
- build. The entrypoint is the default export of `src/tui.tsx`, exposed as the
40
+ build. The entrypoint is `dist/tui.js` (compiled from `src/tui.tsx`), exposed as the
41
41
  package root and as `opencode-skill-audit-tui/tui`.
42
42
 
43
43
  ### From a local checkout
package/dist/calc.js ADDED
@@ -0,0 +1,177 @@
1
+ // Pure formatting/aggregation for the context meter. No imports on purpose:
2
+ // this module is imported by both the TUI plugin and a headless test, so it
3
+ // must stay loadable without @opencode/plugin/tui or solid-js.
4
+ /**
5
+ * Splits the skill catalog into what costs context on every single turn and
6
+ * what only costs it when actually invoked.
7
+ *
8
+ * A skill is *advertised* when it has a description and is not marked
9
+ * `autoinvoke: false` -- that description sits in the system prompt on every
10
+ * request regardless of whether the skill is ever used. The body is only
11
+ * paid for on activation, so `loadedChars` is what the session actually
12
+ * spent.
13
+ *
14
+ * `loadedByName` maps an activated skill id to the characters it injected.
15
+ */
16
+ export function buildSkillAudit(skills, loadedByName) {
17
+ const rows = skills.map((skill) => {
18
+ const description = skill.description ?? "";
19
+ const id = skill.name || skill.id;
20
+ const advertised = description.length > 0 && skill.autoinvoke !== false;
21
+ return {
22
+ id,
23
+ name: id,
24
+ descChars: description.length,
25
+ bodyChars: skill.content?.length ?? 0,
26
+ advertised,
27
+ loaded: loadedByName.has(skill.id) || loadedByName.has(id),
28
+ };
29
+ });
30
+ return {
31
+ rows,
32
+ total: rows.length,
33
+ advertisedCount: rows.filter((row) => row.advertised).length,
34
+ descChars: rows.reduce((sum, row) => sum + (row.advertised ? row.descChars : 0), 0),
35
+ loadedCount: loadedByName.size,
36
+ loadedChars: [...loadedByName.values()].reduce((a, b) => a + b, 0),
37
+ };
38
+ }
39
+ /**
40
+ * Tokens the next request will carry.
41
+ *
42
+ * OpenCode reports per-message usage where `input` is only the uncached slice;
43
+ * the rest of the window arrives as `cache.read`. Reasoning is generated
44
+ * inside the window and is billed again on the following turn, so it counts
45
+ * toward occupancy the same way Claude Code's statusline counts it.
46
+ */
47
+ export function contextTokens(usage) {
48
+ if (!usage)
49
+ return 0;
50
+ const cache = usage.cache ?? {};
51
+ return (usage.input ?? 0) + (cache.read ?? 0) + (cache.write ?? 0) + (usage.reasoning ?? 0);
52
+ }
53
+ /** 200000 -> "200k", 1050000 -> "1.05M". Thousands are abbreviated so the
54
+ * footer never wraps on a narrow terminal. */
55
+ export function formatTokens(n) {
56
+ if (!Number.isFinite(n) || n <= 0)
57
+ return "0";
58
+ if (n < 1000)
59
+ return String(Math.round(n));
60
+ if (n < 1_000_000) {
61
+ const k = n / 1000;
62
+ return `${k < 10 ? k.toFixed(1) : Math.round(k)}k`.replace(/\.0k$/, "k");
63
+ }
64
+ const m = n / 1_000_000;
65
+ return `${m.toFixed(m < 10 ? 2 : 1)}M`.replace(/\.?0+M$/, "M");
66
+ }
67
+ /**
68
+ * Skill bodies are measured in characters, not tokens -- OpenCode exposes no
69
+ * tokenizer and a char count silently labelled "tokens" would be a lie. The
70
+ * unit is spelled out in the output instead.
71
+ */
72
+ export function formatKB(chars) {
73
+ if (!Number.isFinite(chars) || chars <= 0)
74
+ return "0 KB";
75
+ return `${(chars / 1000).toFixed(1)} KB`;
76
+ }
77
+ /**
78
+ * OpenCode ships no tokenizer and exposes no way to count the system prompt or
79
+ * built-in tool definitions, so a Claude-Code-style breakdown can only be exact
80
+ * for the parts it reports. Everything derived from text is converted at 4
81
+ * characters per token, which is the usual English average and lands within
82
+ * roughly 10% -- hence every derived figure is labelled as an estimate.
83
+ */
84
+ export const CHARS_PER_TOKEN = 4;
85
+ export function estimateTokens(chars) {
86
+ if (!Number.isFinite(chars) || chars <= 0)
87
+ return 0;
88
+ return Math.round(chars / CHARS_PER_TOKEN);
89
+ }
90
+ /**
91
+ * Builds the context table.
92
+ *
93
+ * The provider-reported figure is the size of the whole window, so it already
94
+ * contains the skills, the agents, the system prompt and the tool definitions.
95
+ * It is therefore NOT added to them -- doing so double-counts and can exceed the
96
+ * window. Instead the measurable static parts are itemised and the remainder is
97
+ * reported as one unattributed row.
98
+ *
99
+ * That remainder is real context that OpenCode simply does not expose: the
100
+ * system prompt, the built-in tool definitions, the MCP tool schemas and the
101
+ * conversation. Showing the gap is more honest than quietly rescaling the
102
+ * known rows to fill it.
103
+ *
104
+ * Character-derived rows are estimates at CHARS_PER_TOKEN and are flagged.
105
+ */
106
+ export function buildContextBreakdown(input) {
107
+ const used = input.windowTokens;
108
+ const share = (tokens) => (input.limit > 0 ? (tokens / input.limit) * 100 : 0);
109
+ const desc = estimateTokens(input.skillDescChars);
110
+ const bodies = estimateTokens(input.skillBodyChars);
111
+ const agents = estimateTokens(input.agentChars);
112
+ const known = desc + bodies + agents;
113
+ const rows = [
114
+ {
115
+ label: "Skill descriptions",
116
+ tokens: desc,
117
+ share: share(desc),
118
+ estimated: true,
119
+ detail: `${input.skillCount} skills - charged on every request`,
120
+ },
121
+ {
122
+ label: "Skill bodies loaded",
123
+ tokens: bodies,
124
+ share: share(bodies),
125
+ estimated: true,
126
+ detail: `${input.loadedSkills} activated this session`,
127
+ },
128
+ {
129
+ label: "Agent definitions",
130
+ tokens: agents,
131
+ share: share(agents),
132
+ estimated: true,
133
+ detail: `${input.agentCount} agents`,
134
+ },
135
+ {
136
+ label: "System prompt, tools and conversation",
137
+ tokens: Math.max(0, used - known),
138
+ share: share(Math.max(0, used - known)),
139
+ estimated: false,
140
+ detail: `${input.mcpServers} MCP servers connected; their tool definitions are not exposed, so they sit in here`,
141
+ },
142
+ ];
143
+ return {
144
+ rows,
145
+ knownTokens: known,
146
+ limit: input.limit,
147
+ usedTokens: used,
148
+ freeTokens: Math.max(0, input.limit - used),
149
+ unaccountedTokens: Math.max(0, used - known),
150
+ threshold: input.threshold,
151
+ buffer: input.buffer,
152
+ };
153
+ }
154
+ /**
155
+ * Proportion bar of a fixed width. `share` is a percentage of the whole.
156
+ *
157
+ * A row that rounds to zero cells still gets one, otherwise the small-but-real
158
+ * costs (a few dozen tokens of agent text) would render as nothing at all and
159
+ * the reader would conclude they are free.
160
+ */
161
+ export function proportionBar(share, width = 40, filled = "█", empty = "░") {
162
+ if (width <= 0)
163
+ return "";
164
+ const safe = Number.isFinite(share) ? Math.max(0, share) : 0;
165
+ const exact = (safe / 100) * width;
166
+ const cells = Math.max(exact > 0 ? 1 : 0, Math.min(width, Math.round(exact)));
167
+ return filled.repeat(cells) + empty.repeat(width - cells);
168
+ }
169
+ /**
170
+ * True once the window is within `margin` percent of the compaction point.
171
+ * Used to warn before the user is surprised by an automatic compaction.
172
+ */
173
+ export function nearThreshold(usedTokens, threshold, margin = 0.8) {
174
+ if (threshold <= 0 || usedTokens <= 0)
175
+ return false;
176
+ return usedTokens >= threshold * margin;
177
+ }
package/dist/tui.js ADDED
@@ -0,0 +1,163 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "@opentui/solid/jsx-runtime";
2
+ import { Plugin } from "@opencode/plugin/tui";
3
+ import { For, Show } from "solid-js";
4
+ import { contextTokens, formatTokens, formatKB, buildSkillAudit, buildContextBreakdown, proportionBar, nearThreshold, } from "./calc.js";
5
+ /**
6
+ * On-demand context reporting. Nothing renders in the TUI until you ask:
7
+ *
8
+ * /context what is filling the window, broken down by category
9
+ * /skill-audit every skill OpenCode knows, split by what each one costs
10
+ *
11
+ * Skill activations are recorded as `skill` tool calls, not as messages.
12
+ * Verified against the live API: a session that loaded four skills returns zero
13
+ * results for `type=skill`. The tool's content array is the exact text injected
14
+ * into the window, so its length is the real cost.
15
+ */
16
+ function loadedSkillMap(messages) {
17
+ const out = new Map();
18
+ for (const message of messages) {
19
+ for (const part of message?.content ?? []) {
20
+ if (part?.type !== "tool" || part?.name !== "skill")
21
+ continue;
22
+ const name = part?.state?.input?.id;
23
+ if (!name)
24
+ continue;
25
+ let chars = 0;
26
+ for (const chunk of part?.state?.content ?? [])
27
+ chars += String(chunk?.text ?? "").length;
28
+ out.set(name, Math.max(out.get(name) ?? 0, chars));
29
+ }
30
+ }
31
+ return out;
32
+ }
33
+ /**
34
+ * OpenCode exposes no "current session" accessor, so this assumes the session
35
+ * touched most recently is the one on screen. That holds for a foreground TUI; it
36
+ * would be wrong if you switched tabs without sending anything.
37
+ */
38
+ function currentSession(context) {
39
+ const all = context.data.session.list();
40
+ if (!Array.isArray(all) || all.length === 0)
41
+ return undefined;
42
+ return all.slice().sort((a, b) => (b?.time?.updated ?? 0) - (a?.time?.updated ?? 0))[0];
43
+ }
44
+ const BAR_WIDTH = 62;
45
+ const COL_LABEL = 48;
46
+ const COL_VALUE = 10;
47
+ const COL_SHARE = 8;
48
+ export default Plugin.define({
49
+ id: "skill-audit",
50
+ setup(context) {
51
+ const muted = () => context.theme?.text?.muted ?? context.theme?.text?.base;
52
+ const alert = () => context.theme?.warning ?? context.theme?.error ?? context.theme?.text?.feedback;
53
+ const read = () => {
54
+ const session = currentSession(context);
55
+ const messages = session ? (context.data.session.message.list(session.id) ?? []) : [];
56
+ const catalog = context.data.location?.skill?.list?.(context.location) ?? [];
57
+ const agents = context.data.location?.agent?.list?.(context.location) ?? [];
58
+ const servers = context.data.location?.mcp?.server?.list?.(context.location) ?? [];
59
+ const loaded = loadedSkillMap(messages);
60
+ let windowTokens = 0;
61
+ for (let i = messages.length - 1; i >= 0; i--) {
62
+ const usage = messages[i]?.tokens;
63
+ if (usage) {
64
+ windowTokens = contextTokens(usage);
65
+ break;
66
+ }
67
+ }
68
+ const model = session?.model;
69
+ const models = context.data.location?.model?.list?.(context.location) ?? [];
70
+ const limit = (Array.isArray(models) ? models : []).find((m) => m?.providerID === model?.providerID && m?.modelID === model?.id)?.limit?.context ?? 0;
71
+ const agentChars = agents.reduce((sum, agent) => sum + String(agent?.description ?? "").length + String(agent?.system ?? "").length, 0);
72
+ return { catalog, agents, servers, loaded, windowTokens, model, limit, agentChars };
73
+ };
74
+ context.keymap.layer(() => ({
75
+ mode: "global",
76
+ priority: 10,
77
+ commands: [
78
+ {
79
+ id: "skill-audit.context",
80
+ title: "Context breakdown",
81
+ group: "Skill audit",
82
+ slash: { name: "context" },
83
+ run: async () => {
84
+ const state = read();
85
+ const audit = buildSkillAudit(state.catalog, state.loaded);
86
+ const config = await context.client?.config?.get?.().catch(() => undefined);
87
+ const breakdown = buildContextBreakdown({
88
+ windowTokens: state.windowTokens,
89
+ skillDescChars: state.catalog.reduce((sum, s) => sum + (s?.autoinvoke === false ? 0 : String(s?.description ?? "").length), 0),
90
+ skillBodyChars: audit.loadedChars,
91
+ agentChars: state.agentChars,
92
+ mcpServers: Array.isArray(state.servers) ? state.servers.length : 0,
93
+ limit: state.limit,
94
+ threshold: config?.compaction?.keep?.tokens ?? 0,
95
+ buffer: config?.compaction?.buffer ?? 0,
96
+ loadedSkills: audit.loadedCount,
97
+ skillCount: audit.advertisedCount,
98
+ agentCount: Array.isArray(state.agents) ? state.agents.length : 0,
99
+ });
100
+ const pct = (tokens) => state.limit > 0 ? `${((tokens / state.limit) * 100).toFixed(1)}%` : "-";
101
+ const usedPct = state.limit > 0 ? (state.windowTokens / state.limit) * 100 : 0;
102
+ const warn = nearThreshold(state.windowTokens, breakdown.threshold);
103
+ context.ui.dialog.set({ size: "large", centered: true });
104
+ context.ui.dialog.show(() => (_jsxs("box", { flexDirection: "column", padding: 1, children: [_jsxs("box", { flexDirection: "row", alignItems: "baseline", children: [_jsx("text", { fg: context.theme?.text?.base, children: "Context" }), _jsx("text", { fg: muted(), children: state.model?.modelID ?? "unknown model" })] }), _jsxs("box", { flexDirection: "row", alignItems: "baseline", marginTop: 1, gap: 2, children: [_jsx("text", { fg: warn ? alert() : context.theme?.text?.base, children: proportionBar(usedPct, BAR_WIDTH) }), _jsx("text", { width: 7, textAlign: "right", fg: warn ? alert() : context.theme?.text?.base, children: usedPct.toFixed(1) + "%" })] }), _jsx("text", { fg: muted(), children: formatTokens(breakdown.usedTokens) + " of " + formatTokens(breakdown.limit) }), _jsxs("box", { flexDirection: "row", marginTop: 1, children: [_jsx("text", { width: COL_LABEL, fg: muted(), truncate: true, children: "Category" }), _jsx("text", { width: COL_VALUE, textAlign: "right", fg: muted(), children: "Tokens" }), _jsx("text", { width: COL_SHARE, textAlign: "right", fg: muted(), children: "Share" })] }), _jsx(For, { each: breakdown.rows, children: (row) => (_jsxs("box", { flexDirection: "column", children: [_jsxs("box", { flexDirection: "row", alignItems: "baseline", children: [_jsx("text", { width: COL_LABEL, truncate: true, fg: row.estimated ? muted() : context.theme?.text?.base, children: row.label }), _jsx("text", { width: COL_VALUE, textAlign: "right", fg: row.estimated ? muted() : context.theme?.text?.base, children: (row.estimated ? "~" : "") + formatTokens(row.tokens) }), _jsx("text", { width: COL_SHARE, textAlign: "right", fg: muted(), children: pct(row.tokens) })] }), _jsx(Show, { when: row.detail, children: _jsx("text", { fg: muted(), paddingLeft: 2, truncate: true, children: row.detail }) })] })) }), _jsxs("box", { flexDirection: "row", marginTop: 1, alignItems: "baseline", children: [_jsx("text", { width: COL_LABEL, fg: muted(), children: "Free" }), _jsx("text", { width: COL_VALUE, textAlign: "right", fg: muted(), children: formatTokens(breakdown.freeTokens) }), _jsx(Show, { when: breakdown.threshold > 0, children: _jsx("box", { flexDirection: "row", width: COL_SHARE + 14, children: _jsx("text", { textAlign: "right", fg: warn ? alert() : muted(), truncate: true, children: "compaction " +
105
+ formatTokens(breakdown.threshold) +
106
+ (breakdown.buffer > 0 ? " +" + formatTokens(breakdown.buffer) : "") }) }) })] }), _jsx(Show, { when: warn, children: _jsx("text", { fg: alert(), children: "Approaching compaction \u2014 compact now to free context." }) }), _jsx("text", { fg: muted(), marginTop: 1, truncate: true, children: "~ estimated from characters (\u00F74); system prompt and tools not exposed" })] })), () => { });
107
+ },
108
+ },
109
+ {
110
+ id: "skill-audit.audit",
111
+ title: "Skill audit",
112
+ group: "Skill audit",
113
+ slash: { name: "skill-audit" },
114
+ run: async () => {
115
+ const state = read();
116
+ const audit = buildSkillAudit(state.catalog, state.loaded);
117
+ const options = audit.rows.map((row) => ({
118
+ title: (row.loaded ? "● " : "○ ") + row.name,
119
+ description: formatKB(row.descChars + row.bodyChars),
120
+ value: row.id,
121
+ loaded: row.loaded,
122
+ descChars: row.descChars,
123
+ bodyChars: row.bodyChars,
124
+ category: row.loaded ? "Loaded this session" : "Not loaded",
125
+ }));
126
+ // A leading slash switches sort/filter mode. Skill names never start
127
+ // with one, so this cannot collide with the name search.
128
+ const bySize = (a, b) => Number(b.loaded) - Number(a.loaded) || b.descChars - a.descChars;
129
+ const byName = (a, b) => a.value.localeCompare(b.value);
130
+ const byBody = (a, b) => b.bodyChars - a.bodyChars;
131
+ await context.ui.dialog.select({
132
+ title: audit.advertisedCount +
133
+ "/" +
134
+ audit.total +
135
+ " advertised · " +
136
+ formatKB(audit.descChars) +
137
+ " in every prompt",
138
+ placeholder: "/unused · /big · /name · /size",
139
+ options: options,
140
+ search: (query, all) => {
141
+ const q = query.trim().toLowerCase();
142
+ if (q === "/unused") {
143
+ return all.filter((o) => !o.loaded).sort(byBody);
144
+ }
145
+ if (q === "/big")
146
+ return [...all].sort(byBody);
147
+ if (q === "/name")
148
+ return [...all].sort(byName);
149
+ if (q === "/size")
150
+ return [...all].sort(bySize);
151
+ if (q.startsWith("/"))
152
+ return [...all].sort(bySize);
153
+ return q
154
+ ? all.filter((o) => o.value.toLowerCase().includes(q))
155
+ : [...all].sort(bySize);
156
+ },
157
+ });
158
+ },
159
+ },
160
+ ],
161
+ }));
162
+ },
163
+ });
package/package.json CHANGED
@@ -1,23 +1,24 @@
1
1
  {
2
2
  "name": "opencode-skill-audit-tui",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "OpenCode v2 TUI plugin: context occupancy and loaded-skill accounting in the footer",
5
5
  "type": "module",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/zademy/opencode-skill-audit.git"
9
9
  },
10
+ "main": "./dist/tui.js",
10
11
  "exports": {
11
- ".": "./src/tui.tsx",
12
- "./tui": "./src/tui.tsx"
12
+ ".": "./dist/tui.js",
13
+ "./tui": "./dist/tui.js"
13
14
  },
14
15
  "files": [
15
- "src/tui.tsx",
16
- "src/calc.ts",
16
+ "dist",
17
17
  "README.md"
18
18
  ],
19
19
  "scripts": {
20
20
  "build": "tsc -p tsconfig.build.json",
21
+ "prepublishOnly": "npm run build",
21
22
  "typecheck": "tsc --noEmit",
22
23
  "build:check": "tsc -p tsconfig.build.json --noEmit",
23
24
  "lint": "eslint .",
@@ -26,19 +27,6 @@
26
27
  "test": "node src/calc.test.ts",
27
28
  "check": "npm run format:check && npm run lint && npm run typecheck && npm run build:check && npm run test"
28
29
  },
29
- "peerDependencies": {
30
- "@opentui/core": ">=0.5.8",
31
- "@opentui/solid": ">=0.5.8",
32
- "solid-js": ">=1.9.0"
33
- },
34
- "peerDependenciesMeta": {
35
- "@opentui/core": {
36
- "optional": true
37
- },
38
- "@opentui/solid": {
39
- "optional": true
40
- }
41
- },
42
30
  "devDependencies": {
43
31
  "@opencode/plugin": "2.0.26",
44
32
  "@opentui/core": "0.6.0",
package/src/calc.ts DELETED
@@ -1,249 +0,0 @@
1
- // Pure formatting/aggregation for the context meter. No imports on purpose:
2
- // this module is imported by both the TUI plugin and a headless test, so it
3
- // must stay loadable without @opencode/plugin/tui or solid-js.
4
-
5
- export type TokenUsage = {
6
- input?: number
7
- output?: number
8
- reasoning?: number
9
- cache?: { read?: number; write?: number }
10
- }
11
-
12
- export type CatalogSkill = {
13
- id: string
14
- name?: string
15
- description?: string
16
- content?: string
17
- autoinvoke?: boolean
18
- }
19
-
20
- export type SkillRow = {
21
- id: string
22
- name: string
23
- descChars: number
24
- bodyChars: number
25
- advertised: boolean
26
- loaded: boolean
27
- }
28
-
29
- export type SkillAudit = {
30
- rows: SkillRow[]
31
- total: number
32
- advertisedCount: number
33
- descChars: number
34
- loadedCount: number
35
- loadedChars: number
36
- }
37
-
38
- /**
39
- * Splits the skill catalog into what costs context on every single turn and
40
- * what only costs it when actually invoked.
41
- *
42
- * A skill is *advertised* when it has a description and is not marked
43
- * `autoinvoke: false` -- that description sits in the system prompt on every
44
- * request regardless of whether the skill is ever used. The body is only
45
- * paid for on activation, so `loadedChars` is what the session actually
46
- * spent.
47
- *
48
- * `loadedByName` maps an activated skill id to the characters it injected.
49
- */
50
- export function buildSkillAudit(
51
- skills: CatalogSkill[],
52
- loadedByName: Map<string, number>,
53
- ): SkillAudit {
54
- const rows: SkillRow[] = skills.map((skill) => {
55
- const description = skill.description ?? ""
56
- const id = skill.name || skill.id
57
- const advertised = description.length > 0 && skill.autoinvoke !== false
58
- return {
59
- id,
60
- name: id,
61
- descChars: description.length,
62
- bodyChars: skill.content?.length ?? 0,
63
- advertised,
64
- loaded: loadedByName.has(skill.id) || loadedByName.has(id),
65
- }
66
- })
67
-
68
- return {
69
- rows,
70
- total: rows.length,
71
- advertisedCount: rows.filter((row) => row.advertised).length,
72
- descChars: rows.reduce((sum, row) => sum + (row.advertised ? row.descChars : 0), 0),
73
- loadedCount: loadedByName.size,
74
- loadedChars: [...loadedByName.values()].reduce((a, b) => a + b, 0),
75
- }
76
- }
77
-
78
- /**
79
- * Tokens the next request will carry.
80
- *
81
- * OpenCode reports per-message usage where `input` is only the uncached slice;
82
- * the rest of the window arrives as `cache.read`. Reasoning is generated
83
- * inside the window and is billed again on the following turn, so it counts
84
- * toward occupancy the same way Claude Code's statusline counts it.
85
- */
86
- export function contextTokens(usage: TokenUsage | undefined): number {
87
- if (!usage) return 0
88
- const cache = usage.cache ?? {}
89
- return (usage.input ?? 0) + (cache.read ?? 0) + (cache.write ?? 0) + (usage.reasoning ?? 0)
90
- }
91
-
92
- /** 200000 -> "200k", 1050000 -> "1.05M". Thousands are abbreviated so the
93
- * footer never wraps on a narrow terminal. */
94
- export function formatTokens(n: number): string {
95
- if (!Number.isFinite(n) || n <= 0) return "0"
96
- if (n < 1000) return String(Math.round(n))
97
- if (n < 1_000_000) {
98
- const k = n / 1000
99
- return `${k < 10 ? k.toFixed(1) : Math.round(k)}k`.replace(/\.0k$/, "k")
100
- }
101
- const m = n / 1_000_000
102
- return `${m.toFixed(m < 10 ? 2 : 1)}M`.replace(/\.?0+M$/, "M")
103
- }
104
-
105
- /**
106
- * Skill bodies are measured in characters, not tokens -- OpenCode exposes no
107
- * tokenizer and a char count silently labelled "tokens" would be a lie. The
108
- * unit is spelled out in the output instead.
109
- */
110
- export function formatKB(chars: number): string {
111
- if (!Number.isFinite(chars) || chars <= 0) return "0 KB"
112
- return `${(chars / 1000).toFixed(1)} KB`
113
- }
114
-
115
- /**
116
- * OpenCode ships no tokenizer and exposes no way to count the system prompt or
117
- * built-in tool definitions, so a Claude-Code-style breakdown can only be exact
118
- * for the parts it reports. Everything derived from text is converted at 4
119
- * characters per token, which is the usual English average and lands within
120
- * roughly 10% -- hence every derived figure is labelled as an estimate.
121
- */
122
- export const CHARS_PER_TOKEN = 4
123
-
124
- export function estimateTokens(chars: number): number {
125
- if (!Number.isFinite(chars) || chars <= 0) return 0
126
- return Math.round(chars / CHARS_PER_TOKEN)
127
- }
128
-
129
- export type BreakdownRow = {
130
- label: string
131
- tokens: number
132
- share: number
133
- estimated: boolean
134
- detail?: string
135
- }
136
-
137
- export type ContextBreakdown = {
138
- rows: BreakdownRow[]
139
- knownTokens: number
140
- limit: number
141
- usedTokens: number
142
- freeTokens: number
143
- unaccountedTokens: number
144
- threshold: number
145
- buffer: number
146
- }
147
-
148
- /**
149
- * Builds the context table.
150
- *
151
- * The provider-reported figure is the size of the whole window, so it already
152
- * contains the skills, the agents, the system prompt and the tool definitions.
153
- * It is therefore NOT added to them -- doing so double-counts and can exceed the
154
- * window. Instead the measurable static parts are itemised and the remainder is
155
- * reported as one unattributed row.
156
- *
157
- * That remainder is real context that OpenCode simply does not expose: the
158
- * system prompt, the built-in tool definitions, the MCP tool schemas and the
159
- * conversation. Showing the gap is more honest than quietly rescaling the
160
- * known rows to fill it.
161
- *
162
- * Character-derived rows are estimates at CHARS_PER_TOKEN and are flagged.
163
- */
164
- export function buildContextBreakdown(input: {
165
- windowTokens: number
166
- skillDescChars: number
167
- skillBodyChars: number
168
- agentChars: number
169
- mcpServers: number
170
- limit: number
171
- threshold: number
172
- buffer: number
173
- loadedSkills: number
174
- skillCount: number
175
- agentCount: number
176
- }): ContextBreakdown {
177
- const used = input.windowTokens
178
- const share = (tokens: number) => (input.limit > 0 ? (tokens / input.limit) * 100 : 0)
179
- const desc = estimateTokens(input.skillDescChars)
180
- const bodies = estimateTokens(input.skillBodyChars)
181
- const agents = estimateTokens(input.agentChars)
182
- const known = desc + bodies + agents
183
-
184
- const rows: BreakdownRow[] = [
185
- {
186
- label: "Skill descriptions",
187
- tokens: desc,
188
- share: share(desc),
189
- estimated: true,
190
- detail: `${input.skillCount} skills - charged on every request`,
191
- },
192
- {
193
- label: "Skill bodies loaded",
194
- tokens: bodies,
195
- share: share(bodies),
196
- estimated: true,
197
- detail: `${input.loadedSkills} activated this session`,
198
- },
199
- {
200
- label: "Agent definitions",
201
- tokens: agents,
202
- share: share(agents),
203
- estimated: true,
204
- detail: `${input.agentCount} agents`,
205
- },
206
- {
207
- label: "System prompt, tools and conversation",
208
- tokens: Math.max(0, used - known),
209
- share: share(Math.max(0, used - known)),
210
- estimated: false,
211
- detail: `${input.mcpServers} MCP servers connected; their tool definitions are not exposed, so they sit in here`,
212
- },
213
- ]
214
-
215
- return {
216
- rows,
217
- knownTokens: known,
218
- limit: input.limit,
219
- usedTokens: used,
220
- freeTokens: Math.max(0, input.limit - used),
221
- unaccountedTokens: Math.max(0, used - known),
222
- threshold: input.threshold,
223
- buffer: input.buffer,
224
- }
225
- }
226
-
227
- /**
228
- * Proportion bar of a fixed width. `share` is a percentage of the whole.
229
- *
230
- * A row that rounds to zero cells still gets one, otherwise the small-but-real
231
- * costs (a few dozen tokens of agent text) would render as nothing at all and
232
- * the reader would conclude they are free.
233
- */
234
- export function proportionBar(share: number, width = 40, filled = "█", empty = "░"): string {
235
- if (width <= 0) return ""
236
- const safe = Number.isFinite(share) ? Math.max(0, share) : 0
237
- const exact = (safe / 100) * width
238
- const cells = Math.max(exact > 0 ? 1 : 0, Math.min(width, Math.round(exact)))
239
- return filled.repeat(cells) + empty.repeat(width - cells)
240
- }
241
-
242
- /**
243
- * True once the window is within `margin` percent of the compaction point.
244
- * Used to warn before the user is surprised by an automatic compaction.
245
- */
246
- export function nearThreshold(usedTokens: number, threshold: number, margin = 0.8): boolean {
247
- if (threshold <= 0 || usedTokens <= 0) return false
248
- return usedTokens >= threshold * margin
249
- }
package/src/tui.tsx DELETED
@@ -1,286 +0,0 @@
1
- import { Plugin } from "@opencode/plugin/tui"
2
- import { For, Show } from "solid-js"
3
- import {
4
- contextTokens,
5
- formatTokens,
6
- formatKB,
7
- buildSkillAudit,
8
- buildContextBreakdown,
9
- proportionBar,
10
- nearThreshold,
11
- } from "./calc.ts"
12
-
13
- /**
14
- * On-demand context reporting. Nothing renders in the TUI until you ask:
15
- *
16
- * /context what is filling the window, broken down by category
17
- * /skill-audit every skill OpenCode knows, split by what each one costs
18
- *
19
- * Skill activations are recorded as `skill` tool calls, not as messages.
20
- * Verified against the live API: a session that loaded four skills returns zero
21
- * results for `type=skill`. The tool's content array is the exact text injected
22
- * into the window, so its length is the real cost.
23
- */
24
- function loadedSkillMap(messages: any[]): Map<string, number> {
25
- const out = new Map<string, number>()
26
- for (const message of messages) {
27
- for (const part of (message as any)?.content ?? []) {
28
- if (part?.type !== "tool" || part?.name !== "skill") continue
29
- const name = part?.state?.input?.id
30
- if (!name) continue
31
- let chars = 0
32
- for (const chunk of part?.state?.content ?? []) chars += String(chunk?.text ?? "").length
33
- out.set(name, Math.max(out.get(name) ?? 0, chars))
34
- }
35
- }
36
- return out
37
- }
38
-
39
- /**
40
- * OpenCode exposes no "current session" accessor, so this assumes the session
41
- * touched most recently is the one on screen. That holds for a foreground TUI; it
42
- * would be wrong if you switched tabs without sending anything.
43
- */
44
- function currentSession(context: any): any {
45
- const all = context.data.session.list()
46
- if (!Array.isArray(all) || all.length === 0) return undefined
47
- return all.slice().sort((a: any, b: any) => (b?.time?.updated ?? 0) - (a?.time?.updated ?? 0))[0]
48
- }
49
-
50
- const BAR_WIDTH = 62
51
- const COL_LABEL = 48
52
- const COL_VALUE = 10
53
- const COL_SHARE = 8
54
-
55
- export default Plugin.define({
56
- id: "skill-audit",
57
- setup(context) {
58
- const muted = () => context.theme?.text?.muted ?? context.theme?.text?.base
59
- const alert = () =>
60
- context.theme?.warning ?? context.theme?.error ?? context.theme?.text?.feedback
61
-
62
- const read = () => {
63
- const session = currentSession(context)
64
- const messages = session ? (context.data.session.message.list(session.id) ?? []) : []
65
- const catalog = context.data.location?.skill?.list?.(context.location) ?? []
66
- const agents = context.data.location?.agent?.list?.(context.location) ?? []
67
- const servers = context.data.location?.mcp?.server?.list?.(context.location) ?? []
68
- const loaded = loadedSkillMap(messages)
69
-
70
- let windowTokens = 0
71
- for (let i = messages.length - 1; i >= 0; i--) {
72
- const usage = (messages[i] as any)?.tokens
73
- if (usage) {
74
- windowTokens = contextTokens(usage)
75
- break
76
- }
77
- }
78
-
79
- const model = session?.model
80
- const models = context.data.location?.model?.list?.(context.location) ?? []
81
- const limit =
82
- (Array.isArray(models) ? models : []).find(
83
- (m: any) => m?.providerID === model?.providerID && m?.modelID === model?.id,
84
- )?.limit?.context ?? 0
85
-
86
- const agentChars = agents.reduce(
87
- (sum: number, agent: any) =>
88
- sum + String(agent?.description ?? "").length + String(agent?.system ?? "").length,
89
- 0,
90
- )
91
-
92
- return { catalog, agents, servers, loaded, windowTokens, model, limit, agentChars }
93
- }
94
-
95
- context.keymap.layer(() => ({
96
- mode: "global",
97
- priority: 10,
98
- commands: [
99
- {
100
- id: "skill-audit.context",
101
- title: "Context breakdown",
102
- group: "Skill audit",
103
- slash: { name: "context" },
104
- run: async () => {
105
- const state = read()
106
- const audit = buildSkillAudit(state.catalog, state.loaded)
107
- const config = await (context.client as any)?.config?.get?.().catch(() => undefined)
108
-
109
- const breakdown = buildContextBreakdown({
110
- windowTokens: state.windowTokens,
111
- skillDescChars: state.catalog.reduce(
112
- (sum: number, s: any) =>
113
- sum + (s?.autoinvoke === false ? 0 : String(s?.description ?? "").length),
114
- 0,
115
- ),
116
- skillBodyChars: audit.loadedChars,
117
- agentChars: state.agentChars,
118
- mcpServers: Array.isArray(state.servers) ? state.servers.length : 0,
119
- limit: state.limit,
120
- threshold: config?.compaction?.keep?.tokens ?? 0,
121
- buffer: config?.compaction?.buffer ?? 0,
122
- loadedSkills: audit.loadedCount,
123
- skillCount: audit.advertisedCount,
124
- agentCount: Array.isArray(state.agents) ? state.agents.length : 0,
125
- })
126
-
127
- const pct = (tokens: number) =>
128
- state.limit > 0 ? `${((tokens / state.limit) * 100).toFixed(1)}%` : "-"
129
- const usedPct = state.limit > 0 ? (state.windowTokens / state.limit) * 100 : 0
130
- const warn = nearThreshold(state.windowTokens, breakdown.threshold)
131
-
132
- context.ui.dialog.set({ size: "large", centered: true })
133
- context.ui.dialog.show(
134
- () => (
135
- <box flexDirection="column" padding={1}>
136
- <box flexDirection="row" alignItems="baseline">
137
- <text fg={context.theme?.text?.base}>Context</text>
138
- <text fg={muted()}>{state.model?.modelID ?? "unknown model"}</text>
139
- </box>
140
-
141
- <box flexDirection="row" alignItems="baseline" marginTop={1} gap={2}>
142
- <text fg={warn ? alert() : context.theme?.text?.base}>
143
- {proportionBar(usedPct, BAR_WIDTH)}
144
- </text>
145
- <text
146
- width={7}
147
- textAlign="right"
148
- fg={warn ? alert() : context.theme?.text?.base}
149
- >
150
- {usedPct.toFixed(1) + "%"}
151
- </text>
152
- </box>
153
- <text fg={muted()}>
154
- {formatTokens(breakdown.usedTokens) + " of " + formatTokens(breakdown.limit)}
155
- </text>
156
-
157
- <box flexDirection="row" marginTop={1}>
158
- <text width={COL_LABEL} fg={muted()} truncate>
159
- Category
160
- </text>
161
- <text width={COL_VALUE} textAlign="right" fg={muted()}>
162
- Tokens
163
- </text>
164
- <text width={COL_SHARE} textAlign="right" fg={muted()}>
165
- Share
166
- </text>
167
- </box>
168
-
169
- <For each={breakdown.rows}>
170
- {(row) => (
171
- <box flexDirection="column">
172
- <box flexDirection="row" alignItems="baseline">
173
- <text
174
- width={COL_LABEL}
175
- truncate
176
- fg={row.estimated ? muted() : context.theme?.text?.base}
177
- >
178
- {row.label}
179
- </text>
180
- <text
181
- width={COL_VALUE}
182
- textAlign="right"
183
- fg={row.estimated ? muted() : context.theme?.text?.base}
184
- >
185
- {(row.estimated ? "~" : "") + formatTokens(row.tokens)}
186
- </text>
187
- <text width={COL_SHARE} textAlign="right" fg={muted()}>
188
- {pct(row.tokens)}
189
- </text>
190
- </box>
191
- <Show when={row.detail}>
192
- <text fg={muted()} paddingLeft={2} truncate>
193
- {row.detail}
194
- </text>
195
- </Show>
196
- </box>
197
- )}
198
- </For>
199
-
200
- <box flexDirection="row" marginTop={1} alignItems="baseline">
201
- <text width={COL_LABEL} fg={muted()}>
202
- Free
203
- </text>
204
- <text width={COL_VALUE} textAlign="right" fg={muted()}>
205
- {formatTokens(breakdown.freeTokens)}
206
- </text>
207
- <Show when={breakdown.threshold > 0}>
208
- <box flexDirection="row" width={COL_SHARE + 14}>
209
- <text textAlign="right" fg={warn ? alert() : muted()} truncate>
210
- {"compaction " +
211
- formatTokens(breakdown.threshold) +
212
- (breakdown.buffer > 0 ? " +" + formatTokens(breakdown.buffer) : "")}
213
- </text>
214
- </box>
215
- </Show>
216
- </box>
217
-
218
- <Show when={warn}>
219
- <text fg={alert()}>Approaching compaction — compact now to free context.</text>
220
- </Show>
221
-
222
- <text fg={muted()} marginTop={1} truncate>
223
- ~ estimated from characters (÷4); system prompt and tools not exposed
224
- </text>
225
- </box>
226
- ),
227
- () => {},
228
- )
229
- },
230
- },
231
- {
232
- id: "skill-audit.audit",
233
- title: "Skill audit",
234
- group: "Skill audit",
235
- slash: { name: "skill-audit" },
236
- run: async () => {
237
- const state = read()
238
- const audit = buildSkillAudit(state.catalog, state.loaded)
239
-
240
- const options = audit.rows.map((row) => ({
241
- title: (row.loaded ? "● " : "○ ") + row.name,
242
- description: formatKB(row.descChars + row.bodyChars),
243
- value: row.id,
244
- loaded: row.loaded,
245
- descChars: row.descChars,
246
- bodyChars: row.bodyChars,
247
- category: row.loaded ? "Loaded this session" : "Not loaded",
248
- }))
249
-
250
- // A leading slash switches sort/filter mode. Skill names never start
251
- // with one, so this cannot collide with the name search.
252
- const bySize = (a: any, b: any) =>
253
- Number(b.loaded) - Number(a.loaded) || b.descChars - a.descChars
254
- const byName = (a: any, b: any) => a.value.localeCompare(b.value)
255
- const byBody = (a: any, b: any) => b.bodyChars - a.bodyChars
256
-
257
- await context.ui.dialog.select({
258
- title:
259
- audit.advertisedCount +
260
- "/" +
261
- audit.total +
262
- " advertised · " +
263
- formatKB(audit.descChars) +
264
- " in every prompt",
265
- placeholder: "/unused · /big · /name · /size",
266
- options: options as any,
267
- search: (query, all) => {
268
- const q = query.trim().toLowerCase()
269
- if (q === "/unused") {
270
- return all.filter((o: any) => !o.loaded).sort(byBody)
271
- }
272
- if (q === "/big") return [...all].sort(byBody)
273
- if (q === "/name") return [...all].sort(byName)
274
- if (q === "/size") return [...all].sort(bySize)
275
- if (q.startsWith("/")) return [...all].sort(bySize)
276
- return q
277
- ? all.filter((o: any) => o.value.toLowerCase().includes(q))
278
- : [...all].sort(bySize)
279
- },
280
- })
281
- },
282
- },
283
- ],
284
- }))
285
- },
286
- })