seestack 0.0.0-stage → 1.0.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/.claude-plugin/marketplace.json +22 -0
- package/LICENSE +21 -0
- package/README.md +164 -2
- package/bin/cli.js +141 -0
- package/mods/context-bar/.claude-plugin/plugin.json +11 -0
- package/mods/context-bar/hooks/hooks.json +1 -0
- package/mods/context-bar/hooks/register.tsx +598 -0
- package/mods/context-bar/tests/context-bar.test.tsx +549 -0
- package/mods/context-bar/tsconfig.json +3 -0
- package/mods/context-bar/types/index.d.ts +74 -0
- package/mods/read-aloud/.claude-plugin/plugin.json +42 -0
- package/mods/read-aloud/hooks/hooks.json +1 -0
- package/mods/read-aloud/hooks/register.tsx +920 -0
- package/mods/read-aloud/tests/read-aloud.test.ts +235 -0
- package/mods/read-aloud/tsconfig.json +3 -0
- package/mods/read-aloud/types/index.d.ts +43 -0
- package/package.json +37 -4
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
import { describe, expect, test } from 'claude-code/testing'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
barFill,
|
|
5
|
+
blocks,
|
|
6
|
+
cells,
|
|
7
|
+
figures,
|
|
8
|
+
frameWidth,
|
|
9
|
+
legend,
|
|
10
|
+
lists,
|
|
11
|
+
messageItems,
|
|
12
|
+
metrics,
|
|
13
|
+
promptItems,
|
|
14
|
+
share,
|
|
15
|
+
toReading,
|
|
16
|
+
tokens,
|
|
17
|
+
toolItems,
|
|
18
|
+
} from '../hooks/register'
|
|
19
|
+
|
|
20
|
+
const BAND = { component: 'AbovePrompt', props: { hasSurvey: false, isWorking: false, maxRows: 20, bodyColumns: 84 } }
|
|
21
|
+
|
|
22
|
+
// /context's breakdown as the engine hands it over, for a 1M window.
|
|
23
|
+
const BREAKDOWN = {
|
|
24
|
+
categories: [
|
|
25
|
+
{ name: 'System prompt', tokens: 3_400, color: 'promptBorder', kind: 'used', isDeferred: false },
|
|
26
|
+
{ name: 'System tools', tokens: 12_000, color: 'inactive', kind: 'used', isDeferred: false },
|
|
27
|
+
{ name: 'MCP tools', tokens: 2_600, color: 'cyan_FOR_SUBAGENTS_ONLY', kind: 'used', isDeferred: false },
|
|
28
|
+
{ name: 'MCP tools (deferred)', tokens: 40_000, color: 'inactive', kind: 'deferred', isDeferred: true },
|
|
29
|
+
{ name: 'Memory files', tokens: 4_000, color: 'warning', kind: 'used', isDeferred: false },
|
|
30
|
+
{ name: 'Custom agents', tokens: 960, color: 'permission', kind: 'used', isDeferred: false },
|
|
31
|
+
{ name: 'Skills', tokens: 2_000, color: 'warning', kind: 'used', isDeferred: false },
|
|
32
|
+
{ name: 'Slash commands', tokens: 1_500, color: 'inactive', kind: 'used', isDeferred: false },
|
|
33
|
+
{ name: 'Messages', tokens: 186_000, color: 'purple_FOR_SUBAGENTS_ONLY', kind: 'used', isDeferred: false },
|
|
34
|
+
{ name: 'Autocompact buffer', tokens: 50_000, color: 'inactive', kind: 'buffer', isDeferred: false },
|
|
35
|
+
{ name: 'Free space', tokens: 746_000, color: 'promptBorder', kind: 'free', isDeferred: false },
|
|
36
|
+
],
|
|
37
|
+
totalTokens: 204_000,
|
|
38
|
+
maxTokens: 1_000_000,
|
|
39
|
+
rawMaxTokens: 1_000_000,
|
|
40
|
+
percentage: 20,
|
|
41
|
+
autoCompactThreshold: 950_000,
|
|
42
|
+
isAutoCompactEnabled: true,
|
|
43
|
+
gridRows: [],
|
|
44
|
+
memoryFiles: [
|
|
45
|
+
{ path: '/Users/me/CLAUDE.md', type: 'User', tokens: 3_100 },
|
|
46
|
+
{ path: '/work/proj/CLAUDE.md', type: 'Project', tokens: 900 },
|
|
47
|
+
],
|
|
48
|
+
mcpTools: [
|
|
49
|
+
{ name: 'mcp__playwright__browser_navigate', serverName: 'playwright', tokens: 1_400, isLoaded: true },
|
|
50
|
+
{ name: 'mcp__playwright__browser_click', serverName: 'playwright', tokens: 600, isLoaded: false },
|
|
51
|
+
{ name: 'mcp__obsidian__search', serverName: 'obsidian', tokens: 1_200, isLoaded: true },
|
|
52
|
+
],
|
|
53
|
+
agents: [
|
|
54
|
+
{ agentType: 'Explore', source: 'userSettings', tokens: 800 },
|
|
55
|
+
{ agentType: 'general-purpose', source: 'built-in', tokens: 160 },
|
|
56
|
+
],
|
|
57
|
+
skills: {
|
|
58
|
+
totalSkills: 40,
|
|
59
|
+
includedSkills: 12,
|
|
60
|
+
tokens: 2_000,
|
|
61
|
+
skillFrontmatter: [{ name: 'brainstorm', source: 'userSettings', tokens: 300 }],
|
|
62
|
+
},
|
|
63
|
+
slashCommands: { totalCommands: 30, includedCommands: 25, tokens: 1_500 },
|
|
64
|
+
model: 'opus',
|
|
65
|
+
apiUsage: null,
|
|
66
|
+
autocompactSource: 'model-default',
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The system prompt as the engine composes it: ids, the text sent, and the cache boundary each
|
|
70
|
+
// sits on. The breakdown has no list for the System prompt row — this call is where it comes from.
|
|
71
|
+
const SECTIONS = [
|
|
72
|
+
{ id: 'intro', text: 'x'.repeat(4_000), scope: 'shared' },
|
|
73
|
+
{ id: 'system', text: 'x'.repeat(12_000), scope: 'shared' },
|
|
74
|
+
{ id: 'doing_tasks', text: 'x'.repeat(8_000), scope: 'session' },
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
// The tools the model can call, as `$.tool.list()` answers: built-in and MCP alike, in the order
|
|
78
|
+
// the model sees them. The engine counts the row whole and hands over no count per tool.
|
|
79
|
+
const TOOLS = [
|
|
80
|
+
{ name: 'Bash', description: 'Run a shell command in the working directory. Use it for builds.', mcp: false },
|
|
81
|
+
{ name: 'Read', description: 'Read a file from the filesystem', mcp: false },
|
|
82
|
+
{ name: 'mcp__playwright__browser_navigate', description: 'Navigate to a URL', mcp: true },
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
// The conversation as `$.session.messages()` returns it. The third row carries the same tool
|
|
86
|
+
// result the second one's call does: the transcript holds a result on both sides, one fact.
|
|
87
|
+
const MESSAGES = [
|
|
88
|
+
{ role: 'user' as const, text: 'check whats inside system prompt', toolUses: [] },
|
|
89
|
+
{
|
|
90
|
+
role: 'assistant' as const,
|
|
91
|
+
text: 'The typings run to 21,174 lines.',
|
|
92
|
+
toolUses: [{ tool_use_id: 't1', tool: 'Read', input: { file_path: '/x' }, text: 'z'.repeat(8_000) }],
|
|
93
|
+
},
|
|
94
|
+
{ role: 'user' as const, text: 'ok', toolUses: [], toolResults: [{ tool_use_id: 't1', text: 'z'.repeat(8_000) }] },
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
// Stands for the engine beneath the mod.
|
|
98
|
+
function engine(on: any, store: Record<string, unknown> = {}) {
|
|
99
|
+
const asked: unknown[] = []
|
|
100
|
+
on('session.start', (_$: any, e: any) => ({ sessionId: 's', cwd: e.cwd }))
|
|
101
|
+
on('command.register', () => ({ value: undefined }))
|
|
102
|
+
on('turn.complete', () => ({ text: '' }))
|
|
103
|
+
on('prompt.compose', () => ({ sections: SECTIONS }))
|
|
104
|
+
on('tool.list', () => ({ value: TOOLS }))
|
|
105
|
+
on('session.messages', () => ({ value: MESSAGES }))
|
|
106
|
+
on('store.get', (_$: any, e: any) => ({ value: store[e.key] }))
|
|
107
|
+
on('store.set', (_$: any, e: any) => ((store[e.key] = e.value), { value: undefined }))
|
|
108
|
+
on('session.usage', (_$: any, e: any) => {
|
|
109
|
+
asked.push(e)
|
|
110
|
+
return { value: { startedAt: 0, context: { tokens: 204_000, window: 1_000_000, percent: 20, breakdown: BREAKDOWN }, rateLimits: {}, cost: { usd: 0 } } }
|
|
111
|
+
})
|
|
112
|
+
on('ui.render', ($: any, e: any) => $.ui.resolve(e).Text({ children: 'band below' }))
|
|
113
|
+
return { asked, store }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const settle = () => new Promise(done => (globalThis as any).setTimeout(done, 20)) // the first refresh runs in the background
|
|
117
|
+
const mountBand = ($: any) => $.ui.mount({ plugin: 'context-bar', surface: 'terminal', ...BAND } as any)
|
|
118
|
+
|
|
119
|
+
async function start($: any) {
|
|
120
|
+
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' } as any)
|
|
121
|
+
await settle()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// What the band draws: one instance, so `findAll` counts what is on screen rather than a merge.
|
|
125
|
+
const texts = async (band: any) => (await band.findAll({ type: 'Text' })).map((t: any) => t.text)
|
|
126
|
+
const framed = async (band: any) => (await band.findAll({ type: 'Box' })).filter((b: any) => b.props.borderStyle === 'round')
|
|
127
|
+
|
|
128
|
+
describe('context-bar', () => {
|
|
129
|
+
test('helpers', () => {
|
|
130
|
+
expect(tokens(3_400)).toBe('3.4k')
|
|
131
|
+
expect(tokens(186_000)).toBe('186k')
|
|
132
|
+
expect(tokens(1_000_000)).toBe('1M')
|
|
133
|
+
expect(tokens(950)).toBe('950')
|
|
134
|
+
expect(share(3_400, 1_000_000)).toBe('0.3%')
|
|
135
|
+
expect(share(186_000, 1_000_000)).toBe('19%')
|
|
136
|
+
expect(share(494, 1_000_000)).toBe('<0.1%')
|
|
137
|
+
|
|
138
|
+
const r = toReading(BREAKDOWN)
|
|
139
|
+
// Deferred and empty rows are left out; used first, then free, then the buffer.
|
|
140
|
+
expect(r.slices.map(s => s.name)).toEqual([
|
|
141
|
+
'system prompt',
|
|
142
|
+
'system tools',
|
|
143
|
+
'mcp tools',
|
|
144
|
+
'memory files',
|
|
145
|
+
'custom agents',
|
|
146
|
+
'skills',
|
|
147
|
+
'slash commands',
|
|
148
|
+
'messages',
|
|
149
|
+
'free space',
|
|
150
|
+
'autocompact buffer',
|
|
151
|
+
])
|
|
152
|
+
expect(r.compactsAt).toBe(950_000)
|
|
153
|
+
// Every used row has its own color, and none matches free space or the buffer.
|
|
154
|
+
const colors = r.slices.filter(s => s.kind === 'used').map(s => s.color)
|
|
155
|
+
expect(new Set(colors).size).toBe(colors.length)
|
|
156
|
+
expect(cells(r, 80).find(c => c.kind === 'buffer')?.text).toMatch(/^░+$/)
|
|
157
|
+
expect(cells(r, 80).find(c => c.kind === 'free')?.text).toMatch(/^─+$/)
|
|
158
|
+
|
|
159
|
+
for (const width of [20, 47, 80, 200]) {
|
|
160
|
+
const bar = cells(r, width)
|
|
161
|
+
expect(bar.reduce((n, c) => n + c.text.length, 0)).toBe(width) // always exactly the width
|
|
162
|
+
expect(bar.filter(c => c.kind === 'used').every(c => c.text.length >= 1)).toBe(true) // a small used slice still shows
|
|
163
|
+
}
|
|
164
|
+
for (const line of legend(r, 40)) {
|
|
165
|
+
const size = line.reduce((n, s, i) => n + (i ? 3 : 0) + 2 + s.name.length + 1 + tokens(s.tokens).length + (s.kind === 'used' ? 1 + share(s.tokens, r.window).length : 0), 0)
|
|
166
|
+
expect(size <= 40 || line.length === 1).toBe(true)
|
|
167
|
+
}
|
|
168
|
+
expect(toReading({ ...BREAKDOWN, isAutoCompactEnabled: false }).compactsAt).toBeUndefined()
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('the drill-down groups MCP tools by server and shortens paths', () => {
|
|
172
|
+
const d = toReading(BREAKDOWN).drilldown
|
|
173
|
+
expect(d.memoryFiles).toEqual([
|
|
174
|
+
{ name: '~/CLAUDE.md', tokens: 3_100, note: 'User' },
|
|
175
|
+
{ name: '/work/proj/CLAUDE.md', tokens: 900, note: 'Project' },
|
|
176
|
+
])
|
|
177
|
+
// Servers by tokens, each with its tools named; an on-demand schema is counted, not listed.
|
|
178
|
+
expect(d.mcpServers).toEqual([
|
|
179
|
+
{ name: 'playwright', tokens: 2_000, note: 'browser_navigate · 1 on demand' },
|
|
180
|
+
{ name: 'obsidian', tokens: 1_200, note: 'search' },
|
|
181
|
+
])
|
|
182
|
+
expect(d.agents.map(a => a.name)).toEqual(['Explore', 'general-purpose'])
|
|
183
|
+
expect(d.skills).toEqual([{ name: 'brainstorm', tokens: 300, note: 'userSettings' }])
|
|
184
|
+
expect(d.slashCommands).toEqual({ tokens: 1_500, included: 25, total: 30 })
|
|
185
|
+
expect(d.model).toBe('opus')
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
test('draws the bar and legend above the prompt from a summary breakdown', async ($, on) => {
|
|
189
|
+
const { asked } = engine(on)
|
|
190
|
+
await start($)
|
|
191
|
+
expect(asked).toEqual([{ breakdown: 'summary' }]) // estimated locally, never the token-count API
|
|
192
|
+
|
|
193
|
+
const band = await mountBand($)
|
|
194
|
+
expect(await band.find({ type: 'Text', text: /204k of 1M · compacts at 950k/ })).toBeDefined()
|
|
195
|
+
expect(await band.find({ type: 'Text', text: / 20% / })).toBeDefined()
|
|
196
|
+
expect(await band.find({ type: 'Text', text: /^messages $/ })).toBeDefined() // the legend, not the detail table
|
|
197
|
+
expect(await band.find({ type: 'Text', text: /^186k$/ })).toBeDefined()
|
|
198
|
+
expect(await band.find({ type: 'Text', text: /mcp tools \(deferred\)/ })).toBeUndefined()
|
|
199
|
+
expect(await band.find({ type: 'Text', text: 'band below' })).toBeDefined() // the band beneath stays
|
|
200
|
+
await band.unmount()
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
test('the ▾ opens the same box in place: one frame, one header, the legend giving way', async ($, on) => {
|
|
204
|
+
engine(on)
|
|
205
|
+
await start($)
|
|
206
|
+
const band = await mountBand($)
|
|
207
|
+
|
|
208
|
+
expect(await band.find({ type: 'Text', text: /^detail$/ })).toBeUndefined() // folded: no detail yet
|
|
209
|
+
expect(await framed(band)).toHaveLength(1) // one box, folded
|
|
210
|
+
|
|
211
|
+
await band.press({ key: 'toggle' })
|
|
212
|
+
|
|
213
|
+
// Still one box and one header: nothing popped in beside the bar.
|
|
214
|
+
expect(await framed(band)).toHaveLength(1)
|
|
215
|
+
expect((await texts(band)).filter((t: string) => t === 'context')).toHaveLength(1)
|
|
216
|
+
expect(await band.find({ type: 'Text', text: /context window/ })).toBeUndefined() // the old second header is gone
|
|
217
|
+
// The bar is still drawn once, and the header still reads the same figures.
|
|
218
|
+
expect(await band.find({ type: 'Text', text: /204k of 1M · compacts at 950k/ })).toBeDefined()
|
|
219
|
+
// The legend has given way to the detail table.
|
|
220
|
+
expect(await band.find({ type: 'Text', text: /^messages $/ })).toBeUndefined()
|
|
221
|
+
expect(await band.find({ type: 'Text', text: /^detail$/ })).toBeDefined()
|
|
222
|
+
expect(await band.find({ type: 'Text', text: /^messages +$/ })).toBeDefined()
|
|
223
|
+
expect(await band.find({ type: 'Text', text: /^\s+186k$/ })).toBeDefined()
|
|
224
|
+
expect(await band.find({ type: 'Text', text: /^\s+19%$/ })).toBeDefined() // messages' share of the window
|
|
225
|
+
expect(await band.find({ type: 'Text', text: /^\s+0\.3%$/ })).toBeDefined() // system prompt's
|
|
226
|
+
// The rows that have detail behind them lead with a mark, and none of them is open yet.
|
|
227
|
+
for (const row of ['memory', 'mcp', 'agents', 'skills']) {
|
|
228
|
+
expect((await band.find({ type: 'Button', key: row }))?.props.label).toBe('▶')
|
|
229
|
+
}
|
|
230
|
+
// The command count and the window's line are figures: read at a glance, nothing to open.
|
|
231
|
+
expect(await band.find({ type: 'Button', key: 'slash' })).toBeUndefined()
|
|
232
|
+
expect(await band.find({ type: 'Button', key: 'window' })).toBeUndefined()
|
|
233
|
+
expect(await band.find({ type: 'Text', text: /^\s+25 of 30 · 1\.5k$/ })).toBeDefined()
|
|
234
|
+
expect(await band.find({ type: 'Text', text: /^\s+opus · compacts at 950k$/ })).toBeDefined()
|
|
235
|
+
expect(await band.find({ type: 'Text', text: '~/CLAUDE.md' })).toBeUndefined()
|
|
236
|
+
expect(await band.find({ type: 'Text', text: /^\s+browser_navigate · 1 on demand$/ })).toBeUndefined()
|
|
237
|
+
// The lists are not laid out again below the rows: a row is where its detail is.
|
|
238
|
+
expect(await band.find({ type: 'Text', text: /^memory files$/ })).toBeUndefined()
|
|
239
|
+
const minimize = await band.find({ type: 'Button', key: 'minimize' })
|
|
240
|
+
expect(minimize?.props).toMatchObject({ label: 'Minimize', hotkey: 'q' })
|
|
241
|
+
// The opened content is one block, sized to its widest row and never over the room it has.
|
|
242
|
+
const block = (await band.findAll({ type: 'Box' })).find((b: any) => typeof b.props.width === 'number')
|
|
243
|
+
expect(block?.props.width).toBe(metrics(frameWidth(BAND.props.bodyColumns - 4)).total)
|
|
244
|
+
expect(block!.props.width as number).toBeLessThanOrEqual(BAND.props.bodyColumns - 4)
|
|
245
|
+
await band.unmount()
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
test('a row unfolds its own list under itself, and only one row is open at a time', async ($, on) => {
|
|
249
|
+
engine(on)
|
|
250
|
+
await start($)
|
|
251
|
+
const band = await mountBand($)
|
|
252
|
+
await band.press({ key: 'toggle' })
|
|
253
|
+
|
|
254
|
+
expect(await band.find({ type: 'Text', text: /^brainstorm$/ })).toBeUndefined()
|
|
255
|
+
|
|
256
|
+
await band.press({ key: 'skills' })
|
|
257
|
+
expect((await band.find({ type: 'Button', key: 'skills' }))?.props.label).toBe('▼')
|
|
258
|
+
expect(await band.find({ type: 'Text', text: /^brainstorm$/ })).toBeDefined()
|
|
259
|
+
|
|
260
|
+
await band.press({ key: 'skills' }) // the ▶ it became: shut again
|
|
261
|
+
expect((await band.find({ type: 'Button', key: 'skills' }))?.props.label).toBe('▶')
|
|
262
|
+
expect(await band.find({ type: 'Text', text: /^brainstorm$/ })).toBeUndefined()
|
|
263
|
+
|
|
264
|
+
// Opening another shuts the first: the box is the rows plus one list, never two.
|
|
265
|
+
await band.press({ key: 'memory' })
|
|
266
|
+
expect(await band.find({ type: 'Text', text: '~/CLAUDE.md' })).toBeDefined()
|
|
267
|
+
await band.press({ key: 'mcp' })
|
|
268
|
+
expect(await band.find({ type: 'Text', text: /^\s+browser_navigate · 1 on demand$/ })).toBeDefined()
|
|
269
|
+
expect(await band.find({ type: 'Text', text: '~/CLAUDE.md' })).toBeUndefined()
|
|
270
|
+
expect((await band.find({ type: 'Button', key: 'memory' }))?.props.label).toBe('▶')
|
|
271
|
+
await band.unmount()
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
test('the marks say which rows have detail, and no list is repeated below them', () => {
|
|
275
|
+
const r = toReading(BREAKDOWN)
|
|
276
|
+
const l = lists(r)
|
|
277
|
+
expect(l.map(x => x.id)).toEqual(['memory', 'mcp', 'agents', 'skills'])
|
|
278
|
+
expect(l.map(x => x.note)).toEqual(['4k', 'by server · 3.2k', '960', '1 of 12 · 2k'])
|
|
279
|
+
// What /context counts with nothing under it is a figure, not a list: no mark, nothing to open.
|
|
280
|
+
expect(figures(r)).toEqual([
|
|
281
|
+
{ text: 'slash commands', note: '25 of 30 · 1.5k' },
|
|
282
|
+
{ text: 'window', note: 'opus · compacts at 950k' },
|
|
283
|
+
])
|
|
284
|
+
|
|
285
|
+
const shut = blocks(r)
|
|
286
|
+
const rows = shut.filter(b => b.kind === 'row')
|
|
287
|
+
expect(rows).toHaveLength(r.slices.length)
|
|
288
|
+
// The rows /context counts a list for carry the mark; the rest of the breakdown does not.
|
|
289
|
+
expect(rows.filter((b: any) => b.list).map((b: any) => b.list.id)).toEqual(['mcp', 'memory', 'agents', 'skills'])
|
|
290
|
+
expect(rows.every((b: any) => !b.open)).toBe(true)
|
|
291
|
+
expect(shut.filter(b => b.kind === 'item')).toHaveLength(0)
|
|
292
|
+
// Every list has a row to open under, so nothing is left over to keep an entry.
|
|
293
|
+
expect(shut.filter(b => b.kind === 'entry')).toHaveLength(0)
|
|
294
|
+
expect(shut.filter(b => b.kind === 'figure').map((b: any) => b.text)).toEqual(['slash commands', 'window'])
|
|
295
|
+
|
|
296
|
+
// Open one: its items follow its own row, and no other row opened with it.
|
|
297
|
+
const open = blocks(r, 'skills')
|
|
298
|
+
expect(open.filter(b => b.kind === 'item')).toHaveLength(1)
|
|
299
|
+
expect(open.find((b: any) => b.list?.id === 'skills')).toMatchObject({ kind: 'row', open: true })
|
|
300
|
+
expect(open.filter((b: any) => b.kind === 'row' && b.open)).toHaveLength(1)
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
test('the System prompt row reads off the composition, its figures marked as estimates', async ($, on) => {
|
|
304
|
+
engine(on)
|
|
305
|
+
await start($)
|
|
306
|
+
// The engine fires this for every prompt it sends; the sections come free here. Each fact
|
|
307
|
+
// left out is read off the session, so the model alone is enough to compose for.
|
|
308
|
+
await $.prompt.compose({ model: 'opus', promptModel: 'opus', surfaces: ['terminal'], tools: [], outputStyle: null, traits: [] } as any)
|
|
309
|
+
await settle()
|
|
310
|
+
const band = await mountBand($)
|
|
311
|
+
await band.press({ key: 'toggle' })
|
|
312
|
+
|
|
313
|
+
// The row the breakdown carries no list for now has one, and the mark says so.
|
|
314
|
+
expect((await band.find({ type: 'Button', key: 'prompt' }))?.props.label).toBe('▶')
|
|
315
|
+
await band.press({ key: 'prompt' })
|
|
316
|
+
for (const id of ['intro', 'system', 'doing_tasks']) {
|
|
317
|
+
expect(await band.find({ type: 'Text', text: id })).toBeDefined()
|
|
318
|
+
}
|
|
319
|
+
// `~` on every figure this mod estimated, and none on the engine's own.
|
|
320
|
+
expect(await band.find({ type: 'Text', text: /^\s+~3k$/ })).toBeDefined()
|
|
321
|
+
expect(await band.find({ type: 'Text', text: /^\s+shared$/ })).toBeDefined() // the cache boundary
|
|
322
|
+
expect(await band.find({ type: 'Text', text: /^\s+session$/ })).toBeDefined()
|
|
323
|
+
|
|
324
|
+
await band.press({ key: 'memory' }) // the engine's own counts keep no `~`
|
|
325
|
+
expect(await band.find({ type: 'Text', text: /^\s+3\.1k$/ })).toBeDefined()
|
|
326
|
+
await band.unmount()
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
test('the prompt sections are the engine\'s ids and text, and the tokens are the mod\'s guess', () => {
|
|
330
|
+
expect(promptItems(SECTIONS)).toEqual([
|
|
331
|
+
{ name: 'intro', tokens: 1_000, note: 'shared', estimated: true },
|
|
332
|
+
{ name: 'system', tokens: 3_000, note: 'shared', estimated: true },
|
|
333
|
+
{ name: 'doing_tasks', tokens: 2_000, note: 'session', estimated: true },
|
|
334
|
+
])
|
|
335
|
+
const r = toReading(BREAKDOWN)
|
|
336
|
+
// The list hangs off the System prompt row, and is a count of sections rather than a total:
|
|
337
|
+
// a total of this mod's estimates would sit against the engine's own figure for the row.
|
|
338
|
+
const l = lists(r, promptItems(SECTIONS))
|
|
339
|
+
expect(l.map(x => x.id)).toEqual(['prompt', 'memory', 'mcp', 'agents', 'skills'])
|
|
340
|
+
expect(l[0]!.note).toBe('3 sections')
|
|
341
|
+
expect(blocks(r, null, promptItems(SECTIONS)).find((b: any) => b.list?.id === 'prompt')).toMatchObject({
|
|
342
|
+
kind: 'row',
|
|
343
|
+
open: false,
|
|
344
|
+
})
|
|
345
|
+
// With no composition read yet, the row is a plain one again.
|
|
346
|
+
expect(lists(r).map(x => x.id)).toEqual(['memory', 'mcp', 'agents', 'skills'])
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
test('the System tools row is a roll-call: names and their own words, with no figure to give', async ($, on) => {
|
|
350
|
+
engine(on)
|
|
351
|
+
await start($)
|
|
352
|
+
await settle()
|
|
353
|
+
const band = await mountBand($)
|
|
354
|
+
await band.press({ key: 'toggle' })
|
|
355
|
+
|
|
356
|
+
expect((await band.find({ type: 'Button', key: 'tools' }))?.props.label).toBe('▶')
|
|
357
|
+
await band.press({ key: 'tools' })
|
|
358
|
+
expect(await band.find({ type: 'Text', text: 'Bash' })).toBeDefined()
|
|
359
|
+
expect(await band.find({ type: 'Text', text: /^\s+Run a shell command in the working directory$/ })).toBeDefined()
|
|
360
|
+
expect(await band.find({ type: 'Text', text: 'Read' })).toBeDefined()
|
|
361
|
+
// The cells are bare rather than filled with a zero: the engine counts the row whole.
|
|
362
|
+
expect(await band.find({ type: 'Text', text: /^\s+0$/ })).toBeUndefined()
|
|
363
|
+
// An MCP tool is counted under its own row, so it is not listed here as well.
|
|
364
|
+
expect(await band.find({ type: 'Text', text: 'mcp__playwright__browser_navigate' })).toBeUndefined()
|
|
365
|
+
await band.unmount()
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
test('the built-in tools carry no figure and leave MCP to its own row', () => {
|
|
369
|
+
expect(toolItems(TOOLS)).toEqual([
|
|
370
|
+
{ name: 'Bash', note: 'Run a shell command in the working directory' },
|
|
371
|
+
{ name: 'Read', note: 'Read a file from the filesystem' },
|
|
372
|
+
])
|
|
373
|
+
expect(toolItems(TOOLS).every(i => i.tokens === undefined)).toBe(true) // nothing guessed off a description
|
|
374
|
+
const long = toolItems([{ name: 'X', description: 'a'.repeat(90), mcp: false }])
|
|
375
|
+
expect(long[0]!.note!.length).toBeLessThanOrEqual(44)
|
|
376
|
+
expect(long[0]!.note!.endsWith('…')).toBe(true)
|
|
377
|
+
|
|
378
|
+
const r = toReading(BREAKDOWN)
|
|
379
|
+
const l = lists(r, [], toolItems(TOOLS))
|
|
380
|
+
expect(l.map(x => x.id)).toEqual(['tools', 'memory', 'mcp', 'agents', 'skills'])
|
|
381
|
+
expect(l[0]!.note).toBe('2 built-in')
|
|
382
|
+
// The System tools row hangs off it, and reads as a plain row until the tools are read.
|
|
383
|
+
expect(blocks(r, null, [], toolItems(TOOLS)).find((b: any) => b.list?.id === 'tools')).toMatchObject({ kind: 'row', open: false })
|
|
384
|
+
expect(lists(r).map(x => x.id)).toEqual(['memory', 'mcp', 'agents', 'skills'])
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
test('the Messages row opens onto the biggest messages, ranked', async ($, on) => {
|
|
388
|
+
engine(on)
|
|
389
|
+
await start($)
|
|
390
|
+
await settle()
|
|
391
|
+
const band = await mountBand($)
|
|
392
|
+
await band.press({ key: 'toggle' })
|
|
393
|
+
|
|
394
|
+
expect((await band.find({ type: 'Button', key: 'messages' }))?.props.label).toBe('▶')
|
|
395
|
+
await band.press({ key: 'messages' })
|
|
396
|
+
expect(await band.find({ type: 'Text', text: 'assistant · 1 call' })).toBeDefined()
|
|
397
|
+
expect(await band.find({ type: 'Text', text: /^\s+~2k$/ })).toBeDefined() // an estimate, marked as one
|
|
398
|
+
expect(await band.find({ type: 'Text', text: /^\s+check whats inside system prompt$/ })).toBeDefined()
|
|
399
|
+
await band.unmount()
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
test('the conversation is ranked by what each message costs, and a tool result counted once', () => {
|
|
403
|
+
const ranked = messageItems(MESSAGES)
|
|
404
|
+
expect(ranked.map(i => i.name)).toEqual(['assistant · 1 call', 'you', 'you'])
|
|
405
|
+
expect(ranked.map(i => i.note)).toEqual(['The typings run to 21,174 lines', 'check whats inside system prompt', 'ok'])
|
|
406
|
+
expect(ranked.every(i => i.estimated)).toBe(true)
|
|
407
|
+
// The result rides the call that made it; the user message carrying the same text is not charged again.
|
|
408
|
+
expect(ranked[0]!.tokens).toBeGreaterThan(2_000)
|
|
409
|
+
expect(ranked[2]!.tokens).toBe(1)
|
|
410
|
+
expect(ranked[1]!.tokens).toBe(Math.round('check whats inside system prompt'.length / 4))
|
|
411
|
+
// One call and more than one, told apart.
|
|
412
|
+
const many = [{ role: 'assistant' as const, text: '', toolUses: [{ tool_use_id: 'a', tool: 'Bash', input: {} }, { tool_use_id: 'b', tool: 'Read', input: {} }] }]
|
|
413
|
+
expect(messageItems(many)[0]!.name).toBe('assistant · 2 calls')
|
|
414
|
+
|
|
415
|
+
const r = toReading(BREAKDOWN)
|
|
416
|
+
const l = lists(r, [], [], messageItems(MESSAGES))
|
|
417
|
+
expect(l.map(x => x.id)).toEqual(['memory', 'mcp', 'agents', 'skills', 'messages'])
|
|
418
|
+
expect(l.find(x => x.id === 'messages')!.note).toBe('3 messages, biggest first')
|
|
419
|
+
// With no transcript read yet, the row is a plain one again.
|
|
420
|
+
expect(lists(r).map(x => x.id)).toEqual(['memory', 'mcp', 'agents', 'skills'])
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
test('a wide box centers the detail in it, rather than sprawling', async ($, on) => {
|
|
424
|
+
engine(on)
|
|
425
|
+
await start($)
|
|
426
|
+
const wide = { ...BAND, props: { ...BAND.props, bodyColumns: 120 } }
|
|
427
|
+
const band = await $.ui.mount({ plugin: 'context-bar', surface: 'terminal', ...wide } as any)
|
|
428
|
+
await band.press({ key: 'toggle' })
|
|
429
|
+
|
|
430
|
+
const room = 120 - 4
|
|
431
|
+
const block = (await band.findAll({ type: 'Box' })).find((b: any) => typeof b.props.width === 'number')
|
|
432
|
+
expect(block?.props.width).toBe(metrics(frameWidth(room)).total)
|
|
433
|
+
expect(block!.props.width as number).toBeLessThan(room) // margins on both sides to center in
|
|
434
|
+
const centering = (await band.findAll({ type: 'Box' })).find((b: any) => b.props.alignItems === 'center')
|
|
435
|
+
expect(centering).toBeDefined() // and the box that centers it holds the block's column
|
|
436
|
+
await band.unmount()
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
test('the ▴ and the Minimize button fold the one box back', async ($, on) => {
|
|
440
|
+
engine(on)
|
|
441
|
+
await start($)
|
|
442
|
+
const band = await mountBand($)
|
|
443
|
+
await band.press({ key: 'toggle' })
|
|
444
|
+
expect(await band.find({ type: 'Text', text: /^detail$/ })).toBeDefined()
|
|
445
|
+
|
|
446
|
+
await band.press({ key: 'toggle' }) // the ▴ it became
|
|
447
|
+
expect(await band.find({ type: 'Text', text: /^detail$/ })).toBeUndefined()
|
|
448
|
+
expect(await band.find({ type: 'Text', text: /^messages $/ })).toBeDefined() // the legend is back
|
|
449
|
+
expect(await framed(band)).toHaveLength(1)
|
|
450
|
+
|
|
451
|
+
await band.press({ key: 'toggle' })
|
|
452
|
+
await band.press({ key: 'minimize' })
|
|
453
|
+
expect(await band.find({ type: 'Text', text: /^detail$/ })).toBeUndefined()
|
|
454
|
+
await band.unmount()
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
test('the box stays open across a refresh, and hiding the bar folds it', async ($, on) => {
|
|
458
|
+
engine(on)
|
|
459
|
+
await start($)
|
|
460
|
+
const band = await mountBand($)
|
|
461
|
+
await band.press({ key: 'toggle' })
|
|
462
|
+
await band.press({ key: 'agents' }) // one section left open across the refresh
|
|
463
|
+
|
|
464
|
+
await $.turn.complete({ reason: 'answer', answer: 'ok', durationMs: 1 } as any)
|
|
465
|
+
await settle()
|
|
466
|
+
expect(await band.find({ type: 'Text', text: /^detail$/ })).toBeDefined() // a turn redraws it open
|
|
467
|
+
expect((await band.find({ type: 'Button', key: 'agents' }))?.props.label).toBe('▼') // and as it was left
|
|
468
|
+
await band.unmount()
|
|
469
|
+
|
|
470
|
+
await $.command.run({ command: 'context-bar', args: '' } as any) // hiding
|
|
471
|
+
await $.command.run({ command: 'context-bar', args: '' } as any) // showing again
|
|
472
|
+
const again = await mountBand($)
|
|
473
|
+
expect(await again.find({ type: 'Text', text: /^detail$/ })).toBeUndefined() // folded, not left open
|
|
474
|
+
await again.unmount()
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
test('refreshes after a main turn, not after a subagent turn', async ($, on) => {
|
|
478
|
+
const { asked } = engine(on)
|
|
479
|
+
await start($)
|
|
480
|
+
await $.turn.complete({ reason: 'answer', answer: 'ok', durationMs: 1, agentId: 'a1' } as any)
|
|
481
|
+
expect(asked.length).toBe(1)
|
|
482
|
+
await $.turn.complete({ reason: 'answer', answer: 'ok', durationMs: 1 } as any)
|
|
483
|
+
expect(asked.length).toBe(2)
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
test('refreshes after a compaction', async ($, on) => {
|
|
487
|
+
const { asked } = engine(on)
|
|
488
|
+
on('session.compact', () => ({ messages: [{ role: 'user', text: 'summary', toolUses: [] }] }))
|
|
489
|
+
await start($)
|
|
490
|
+
await $.session.compact({ trigger: 'manual', messages: [{ role: 'user', text: 'hi', toolUses: [] }] } as any)
|
|
491
|
+
await settle()
|
|
492
|
+
expect(asked.length).toBe(2)
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
test('/context-bar hides and shows it, and remembers the choice', async ($, on) => {
|
|
496
|
+
const { store } = engine(on)
|
|
497
|
+
await start($)
|
|
498
|
+
expect((await $.command.run({ command: 'context-bar', args: '' } as any)).text).toMatch(/hidden/)
|
|
499
|
+
expect(store.isHidden).toBe(true)
|
|
500
|
+
let band = await mountBand($)
|
|
501
|
+
expect(await band.find({ type: 'Text', text: /of 1M/ })).toBeUndefined()
|
|
502
|
+
await band.unmount()
|
|
503
|
+
|
|
504
|
+
expect((await $.command.run({ command: 'context-bar', args: '' } as any)).text).toBe('Context bar on')
|
|
505
|
+
expect(store.isHidden).toBe(false)
|
|
506
|
+
band = await mountBand($)
|
|
507
|
+
expect(await band.find({ type: 'Text', text: /of 1M/ })).toBeDefined()
|
|
508
|
+
await band.unmount()
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
test('a session that hid it starts hidden', async ($, on) => {
|
|
512
|
+
engine(on, { isHidden: true })
|
|
513
|
+
await start($)
|
|
514
|
+
const band = await mountBand($)
|
|
515
|
+
expect(await band.find({ type: 'Text', text: /of 1M/ })).toBeUndefined()
|
|
516
|
+
await band.unmount()
|
|
517
|
+
})
|
|
518
|
+
|
|
519
|
+
test('the detail rows fit the cells they are given', () => {
|
|
520
|
+
const r = toReading(BREAKDOWN)
|
|
521
|
+
for (const width of [20, 40, 60, 96, 200]) {
|
|
522
|
+
const m = metrics(width)
|
|
523
|
+
const row = m.mark + m.swatch + m.name + (m.bar > 0 ? 1 + m.bar : 0) + 1 + m.num + 1 + m.pct
|
|
524
|
+
expect(row).toBeLessThanOrEqual(width)
|
|
525
|
+
expect(m.total).toBe(row) // what the block is sized to is what the widest row comes to
|
|
526
|
+
}
|
|
527
|
+
// The block never sprawls across a wide box, and never overflows a narrow one.
|
|
528
|
+
expect(frameWidth(200)).toBe(84)
|
|
529
|
+
expect(frameWidth(96)).toBe(84)
|
|
530
|
+
expect(frameWidth(60)).toBe(60)
|
|
531
|
+
expect(frameWidth(10)).toBe(20) // the floor, as the bar's own
|
|
532
|
+
for (const room of [20, 40, 84, 120]) expect(metrics(frameWidth(room)).total).toBeLessThanOrEqual(frameWidth(room))
|
|
533
|
+
|
|
534
|
+
expect(barFill(186_000, 186_000, 20)).toBe(20) // the largest row fills its meter
|
|
535
|
+
expect(barFill(3_400, 186_000, 20)).toBe(1) // a small one still shows
|
|
536
|
+
expect(barFill(746_000, 0, 20)).toBe(0) // nothing to scale against
|
|
537
|
+
expect(blocks(r).filter(b => b.kind === 'row')).toHaveLength(r.slices.length)
|
|
538
|
+
// A list longer than the cap is cut, with a count of what is left out.
|
|
539
|
+
const many = { ...BREAKDOWN, memoryFiles: Array.from({ length: 20 }, (_, i) => ({ path: `/f/${i}.md`, type: 'User', tokens: 10 })) }
|
|
540
|
+
const cut = blocks(toReading(many), 'memory')
|
|
541
|
+
expect(cut.filter(b => b.kind === 'item' && b.item.note === 'User')).toHaveLength(12) // the memory files, capped
|
|
542
|
+
expect(cut.some(b => b.kind === 'note' && b.text === '+8 more')).toBe(true)
|
|
543
|
+
// A list with nothing in it is left out whole: its row keeps no mark to press.
|
|
544
|
+
const bare = blocks(toReading({ ...BREAKDOWN, memoryFiles: [], mcpTools: [], agents: [], skills: undefined, slashCommands: undefined }))
|
|
545
|
+
expect(bare.some(b => b.kind === 'row' && b.list !== undefined && b.list.id === 'mcp')).toBe(false)
|
|
546
|
+
// The figures are drawn whatever else is, and one with no count behind it is not.
|
|
547
|
+
expect(bare.filter(b => b.kind === 'figure').map((b: any) => b.text)).toEqual(['window'])
|
|
548
|
+
})
|
|
549
|
+
})
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
export type Slice = { name: string; tokens: number; color: string; kind: 'used' | 'free' | 'buffer' }
|
|
2
|
+
|
|
3
|
+
/** One line under a row: a memory file, an MCP server, an agent, a skill or a tool. */
|
|
4
|
+
export type Item = {
|
|
5
|
+
name: string
|
|
6
|
+
// Left out where there is no figure to draw: the engine counts the System tools row whole and
|
|
7
|
+
// hands over no per-tool count, so those rows carry a name and their own words and nothing else.
|
|
8
|
+
tokens?: number
|
|
9
|
+
note?: string
|
|
10
|
+
// The tokens are this mod's estimate off a text's length, not a count the engine made: drawn
|
|
11
|
+
// with a `~` so they are never read as one of the engine's figures.
|
|
12
|
+
estimated?: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** What sits under the rows: the lists `/context` carries beside them. */
|
|
16
|
+
export type Drilldown = {
|
|
17
|
+
memoryFiles: Item[] // name: the path, note: where it was loaded from
|
|
18
|
+
mcpServers: Item[] // name: the server, note: the tools it brings
|
|
19
|
+
agents: Item[]
|
|
20
|
+
skills: Item[]
|
|
21
|
+
skillsTokens?: number // every listed skill together
|
|
22
|
+
skillsIncluded?: number // how many skills the window carries, listed or not
|
|
23
|
+
slashCommands?: { tokens: number; included: number; total: number }
|
|
24
|
+
model: string
|
|
25
|
+
autoCompactSource: string // how the window measured against was settled
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type Reading = {
|
|
29
|
+
slices: Slice[]
|
|
30
|
+
total: number // tokens in use
|
|
31
|
+
window: number // the window measured against
|
|
32
|
+
percent: number
|
|
33
|
+
compactsAt?: number // where auto-compaction runs, when it is on
|
|
34
|
+
drilldown: Drilldown
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* What a row holds in detail: the memory files, MCP servers, agents, skills or commands that
|
|
39
|
+
* come to the tokens the row is counted for. It opens under the row that names it.
|
|
40
|
+
*/
|
|
41
|
+
export type List = {
|
|
42
|
+
id: string // what a press on the row is remembered by
|
|
43
|
+
head: string // the row's own name, where the list has no row to open under
|
|
44
|
+
note?: string // what it holds and what it costs, read while it is shut
|
|
45
|
+
items: Item[]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** One block of the opened box's body, in the order drawn. The bar is drawn above them. */
|
|
49
|
+
export type Block =
|
|
50
|
+
| { kind: 'gap' }
|
|
51
|
+
| { kind: 'head'; text: string; note?: string }
|
|
52
|
+
// A row, its meter scaled to the largest of them, and the list that opens under it when it has one.
|
|
53
|
+
| { kind: 'row'; slice: Slice; max: number; list?: List; open: boolean }
|
|
54
|
+
| { kind: 'entry'; list: List; open: boolean } // a list with no row of its own to open under
|
|
55
|
+
| { kind: 'figure'; text: string; note: string } // a count that is all there is: nothing opens under it
|
|
56
|
+
| { kind: 'item'; item: Item }
|
|
57
|
+
| { kind: 'note'; text: string }
|
|
58
|
+
|
|
59
|
+
declare module 'claude-code' {
|
|
60
|
+
interface PluginState {
|
|
61
|
+
// `openRow` names the one list that is open: opening another shuts the first.
|
|
62
|
+
// `promptSections` is the system prompt's own sections and `toolList` the built-in tools the
|
|
63
|
+
// model can call: the two rows the breakdown carries no list of their own for.
|
|
64
|
+
'context-bar': {
|
|
65
|
+
reading: Reading | null
|
|
66
|
+
isHidden: boolean
|
|
67
|
+
isOpen: boolean
|
|
68
|
+
openRow: string | null
|
|
69
|
+
promptSections: Item[]
|
|
70
|
+
toolList: Item[]
|
|
71
|
+
messageList: Item[]
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|