seestack 0.0.0-stage → 1.0.1

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.
@@ -0,0 +1,598 @@
1
+ // Context Bar: what is filling my context window?
2
+ // Above the prompt, the window as one stacked bar, a color per category as /context
3
+ // draws them (system prompt, tools, MCP tools, memory files, skills, messages, free),
4
+ // with a legend of tokens and shares and where auto-compaction runs. It refreshes after
5
+ // each turn. The ▾ at the header's end opens the box up: the legend gives way to every row
6
+ // with its tokens and share. The rows counted for something with a list behind them — the
7
+ // system prompt, system tools, memory files, MCP tools, agents, skills, messages — lead with a ▶ that unfolds that list under the row
8
+ // itself, the mark turning to a ▼. Only one is open at a time, so the box is the rows plus a
9
+ // single list however long that list is. Nothing is repeated below the rows: a row is where
10
+ // its detail is. What /context counts with nothing under it — the command listing, and the
11
+ // window it measured against — is drawn as a figure, there to be read and with no mark to
12
+ // press. One box throughout, growing away from the prompt — no second frame, no repeated
13
+ // header. The ▴ it becomes, or the Minimize button, folds it back. /context-bar shows or
14
+ // hides the bar, and the choice is kept across sessions.
15
+ import { atom, read, update } from 'claude-code'
16
+ import type { EngineInterface, Register } from 'claude-code'
17
+
18
+ import type { Block, Drilldown, Item, List, Reading, Slice } from '../types'
19
+
20
+ const MIN_WIDTH = 20 // narrower than this, the bar is not drawn
21
+ const SPLIT = ' '
22
+ const MAX_ITEMS = 12 // a list longer than this is cut, with a count of what it left out
23
+ const INDENT = 4 // what a list's own rows are indented by, under the row they open from
24
+ const CONTENT_WIDTH = 84 // the widest the detail is laid out at, so it sits centered in a wider box
25
+ // /context's theme gives several rows the same grey, so each used row gets its own color, in order.
26
+ const PALETTE = ['#7aa2f7', '#7dcfff', '#bb9af7', '#9ece6a', '#e0af68', '#f7768e', '#73daca', '#ff9e64', '#c0caf5']
27
+ const MESSAGES = '#d97757' // the row that grows, in the accent color
28
+ const FREE = '#808080' // a mid grey thin line reads as empty on dark and light themes alike
29
+ const BUFFER = '#808080'
30
+ const SWATCH = { used: '■', free: '─', buffer: '░' } as const // what a legend and a detail row lead with
31
+ const GLYPH = { used: '█', free: '─', buffer: '░' } as const // what the bar and a share meter fill with
32
+ const REST = '·' // what a share meter fills the room it has left with
33
+
34
+ // Held by the host, so the bar survives a hot reload of this file.
35
+ const reading = atom({ plugin: 'context-bar', key: 'reading' } as const, null as Reading | null)
36
+ const isHidden = atom({ plugin: 'context-bar', key: 'isHidden' } as const, false)
37
+ const isOpen = atom({ plugin: 'context-bar', key: 'isOpen' } as const, false)
38
+ // The one list that is open under its row, if any. One at a time, so the box is the rows plus a
39
+ // single list however long the band is; nothing open, it is the rows alone.
40
+ const openRow = atom({ plugin: 'context-bar', key: 'openRow' } as const, null as string | null)
41
+ // The system prompt's own sections. The breakdown carries no list for the System prompt row, so
42
+ // these are read off the composition instead — the engine's ids and text, this mod's token guess.
43
+ const promptSections = atom({ plugin: 'context-bar', key: 'promptSections' } as const, [] as Item[])
44
+ // The built-in tools the model can call. The engine counts the System tools row whole and hands
45
+ // over no count per tool, so these rows are a roll-call with the tool's own words and no figure.
46
+ const toolList = atom({ plugin: 'context-bar', key: 'toolList' } as const, [] as Item[])
47
+ // The conversation, biggest message first. A transcript runs to hundreds of messages where the
48
+ // box shows twelve, so what it shows are the twelve that cost the most.
49
+ const messageList = atom({ plugin: 'context-bar', key: 'messageList' } as const, [] as Item[])
50
+
51
+ export const register: Register = on => {
52
+ on('session.start', async ($, e, next) => {
53
+ const r = await next(e)
54
+ await $.command.register({ name: 'context-bar', description: 'Show or hide the context window bar above the prompt' }).catch(() => {}) // a name Claude Code already has is refused: start anyway
55
+ const hidden = (await $.store.get('isHidden').catch(() => undefined)) === true
56
+ await update($, isHidden, () => hidden)
57
+ void refresh($).catch(() => {})
58
+ return r
59
+ })
60
+
61
+ on('turn.complete', async ($, e, next) => {
62
+ const r = await next(e)
63
+ if (!e.agentId) await refresh($).catch(() => {}) // a subagent's turn fills its own window, not this one
64
+ return r
65
+ })
66
+
67
+ on('session.compact', async ($, e, next) => {
68
+ const r = await next(e)
69
+ if (!e.agentId && 'messages' in r) void refresh($).catch(() => {}) // a /compact empties the window without a turn ending
70
+ return r
71
+ })
72
+
73
+ // The engine fires this for every prompt it sends, so the sections come free here; asking
74
+ // `$.prompt.compose` instead would compose the whole prompt again on every redraw. The
75
+ // result goes back untouched: this reads the composition, it does not change it.
76
+ on('prompt.compose', async ($, e, next) => {
77
+ const r = await next(e)
78
+ void update($, promptSections, () => promptItems(r.sections)).catch(() => {})
79
+ return r
80
+ })
81
+
82
+ on('command.run', { command: 'context-bar' }, async $ => {
83
+ const hidden = await update($, isHidden, h => !h)
84
+ await $.store.set('isHidden', hidden).catch(() => {})
85
+ if (hidden) await update($, isOpen, () => false) // hiding takes the opened box down with it
86
+ else await refresh($).catch(() => {})
87
+ return { text: hidden ? 'Context bar hidden. /context-bar shows it again' : 'Context bar on' }
88
+ })
89
+
90
+ on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
91
+ const rest = await next(e) // what other mods and Claude Code draw here stays
92
+ const r = await read($, reading)
93
+ if (e.props.hasSurvey || (await read($, isHidden)) || !r) return rest
94
+ const { Box, Text, Button } = $.ui.resolve(e)
95
+ const inner = e.props.bodyColumns - 4 // the border and padding take 4 cells
96
+ if (inner < MIN_WIDTH) return rest
97
+
98
+ const opened = await read($, isOpen)
99
+ const show = await read($, openRow)
100
+ const prompt = await read($, promptSections)
101
+ const tools = await read($, toolList)
102
+ const messages = await read($, messageList)
103
+ // A list opens under its own row, and one at a time: opening another shuts the first.
104
+ const toggle = (id: string) => update($, openRow, (now: string | null) => (now === id ? null : id))
105
+ const head = `${tokens(r.total)} of ${tokens(r.window)}${r.compactsAt ? ` · compacts at ${tokens(r.compactsAt)}` : ''}`
106
+ const badge = ` ${r.percent}% `
107
+ // The detail is laid out in a block narrower than a wide box, and centered in it.
108
+ const m = metrics(frameWidth(inner))
109
+ return (
110
+ <Box flexDirection="column">
111
+ <Box flexDirection="column" borderStyle="round" borderColor="inactive" paddingX={1}>
112
+ <Box flexDirection="row" justifyContent="space-between">
113
+ <Text wrap="truncate-end">
114
+ <Text color="#d97757">{'◆ '}</Text>
115
+ <Text bold>context</Text>
116
+ </Text>
117
+ <Box flexDirection="row">
118
+ <Text wrap="truncate-start">
119
+ <Text dimColor>{`${head} `}</Text>
120
+ <Text bold color="black" backgroundColor={levelColor(r)}>{badge}</Text>
121
+ <Text> </Text>
122
+ </Text>
123
+ <Button key="toggle" plain label={opened ? '▴' : '▾'} onPress={() => update($, isOpen, v => !v)} />
124
+ </Box>
125
+ </Box>
126
+ <Text>
127
+ {cells(r, inner).map(c => (
128
+ <Text color={c.color}>{c.text}</Text>
129
+ ))}
130
+ </Text>
131
+ {!opened &&
132
+ legend(r, inner).map(line => (
133
+ <Text wrap="truncate-end">
134
+ {line.map((s, i) => (
135
+ <Text>
136
+ {i > 0 && <Text>{SPLIT}</Text>}
137
+ <Text color={s.color}>{`${SWATCH[s.kind]} `}</Text>
138
+ <Text dimColor={s.kind !== 'used'}>{`${s.name} `}</Text>
139
+ <Text bold={s.kind === 'used'}>{tokens(s.tokens)}</Text>
140
+ {s.kind === 'used' && <Text dimColor>{` ${share(s.tokens, r.window)}`}</Text>}
141
+ </Text>
142
+ ))}
143
+ </Text>
144
+ ))}
145
+ {opened && (
146
+ <Box flexDirection="column" alignItems="center">
147
+ <Box flexDirection="column" width={m.total}>
148
+ {blocks(r, show, prompt, tools, messages).map(b => {
149
+ if (b.kind === 'gap') return <Text> </Text>
150
+ if (b.kind === 'entry') {
151
+ const list = b.list
152
+ return (
153
+ <Box flexDirection="row">
154
+ <Button key={list.id} plain label={b.open ? '▼' : '▶'} onPress={() => toggle(list.id)} />
155
+ <Text wrap="truncate-end">
156
+ <Text>{' '}</Text>
157
+ <Text bold>{list.head}</Text>
158
+ {list.note !== undefined && <Text dimColor>{` ${list.note}`}</Text>}
159
+ </Text>
160
+ </Box>
161
+ )
162
+ }
163
+ if (b.kind === 'figure')
164
+ return (
165
+ <Text wrap="truncate-end">
166
+ <Text>{' '}</Text>
167
+ <Text bold>{b.text}</Text>
168
+ <Text dimColor>{` ${b.note}`}</Text>
169
+ </Text>
170
+ )
171
+ if (b.kind === 'head')
172
+ return (
173
+ <Text wrap="truncate-end">
174
+ <Text bold>{b.text}</Text>
175
+ {b.note !== undefined && <Text dimColor>{` ${b.note}`}</Text>}
176
+ </Text>
177
+ )
178
+ if (b.kind === 'note')
179
+ return (
180
+ <Text dimColor wrap="truncate-end">
181
+ {`${' '.repeat(2 + m.num)}${b.text}`}
182
+ </Text>
183
+ )
184
+ if (b.kind === 'item')
185
+ return (
186
+ <Text wrap="truncate-end">
187
+ <Text>{' '.repeat(INDENT)}</Text>
188
+ {/* A `~` wherever the figure is this mod's estimate. A tool has no figure at all:
189
+ the engine counts its row whole, so the cells are left bare rather than guessed. */}
190
+ <Text bold>
191
+ {b.item.tokens === undefined ? ' '.repeat(m.num) : lpad(`${b.item.estimated ? '~' : ''}${tokens(b.item.tokens)}`, m.num)}
192
+ </Text>
193
+ <Text>{' '}</Text>
194
+ <Text>{cut(b.item.name, itemRoom(m.total, m.num, b.item))}</Text>
195
+ {b.item.note !== undefined && <Text dimColor>{` ${b.item.note}`}</Text>}
196
+ </Text>
197
+ )
198
+ const filled = barFill(b.slice.tokens, b.max, m.bar)
199
+ const list = b.list
200
+ return (
201
+ <Box flexDirection="row">
202
+ {/* The row that has detail under it leads with a mark; a bare row keeps the cells. */}
203
+ {list ? (
204
+ <Button key={list.id} plain label={b.open ? '▼' : '▶'} onPress={() => toggle(list.id)} />
205
+ ) : (
206
+ <Text>{' '}</Text>
207
+ )}
208
+ <Text>{' '}</Text>
209
+ <Text wrap="truncate-end">
210
+ <Text color={b.slice.color}>{`${SWATCH[b.slice.kind]} `}</Text>
211
+ <Text dimColor={b.slice.kind !== 'used'}>{pad(b.slice.name, m.name)}</Text>
212
+ {m.bar > 0 && <Text>{' '}</Text>}
213
+ {m.bar > 0 && (
214
+ <Text>
215
+ <Text color={b.slice.color}>{GLYPH[b.slice.kind].repeat(filled)}</Text>
216
+ {m.bar - filled > 0 && <Text dimColor>{REST.repeat(m.bar - filled)}</Text>}
217
+ </Text>
218
+ )}
219
+ <Text>{' '}</Text>
220
+ <Text bold={b.slice.kind === 'used'}>{lpad(tokens(b.slice.tokens), m.num)}</Text>
221
+ <Text>{' '}</Text>
222
+ <Text dimColor>{lpad(share(b.slice.tokens, r.window), m.pct)}</Text>
223
+ </Text>
224
+ </Box>
225
+ )
226
+ })}
227
+ <Text> </Text>
228
+ <Button key="minimize" label="Minimize" hotkey="q" onPress={() => update($, isOpen, () => false)} />
229
+ </Box>
230
+ </Box>
231
+ )}
232
+ </Box>
233
+ {rest}
234
+ </Box>
235
+ )
236
+ })
237
+ }
238
+
239
+ // Asks the engine for /context's breakdown, estimated locally (no token-count calls).
240
+ async function refresh($: EngineInterface) {
241
+ if (await read($, isHidden)) return
242
+ const usage = await $.session.usage({ breakdown: 'summary' })
243
+ const b = usage.context.breakdown
244
+ if (!b || !(b.rawMaxTokens > 0)) return // no window to measure against
245
+ await update($, reading, () => toReading(b))
246
+ // The tools can change mid-session — an MCP server connects, a plugin registers one — so this
247
+ // is read on the same beat as the breakdown rather than once at the start.
248
+ const tools = await $.tool.list().catch(() => [])
249
+ await update($, toolList, () => toolItems(tools))
250
+ const said = await $.session.messages().catch(() => [])
251
+ await update($, messageList, () => messageItems(said))
252
+ }
253
+
254
+ export function toReading(b: {
255
+ categories: { name: string; tokens: number; color: string; kind: string }[]
256
+ totalTokens: number
257
+ rawMaxTokens: number
258
+ percentage: number
259
+ autoCompactThreshold?: number
260
+ isAutoCompactEnabled: boolean
261
+ autocompactSource?: string
262
+ model?: string
263
+ memoryFiles?: { path: string; type: string; tokens: number }[]
264
+ mcpTools?: { name: string; serverName: string; tokens: number; isLoaded: boolean }[]
265
+ agents?: { agentType: string; source: string; tokens: number }[]
266
+ skills?: {
267
+ totalSkills: number
268
+ includedSkills: number
269
+ tokens: number
270
+ skillFrontmatter: { name: string; source: string; pluginName?: string; tokens: number }[]
271
+ }
272
+ slashCommands?: { totalCommands: number; includedCommands: number; tokens: number }
273
+ }): Reading {
274
+ const slices: Slice[] = b.categories
275
+ .filter(c => c.kind !== 'deferred' && c.tokens > 0)
276
+ .map(c => ({ name: c.name.toLowerCase(), tokens: c.tokens, color: c.color, kind: c.kind as Slice['kind'] }))
277
+ const order = { used: 0, free: 1, buffer: 2 }
278
+ slices.sort((x, y) => order[x.kind] - order[y.kind]) // stable: used rows keep /context's order
279
+ let next = 0
280
+ for (const s of slices) {
281
+ s.color = s.kind === 'free' ? FREE : s.kind === 'buffer' ? BUFFER : s.name === 'messages' ? MESSAGES : PALETTE[next++ % PALETTE.length]!
282
+ }
283
+ return {
284
+ slices,
285
+ total: b.totalTokens,
286
+ window: b.rawMaxTokens,
287
+ percent: b.percentage,
288
+ compactsAt: b.isAutoCompactEnabled ? b.autoCompactThreshold : undefined,
289
+ drilldown: toDrilldown(b),
290
+ }
291
+ }
292
+
293
+ function toDrilldown(b: Parameters<typeof toReading>[0]): Drilldown {
294
+ const servers = new Map<string, { tokens: number; tools: string[]; onDemand: number }>()
295
+ for (const t of b.mcpTools ?? []) {
296
+ const server = servers.get(t.serverName) ?? { tokens: 0, tools: [], onDemand: 0 }
297
+ server.tokens += t.tokens
298
+ // The wire name leads with its server; the box is already under that server's line.
299
+ if (t.isLoaded) server.tools.push(t.name.replace(/^mcp__.+?__/, ''))
300
+ else server.onDemand++
301
+ servers.set(t.serverName, server)
302
+ }
303
+ return {
304
+ memoryFiles: (b.memoryFiles ?? []).map(m => ({ name: shortPath(m.path), tokens: m.tokens, note: m.type })),
305
+ mcpServers: [...servers.entries()]
306
+ .map(([name, s]) => ({ name, tokens: s.tokens, note: mcpNote(s.tools, s.onDemand) }))
307
+ .sort((x, y) => y.tokens - x.tokens),
308
+ agents: (b.agents ?? []).map(a => ({ name: a.agentType, tokens: a.tokens, note: a.source })),
309
+ skills: (b.skills?.skillFrontmatter ?? []).map(s => ({
310
+ name: s.name,
311
+ tokens: s.tokens,
312
+ note: s.pluginName ? `${s.source} · ${s.pluginName}` : s.source,
313
+ })),
314
+ skillsTokens: b.skills?.tokens,
315
+ skillsIncluded: b.skills?.includedSkills,
316
+ slashCommands: b.slashCommands
317
+ ? { tokens: b.slashCommands.tokens, included: b.slashCommands.includedCommands, total: b.slashCommands.totalCommands }
318
+ : undefined,
319
+ model: b.model ?? 'unknown',
320
+ autoCompactSource: b.autocompactSource ?? 'unknown',
321
+ }
322
+ }
323
+
324
+ // Which row a list opens under, by the row's own name as /context prints it. A name the engine
325
+ // changes leaves its list without a row, where it keeps an entry of its own rather than going.
326
+ const UNDER: Record<string, string> = {
327
+ 'system prompt': 'prompt',
328
+ 'system tools': 'tools',
329
+ 'memory files': 'memory',
330
+ 'mcp tools': 'mcp',
331
+ agents: 'agents',
332
+ 'custom agents': 'agents',
333
+ skills: 'skills',
334
+ 'slash commands': 'slash',
335
+ messages: 'messages',
336
+ }
337
+
338
+ // What each row holds in detail, and what it comes to. A list with nothing in it is left out.
339
+ export function lists(r: Reading, prompt: Item[] = [], tools: Item[] = [], messages: Item[] = []): List[] {
340
+ const d = r.drilldown
341
+ const out: List[] = []
342
+ const add = (id: string, head: string, note: string | undefined, items: Item[]) => {
343
+ if (items.length > 0) out.push({ id, head, note, items })
344
+ }
345
+ // The System prompt and System tools rows are the two the breakdown carries no list for: each
346
+ // note is a count of what is there, so neither sets this mod's number against the engine's.
347
+ add('prompt', 'system prompt', `${prompt.length} sections`, prompt)
348
+ add('tools', 'system tools', `${tools.length} built-in`, tools)
349
+ add('memory', 'memory files', tokens(held(d.memoryFiles)), d.memoryFiles)
350
+ add('mcp', 'mcp tools', `by server · ${tokens(held(d.mcpServers))}`, d.mcpServers)
351
+ add('agents', 'agents', tokens(held(d.agents)), d.agents)
352
+ // What the window carries of each, and the tokens those come to: /context counts the whole set.
353
+ add('skills', 'skills', d.skillsTokens !== undefined ? `${d.skills.length} of ${d.skillsIncluded ?? d.skills.length} · ${tokens(d.skillsTokens)}` : tokens(held(d.skills)), d.skills)
354
+ add('messages', 'messages', `${messages.length} messages, biggest first`, messages)
355
+ return out
356
+ }
357
+
358
+ // The figures that are no list at all: /context counts the command listing and hands over the
359
+ // count with nothing under it, and the window's own line is how it was measured. Both are read
360
+ // at a glance and have nothing to open, so neither carries a mark.
361
+ export function figures(r: Reading): { text: string; note: string }[] {
362
+ const d = r.drilldown
363
+ const out: { text: string; note: string }[] = []
364
+ if (d.slashCommands && d.slashCommands.tokens > 0)
365
+ out.push({ text: 'slash commands', note: `${d.slashCommands.included} of ${d.slashCommands.total} · ${tokens(d.slashCommands.tokens)}` })
366
+ out.push({ text: 'window', note: r.compactsAt ? `${d.model} · compacts at ${tokens(r.compactsAt)}` : `${d.model} · auto-compaction off` })
367
+ return out
368
+ }
369
+
370
+ const held = (items: Item[]) => items.reduce((n, i) => n + (i.tokens ?? 0), 0)
371
+
372
+ /**
373
+ * The system prompt's sections as the System prompt row's items: the engine's own id for each,
374
+ * the cache boundary it sits on, and the tokens off its text. The text is all `prompt.compose`
375
+ * hands over — no count of its own — so these tokens are this mod's estimate and are marked as
376
+ * one. They compare a section against its neighbours; they are not the engine's figures.
377
+ */
378
+ export function promptItems(sections: readonly { id: string; text: string; scope: string }[]): Item[] {
379
+ return sections.map(s => ({ name: s.id, tokens: Math.round(s.text.length / 4), note: s.scope, estimated: true }))
380
+ }
381
+
382
+ /**
383
+ * The built-in tools as the System tools row's items: the name, and the tool's own words for
384
+ * what it does. MCP tools are left out — the breakdown counts them under their own row, and
385
+ * listing them here would put the same schemas in the box twice.
386
+ *
387
+ * No figure is drawn: the engine counts the System tools row whole, and what a tool costs is its
388
+ * input schema, which nothing hands over. A number off the description would be a guess off the
389
+ * wrong quantity, so this row carries none.
390
+ */
391
+ export function toolItems(tools: readonly { name: string; description: string; mcp: boolean }[]): Item[] {
392
+ return tools.filter(t => !t.mcp).map(t => ({ name: t.name, note: oneLine(t.description) }))
393
+ }
394
+
395
+ /** One message of the transcript, as `$.session.messages()` returns it. */
396
+ type Said = {
397
+ role: 'user' | 'assistant'
398
+ text: string
399
+ toolUses: readonly { tool_use_id: string; tool: string; input: Record<string, unknown>; text?: string }[]
400
+ toolResults?: readonly { tool_use_id: string; text: string }[]
401
+ }
402
+
403
+ /**
404
+ * The conversation as the Messages row's items, biggest first. That row is the one the bar is
405
+ * read for, and a transcript holds hundreds of messages where the box shows twelve: the twelve
406
+ * it shows are the twelve that cost the most, which is the question the row raises.
407
+ *
408
+ * The tokens are estimated off the length of what each message holds — its own text, the
409
+ * arguments its tool calls were given, and what those calls came back with — and marked as
410
+ * estimates. The row above is the engine's own count, reconciled to the API; these are
411
+ * proportions of one another and will not add up to it.
412
+ */
413
+ export function messageItems(messages: readonly Said[]): Item[] {
414
+ // A tool result can be read off either side of the transcript — the assistant's call carries it
415
+ // and the user's message does too — so every result is met once here and counted where the call is.
416
+ const results = new Map<string, number>()
417
+ for (const m of messages) for (const u of m.toolUses) if (u.text !== undefined) results.set(u.tool_use_id, u.text.length)
418
+ for (const m of messages) for (const r of m.toolResults ?? []) if (!results.has(r.tool_use_id)) results.set(r.tool_use_id, r.text.length)
419
+
420
+ const out: Item[] = []
421
+ for (const m of messages) {
422
+ let chars = m.text.length
423
+ for (const u of m.toolUses) chars += JSON.stringify(u.input ?? {}).length + (results.get(u.tool_use_id) ?? 0)
424
+ const calls = m.toolUses.length
425
+ out.push({
426
+ name: m.role === 'user' ? 'you' : calls > 0 ? `assistant · ${calls} ${calls === 1 ? 'call' : 'calls'}` : 'assistant',
427
+ tokens: Math.round(chars / 4),
428
+ note: oneLine(m.text),
429
+ estimated: true,
430
+ })
431
+ }
432
+ return out.sort((a, b) => (b.tokens ?? 0) - (a.tokens ?? 0))
433
+ }
434
+
435
+ // A description cut to what one row can carry: its first sentence, and no more than a line of it.
436
+ function oneLine(text: string, room = 44) {
437
+ const said = text.replace(/\s+/g, ' ').trim()
438
+ const end = said.search(/\.(?:\s|$)/)
439
+ const head = end > 0 ? said.slice(0, end) : said
440
+ return head.length <= room ? head : `${head.slice(0, room - 1).trimEnd()}…`
441
+ }
442
+
443
+ // The opened box's body as blocks, in the order drawn: every row of the breakdown, each with the
444
+ // list it names opening under it, then any list the rows do not account for. `open` is the one
445
+ // list unfolded; one at a time keeps the box the rows plus a single list, whatever the band has.
446
+ export function blocks(r: Reading, open: string | null = null, prompt: Item[] = [], tools: Item[] = [], messages: Item[] = []): Block[] {
447
+ const used = r.slices.filter(s => s.kind === 'used')
448
+ const max = Math.max(...used.map(s => s.tokens), 1) // the meter is scaled to the largest row, so rows compare
449
+ const all = lists(r, prompt, tools, messages)
450
+ const rows = new Set<string>()
451
+ const out: Block[] = [{ kind: 'head', text: 'detail' }]
452
+ for (const slice of r.slices) {
453
+ const id = UNDER[slice.name] // the list this row is counted for, where it is one that has detail
454
+ const list = id !== undefined ? all.find(l => l.id === id) : undefined
455
+ const isOpen = list !== undefined && list.id === open
456
+ if (list) rows.add(list.id)
457
+ out.push({ kind: 'row', slice, max, list, open: isOpen })
458
+ if (isOpen) out.push(...items(list))
459
+ }
460
+ // A list no row accounts for — the agents, where the breakdown counts no row for them — keeps
461
+ // an entry of its own, opened the same way.
462
+ for (const list of all) {
463
+ if (rows.has(list.id)) continue
464
+ const isOpen = list.id === open
465
+ out.push({ kind: 'gap' }, { kind: 'entry', list, open: isOpen })
466
+ if (isOpen) out.push(...items(list))
467
+ }
468
+ const figs = figures(r)
469
+ if (figs.length > 0) {
470
+ out.push({ kind: 'gap' })
471
+ for (const f of figs) out.push({ kind: 'figure', text: f.text, note: f.note })
472
+ }
473
+ return out
474
+ }
475
+
476
+ // A list's rows, shown with at most `MAX_ITEMS` of its items and a count of the rest.
477
+ function items(list: List): Block[] {
478
+ const out: Block[] = list.items.slice(0, MAX_ITEMS).map(item => ({ kind: 'item', item }) as Block)
479
+ const left = list.items.length - MAX_ITEMS
480
+ if (left > 0) out.push({ kind: 'note', text: `+${left} more` })
481
+ return out
482
+ }
483
+
484
+ // The cells a detail row is laid out in: `total` is what the widest row comes to, and the
485
+ // block the box centers its opened content as.
486
+ export function metrics(width: number) {
487
+ const mark = 2 // the ▶ a row with detail under it leads with; a bare row keeps the cells in its place
488
+ const num = 7 // tokens, right-aligned (`186.0k`)
489
+ const pct = 6 // the share of the window, right-aligned (`19.0%`)
490
+ const swatch = 2
491
+ const fixed = mark + swatch + 2 + num + 1 + pct
492
+ let name = Math.min(28, Math.max(8, Math.round(width * 0.28)))
493
+ // Too narrow for the name it wanted: it gives way to the figures, which are what the row is for.
494
+ if (fixed + name > width) name = Math.max(1, width - fixed)
495
+ const bar = Math.max(0, Math.min(40, width - (fixed + name)))
496
+ // The two cells the meter is spaced by are only spent when there is a meter to space.
497
+ const total = mark + swatch + name + (bar > 0 ? 1 + bar : 0) + 1 + num + 1 + pct
498
+ return { mark, swatch, name, num, pct, bar, total }
499
+ }
500
+
501
+ // The block the opened detail is laid out in: the room it has, never wider than it reads
502
+ // well at, so what the box centers is a column of even margins rather than the whole screen.
503
+ export function frameWidth(room: number) {
504
+ return Math.max(MIN_WIDTH, Math.min(room, CONTENT_WIDTH))
505
+ }
506
+
507
+ // How much of a meter `tokens` fills against the largest row, at least one cell in use.
508
+ export function barFill(tokens: number, max: number, width: number) {
509
+ if (width <= 0 || max <= 0 || tokens <= 0) return 0
510
+ return Math.max(1, Math.min(width, Math.round((tokens / max) * width)))
511
+ }
512
+
513
+ // The cells an item's name may take, once its indent, its tokens, its note and the gaps are placed.
514
+ function itemRoom(width: number, num: number, item: Item) {
515
+ const fixed = INDENT + num + 2
516
+ const note = item.note ? item.note.length + 2 : 0
517
+ return Math.max(6, width - fixed - note)
518
+ }
519
+
520
+ // The bar as runs of cells: each slice gets its share of `width`, a used one at least one cell.
521
+ export function cells(r: Reading, width: number) {
522
+ const sizes = r.slices.map(s => Math.max(s.kind === 'used' ? 1 : 0, Math.round((s.tokens / r.window) * width)))
523
+ // Rounding leaves the sum a few cells off: free space takes the difference first, then the largest slices.
524
+ let diff = width - sizes.reduce((a, n) => a + n, 0)
525
+ const isUsed = (i: number) => r.slices[i]!.kind === 'used'
526
+ const order = r.slices.map((_, i) => i).sort((a, b) => Number(isUsed(a)) - Number(isUsed(b)) || sizes[b]! - sizes[a]!)
527
+ for (const i of order) {
528
+ if (diff === 0) break
529
+ const size = Math.max(isUsed(i) ? 1 : 0, sizes[i]! + diff)
530
+ diff -= size - sizes[i]!
531
+ sizes[i] = size
532
+ }
533
+ return r.slices.map((s, i) => ({ color: s.color, kind: s.kind, text: GLYPH[s.kind].repeat(sizes[i]!) })).filter(c => c.text !== '')
534
+ }
535
+
536
+ // The legend, packed into lines no wider than `width`. Shown while the box is folded.
537
+ export function legend(r: Reading, width: number) {
538
+ const lines: Slice[][] = [[]]
539
+ let used = 0
540
+ for (const s of r.slices) {
541
+ const size = 2 + s.name.length + 1 + tokens(s.tokens).length + (s.kind === 'used' ? 1 + share(s.tokens, r.window).length : 0)
542
+ const line = lines.at(-1)!
543
+ if (line.length > 0 && used + SPLIT.length + size > width) {
544
+ lines.push([s])
545
+ used = size
546
+ } else {
547
+ used += (line.length > 0 ? SPLIT.length : 0) + size
548
+ line.push(s)
549
+ }
550
+ }
551
+ return lines.filter(l => l.length > 0)
552
+ }
553
+
554
+ // How full the window is, for the badge: red where a compaction is close.
555
+ export function levelColor(r: Reading) {
556
+ const level = r.compactsAt ? r.total / r.compactsAt : r.total / r.window
557
+ return level >= 0.9 ? 'red' : level >= 0.7 ? 'yellow' : 'green'
558
+ }
559
+
560
+ export function tokens(n: number) {
561
+ if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`
562
+ if (n >= 10_000) return `${Math.round(n / 1000)}k`
563
+ if (n >= 1000) return `${+(n / 1000).toFixed(1)}k`
564
+ return String(n)
565
+ }
566
+
567
+ export function share(n: number, window: number) {
568
+ const p = (n / window) * 100
569
+ if (p > 0 && p < 0.1) return '<0.1%'
570
+ return `${p >= 10 ? Math.round(p) : +p.toFixed(1)}%`
571
+ }
572
+
573
+ // A path as it is read at a glance: `~` for the home it sits in.
574
+ function shortPath(path: string) {
575
+ return path.replace(/^\/(?:Users|home)\/[^/]+/, '~')
576
+ }
577
+
578
+ // An MCP server's line: how many tools it brings, named where a few say enough.
579
+ function mcpNote(tools: string[], onDemand: number) {
580
+ const parts: string[] = []
581
+ if (tools.length > 0) parts.push(tools.length <= 3 ? tools.join(', ') : `${tools.slice(0, 3).join(', ')}, +${tools.length - 3}`)
582
+ if (onDemand > 0) parts.push(`${onDemand} on demand`)
583
+ return parts.join(' · ') || 'no schemas loaded'
584
+ }
585
+
586
+ function cut(s: string, n: number) {
587
+ return s.length <= n ? s : `${s.slice(0, Math.max(1, n - 1))}…`
588
+ }
589
+
590
+ function pad(s: string, n: number) {
591
+ const text = cut(s, n)
592
+ return text + ' '.repeat(Math.max(0, n - text.length))
593
+ }
594
+
595
+ function lpad(s: string, n: number) {
596
+ const text = cut(s, n)
597
+ return ' '.repeat(Math.max(0, n - text.length)) + text
598
+ }