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 +1 -1
- package/dist/calc.js +177 -0
- package/dist/tui.js +163 -0
- package/package.json +6 -18
- package/src/calc.ts +0 -249
- package/src/tui.tsx +0 -286
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
|
|
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.
|
|
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
|
-
".": "./
|
|
12
|
-
"./tui": "./
|
|
12
|
+
".": "./dist/tui.js",
|
|
13
|
+
"./tui": "./dist/tui.js"
|
|
13
14
|
},
|
|
14
15
|
"files": [
|
|
15
|
-
"
|
|
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
|
-
})
|