Stacked progress band above the prompt: tasks, subagents, rate limits and monthly cost

Claude Code mods by Shahriar.
Add the marketplace once:
/plugin marketplace add ShahriarBijoy/claude-mods
Then install the mods you want, and reload:
/plugin install stack@shahriar-mods
/plugin install wartezeit@shahriar-mods
/reload-plugins
To update installed mods later, run /plugin marketplace update shahriar-mods, then /reload-plugins.
Mods are an early-access Claude Code feature, so you need a recent Claude Code build (developed on 2.1.286).
A progress band above the prompt with one row per tracked thing:
It starts collapsed to one summary line. Focus the band with ctrl+x tab and press e to expand or collapse it, or use the commands:
/stack: toggle/stack expand and /stack collapse/stack hide <row> and /stack show <row|all>Rows are tasks, subagents, session, weekly, limits and costs.
Note: to backfill costs from before you installed it, stack runs npx --yes ccusage claude monthly --json on your machine at session start, at most every 12 hours. That downloads and runs the ccusage package. If the run fails, /stack reports why, and only months from the install onward are tracked.
A Duolingo-style German vocabulary game in the band above the prompt. It only shows while Claude is working; answer with 1–4 from the empty prompt.
claude -p run on Sonnet 5.5 writes a deck of about 22 words for it, using your Claude login. Until then, and when a run fails, you get a bundled deck of everyday words. A topic seen before reuses its saved deck without a new run.Commands:
/wartezeit: the current deck and mode/wartezeit mode <mixed|vocab|meaning|article|sentence>/wartezeit stats: streak, accuracy per card type, your 10 weakest words, and words learned per topicNote: each new topic costs one Sonnet 5.5 run against your Claude subscription usage (about $0.04, 25 seconds). The run is started with WARTEZEIT_CHILD=1, which makes the mod inside it do nothing.
hooks/register.tsx 394 lines1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register, ToolCallResult } from 'claude-code'
3
4import type { StackAgent, StackLimit, StackTask, StackTaskStatus } from '../types'
5import {
6 CELL_PX,
7 MARK,
8 ORANGE,
9 PILL_BG,
10 PILL_FG,
11 ROW_NAMES,
12 ROW_PX,
13 TONES,
14 buildRows,
15 desktopRowSvg,
16 fit,
17 isHidden,
18 monthKey,
19 parseCcusage,
20 rowGrid,
21 summaryParts,
22 terminalTrack,
23 toolDetail,
24} from './model'
25import type { RowGrid, RowSpec, Segment, StackData } from './model'
26
27const tasks = atom({ plugin: 'stack', key: 'tasks' } as const, [])
28const agents = atom({ plugin: 'stack', key: 'agents' } as const, {})
29const limits = atom({ plugin: 'stack', key: 'limits' } as const, [])
30const tracked = atom({ plugin: 'stack', key: 'tracked' } as const, {})
31const backfill = atom({ plugin: 'stack', key: 'backfill' } as const, {})
32const backfillStatus = atom({ plugin: 'stack', key: 'backfillStatus' } as const, 'not run')
33const lastCostUsd = atom({ plugin: 'stack', key: 'lastCostUsd' } as const, null)
34const isExpanded = atom({ plugin: 'stack', key: 'isExpanded' } as const, false)
35const hidden = atom({ plugin: 'stack', key: 'hidden' } as const, [])
36
37const COST_PREFIX = 'costs.'
38const CCUSAGE_KEY = 'ccusage'
39const HIDDEN_KEY = 'hidden'
40const CCUSAGE_MAX_AGE_MS = 12 * 60 * 60 * 1000
41const CCUSAGE_ARGS = ['--yes', 'ccusage', 'claude', 'monthly', '--json']
42// No shell: on Windows `npx` is a .cmd shim that only cmd.exe can start.
43const CCUSAGE_COMMANDS: string[][] = [
44 ['npx', ...CCUSAGE_ARGS],
45 ['cmd', '/d', '/s', '/c', ['npx', ...CCUSAGE_ARGS].join(' ')],
46]
47
48type $ = EngineInterface
49type CcusageCache = { fetchedAt: number; months: Record<string, number> }
50
51// Data ----------------------------------------------------------------------
52
53const setTasks = ($: $, fn: (list: StackTask[]) => StackTask[]) => update($, tasks, fn)
54
55const asStatus = (value: unknown): StackTaskStatus =>
56 value === 'completed' || value === 'in_progress' ? value : 'pending'
57
58const createdTaskId = (ran: ToolCallResult): string | undefined => {
59 const record = ran.result as { task?: { id?: unknown }; id?: unknown } | undefined
60 const id = record?.task?.id ?? record?.id ?? /#(\d+)/.exec(ran.text ?? '')?.[1]
61 return id === undefined ? undefined : String(id)
62}
63
64const noteAgentTool = async ($: $, agentId: string, tool: string, args: Record<string, unknown>) => {
65 const known = (await read($, agents))[agentId]
66 const info = known ? undefined : (await $.agent.list()).find(agent => agent.id === agentId)
67 const label = known?.label ?? info?.description ?? info?.type ?? 'Subagent'
68 const detail = toolDetail(tool, args)
69 await update($, agents, all => {
70 const before: StackAgent = all[agentId] ?? { label, tool, calls: 0 }
71 return { ...all, [agentId]: { ...before, tool, detail, calls: before.calls + 1 } }
72 })
73}
74
75const dropAgents = ($: $, isGone: (id: string) => boolean) =>
76 update($, agents, all => Object.fromEntries(Object.entries(all).filter(([id]) => !isGone(id))))
77
78const refreshLimits = async ($: $, readings?: readonly StackLimit[]) => {
79 const list = readings ?? (await $.session.usage()).rateLimits
80 await update($, limits, () => list.map(({ kind, percentUsed, resetsAt }) => ({ kind, percentUsed, resetsAt })))
81}
82
83// The session's cost only grows, so each reading's growth since the last one is new spend.
84const recordCost = async ($: $) => {
85 const cost = (await $.session.usage()).cost?.usd
86 if (cost === undefined) return
87 let delta = 0
88 await update($, lastCostUsd, last => {
89 delta = last === null ? 0 : cost >= last ? cost - last : cost
90 return cost
91 })
92 if (delta <= 0) return
93 const month = monthKey(await $.clock.now())
94 const key = `${COST_PREFIX}${month}`
95 const total = Number((await $.store.get(key)) ?? 0) + delta
96 await $.store.set(key, total)
97 await update($, tracked, months => ({ ...months, [month]: total }))
98 // ccusage is run at most every 12 hours, and its figure for this month stops there:
99 // spend since then is new to it, so it carries on from the snapshot.
100 const cache = (await $.store.get(CCUSAGE_KEY)) as CcusageCache | undefined
101 if (!cache?.months) return
102 const months = { ...cache.months, [month]: (cache.months[month] ?? 0) + delta }
103 await $.store.set(CCUSAGE_KEY, { ...cache, months } satisfies CcusageCache)
104 await update($, backfill, () => months)
105}
106
107const loadStore = async ($: $) => {
108 const months: Record<string, number> = {}
109 for (const key of await $.store.keys()) {
110 if (key.startsWith(COST_PREFIX)) months[key.slice(COST_PREFIX.length)] = Number(await $.store.get(key)) || 0
111 }
112 await update($, tracked, () => months)
113 const cache = (await $.store.get(CCUSAGE_KEY)) as CcusageCache | undefined
114 if (cache?.months) await update($, backfill, () => cache.months)
115 const saved = await $.store.get(HIDDEN_KEY)
116 if (Array.isArray(saved)) await update($, hidden, () => saved.filter((id): id is string => typeof id === 'string'))
117}
118
119const runCcusage = async ($: $): Promise<string> => {
120 const failures: string[] = []
121 for (const argv of CCUSAGE_COMMANDS) {
122 try {
123 const ran = await $.process.run(argv, { timeoutMs: 180_000 })
124 if (ran.exitCode === 0) return ran.stdout
125 failures.push(`${argv[0]} exited ${ran.exitCode}: ${ran.stderr.trim().split('\n').pop() ?? ''}`)
126 } catch (error) {
127 failures.push(`${argv[0]}: ${error instanceof Error ? error.message : String(error)}`)
128 }
129 }
130 throw new Error(failures.join('; '))
131}
132
133// Merges by month, keeping the larger figure: Claude Code prunes old transcripts, so a later
134// ccusage run can know less about an old month than an earlier one did.
135const backfillFromCcusage = async ($: $) => {
136 const cache = (await $.store.get(CCUSAGE_KEY)) as CcusageCache | undefined
137 const now = await $.clock.now()
138 if (cache && now - cache.fetchedAt < CCUSAGE_MAX_AGE_MS) {
139 await update($, backfillStatus, () => `cached ${Object.keys(cache.months).length} months`)
140 return
141 }
142 try {
143 const fresh = parseCcusage(await runCcusage($))
144 const months = { ...cache?.months }
145 for (const [key, value] of Object.entries(fresh)) months[key] = Math.max(months[key] ?? 0, value)
146 await $.store.set(CCUSAGE_KEY, { fetchedAt: now, months } satisfies CcusageCache)
147 await update($, backfill, () => months)
148 await update($, backfillStatus, () => `loaded ${Object.keys(fresh).length} months from ccusage`)
149 } catch (error) {
150 const reason = error instanceof Error ? error.message : String(error)
151 await update($, backfillStatus, () => `ccusage failed: ${reason}`)
152 $.ui.log(`stack: ccusage backfill failed: ${reason}`, { to: 'debug' })
153 }
154}
155
156const setHidden = async ($: $, fn: (ids: string[]) => string[]) => {
157 await update($, hidden, ids => [...new Set(fn(ids))])
158 await $.store.set(HIDDEN_KEY, await read($, hidden))
159}
160
161// Drawing -------------------------------------------------------------------
162
163type AnyElements = Elements['terminal'] | Elements['desktop']
164
165// The engine draws `[-]` at the band's top-right corner, over whatever is there.
166const ENGINE_MARK_CELLS = 4
167
168type Layout = { surface: 'terminal' | 'desktop'; grid: RowGrid }
169
170type ProgressRow = RowSpec & { onHide: () => void }
171
172const SEGMENT_STYLE: Record<Segment['kind'], { color?: string; backgroundColor?: string; dimColor?: boolean; bold?: boolean }> = {
173 fill: { color: ORANGE },
174 pill: { color: PILL_FG, backgroundColor: PILL_BG, bold: true },
175 tickFill: { color: ORANGE },
176 tickEmpty: { color: ORANGE, dimColor: true },
177 empty: { dimColor: true },
178}
179
180const progressRow = (elements: AnyElements, row: ProgressRow, layout: Layout) => {
181 const { Box, Text, Button } = elements
182 const hide = <Button key={`hide:${row.id}`} label="×" plain dimColor onPress={row.onHide} />
183
184 if (layout.surface === 'desktop') {
185 const { Svg } = elements as Elements['desktop']
186 const svg = desktopRowSvg(row, layout.grid)
187 return (
188 <Box key={`row:${row.id}`} flexDirection="row" alignItems="center" gap={1}>
189 <Svg source={svg} alt={`${row.label}: ${row.pill}, ${row.right}`} width={Number(/width="(\d+)"/.exec(svg)?.[1])} height={ROW_PX} />
190 {hide}
191 </Box>
192 )
193 }
194
195 return (
196 <Box key={`row:${row.id}`} flexDirection="row">
197 <Text color={ORANGE}>● </Text>
198 <Text>{fit(row.label, layout.grid.labelCells)} </Text>
199 {terminalTrack(row, layout.grid.trackCells).map(segment => (
200 <Text {...SEGMENT_STYLE[segment.kind]}>{segment.text}</Text>
201 ))}
202 <Text {...(row.tone === 'accent' ? {} : { color: TONES[row.tone] })}>{` ${row.right.padStart(layout.grid.rightCells)} `}</Text>
203 {hide}
204 </Box>
205 )
206}
207
208const readData = async ($: $): Promise<StackData> => ({
209 tasks: await read($, tasks),
210 agents: await read($, agents),
211 limits: await read($, limits),
212 tracked: await read($, tracked),
213 backfill: await read($, backfill),
214})
215
216const toggleExpanded = ($: $) => update($, isExpanded, open => !open)
217
218// Hooks ---------------------------------------------------------------------
219
220export const register: Register = on => {
221 on('session.start', async ($, e, next) => {
222 const started = await next(e)
223 await $.command.register({
224 name: 'stack',
225 description: 'Progress band: toggle, expand, collapse, hide <row>, show <row|all>',
226 argumentHint: '[expand|collapse|hide <row>|show <row|all>]',
227 })
228 await loadStore($)
229 const usage = await $.session.usage()
230 if ((await read($, lastCostUsd)) === null) await update($, lastCostUsd, () => usage.cost?.usd ?? 0)
231 await refreshLimits($, usage.rateLimits)
232 $.clock.after(1000, () => void backfillFromCcusage($))
233 return started
234 })
235
236 on('tool.call', async ($, e, next) => {
237 const tool: string = e.tool
238 const args = e as unknown as Record<string, unknown>
239 if (e.agentId) {
240 await noteAgentTool($, e.agentId, tool, args)
241 return next(e)
242 }
243
244 if (tool === 'TodoWrite' && Array.isArray(args.todos)) {
245 const todos = args.todos as Array<Record<string, unknown>>
246 await setTasks($, () =>
247 todos.map((todo, index) => ({
248 id: String(index),
249 subject: String(todo.content ?? ''),
250 status: asStatus(todo.status),
251 ...(typeof todo.activeForm === 'string' ? { activeForm: todo.activeForm } : {}),
252 })),
253 )
254 return next(e)
255 }
256
257 if (tool === 'TaskCreate') {
258 const ran = await next(e)
259 const id = createdTaskId(ran)
260 if (!ran.isError && !ran.deny && id !== undefined) {
261 const task: StackTask = {
262 id,
263 subject: String(args.subject ?? ''),
264 status: 'pending',
265 ...(typeof args.activeForm === 'string' ? { activeForm: args.activeForm } : {}),
266 }
267 await setTasks($, list => [...list.filter(one => one.id !== id), task])
268 }
269 return ran
270 }
271
272 if (tool === 'TaskUpdate' && args.taskId !== undefined) {
273 const ran = await next(e)
274 const id = String(args.taskId)
275 if (!ran.isError && !ran.deny && args.status !== undefined) {
276 await setTasks($, list =>
277 args.status === 'deleted'
278 ? list.filter(task => task.id !== id)
279 : list.map(task => (task.id === id ? { ...task, status: asStatus(args.status) } : task)),
280 )
281 }
282 return ran
283 }
284
285 return next(e)
286 })
287
288 on('session.measure', async ($, e, next) => {
289 if (e.changed.includes('rateLimits')) await refreshLimits($, e.rateLimits)
290 return next(e)
291 })
292
293 on('turn.complete', async ($, e, next) => {
294 const done = await next(e)
295 await recordCost($)
296 if (e.agentId) {
297 const finished = e.agentId
298 await dropAgents($, id => id === finished)
299 } else {
300 const running = new Set((await $.agent.list()).filter(agent => agent.status === 'running').map(agent => agent.id))
301 await dropAgents($, id => !running.has(id))
302 await refreshLimits($)
303 }
304 return done
305 })
306
307 on('command.run', { command: 'stack' }, async ($, e) => {
308 const [verb = '', ...rest] = e.args.trim().split(/\s+/).filter(Boolean)
309 const target = rest.join(' ').toLowerCase()
310
311 if (verb === '') {
312 await toggleExpanded($)
313 return { text: `stack ${(await read($, isExpanded)) ? 'expanded' : 'collapsed'}. Backfill: ${await read($, backfillStatus)}.` }
314 }
315 if (verb === 'expand' || verb === 'collapse') {
316 await update($, isExpanded, () => verb === 'expand')
317 return { text: `stack ${verb === 'expand' ? 'expanded' : 'collapsed'}.` }
318 }
319 if (verb === 'hide' || verb === 'show') {
320 const ids = target === 'all' && verb === 'show' ? null : ROW_NAMES[target]
321 if (ids === undefined) return { text: `Unknown row "${target}". Rows: ${Object.keys(ROW_NAMES).join(', ')}${verb === 'show' ? ', all' : ''}.` }
322 await setHidden($, list => (verb === 'hide' ? [...list, ...(ids ?? [])] : ids === null ? [] : list.filter(id => !ids.includes(id))))
323 return { text: `${verb === 'hide' ? 'Hid' : 'Showing'} ${target}.` }
324 }
325 return { text: 'Usage: /stack [expand|collapse|hide <row>|show <row|all>]' }
326 })
327
328 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
329 if (e.props.hasSurvey || (e.surface !== 'terminal' && e.surface !== 'desktop')) return next(e)
330
331 const now = await $.clock.now()
332 const data = await readData($)
333 const hiddenIds = await read($, hidden)
334 const rows = buildRows(data, now).filter(row => !isHidden(row.id, hiddenIds))
335 // The band is shared: what the mods beneath drew (the engine draws nothing of its own
336 // here) stays, under a one-line summary of these rows.
337 const below = await next(e)
338 if (rows.length === 0) return below
339 const isShared = below !== null && typeof below === 'object' && below.type !== 'engine'
340
341 const elements: AnyElements = $.ui.resolve(e)
342 const { Box, Text, Button } = elements
343 const { bodyColumns, maxRows } = e.props
344 const toggle = (label: string) => (
345 <Button key="toggle" hotkey="e" plain dimColor label={label} onPress={() => toggleExpanded($)} />
346 )
347
348 // In the terminal a blank row above sets the band apart from the transcript, while the
349 // band has the rows to spare.
350 const gap = e.surface === 'terminal' && maxRows >= 3 ? 1 : 0
351
352 if (isShared || !(await read($, isExpanded)) || maxRows - gap < 2) {
353 const parts = summaryParts(rows)
354 const summary = (
355 <Box flexDirection="row" gap={1} marginTop={gap}>
356 <Text color={ORANGE}>{MARK}</Text>
357 <Text wrap="truncate">
358 {parts.flatMap((part, index) => (index === 0 ? [part] : [<Text dimColor> · </Text>, part]))}
359 </Text>
360 {!isShared && toggle('expand')}
361 </Box>
362 )
363 return isShared ? (
364 <Box flexDirection="column">
365 {summary}
366 {below}
367 </Box>
368 ) : (
369 summary
370 )
371 }
372
373 const shown = rows.slice(0, maxRows - gap - 1)
374 // The desktop has no cells; its rows are drawn on a grid of CELL_PX that fills the band.
375 // The terminal leaves the band's last columns to the engine's [-] collapse mark.
376 const cells =
377 e.surface === 'desktop' ? Math.floor(Math.min(1100, Math.max(360, bodyColumns * 7.6 - 60)) / CELL_PX) : bodyColumns - 2 - ENGINE_MARK_CELLS
378 const layout: Layout = { surface: e.surface, grid: rowGrid(shown, cells) }
379
380 const more = rows.length - shown.length
381 const hideRow = (id: string) => () => setHidden($, list => [...list, id])
382
383 return (
384 <Box flexDirection="column" marginTop={gap}>
385 {shown.map(row => progressRow(elements, { ...row, onHide: hideRow(row.id) }, layout))}
386 <Box flexDirection="row" gap={1}>
387 {more > 0 && <Text dimColor>+{more} more</Text>}
388 {toggle('collapse')}
389 </Box>
390 </Box>
391 )
392 })
393}
394hooks/model.ts 332 lines1import type { StackAgent, StackLimit, StackTask } from '../types'
2
3// Claude's orange: the dither and the dots in its light clay, the pill in its deeper crail.
4export const ORANGE = '#D97757'
5export const PILL_BG = '#B5532F'
6export const PILL_FG = '#FFF4EC'
7// A limit close to running out shows its percentage in amber, then red.
8export const TONES = { accent: ORANGE, warn: '#E0A84E', danger: '#E5534B' } as const
9export const MARK = '✻'
10
11const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
12const MONTHS_LONG = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
13const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
14const DAY_MS = 24 * 60 * 60 * 1000
15
16export type Tone = keyof typeof TONES
17
18export type RowSpec = {
19 id: string
20 label: string
21 // null: no known total (a subagent), drawn as a dither run that moves with each tool call.
22 percent: number | null
23 pill: string
24 // The right-hand column: a percentage, or a subagent's call count.
25 right: string
26 // What the collapsed one-line summary says for this row; '' for none.
27 summary: string
28 steps: number
29 tone: Tone
30 phase: number
31}
32
33export type StackData = {
34 tasks: StackTask[]
35 agents: Record<string, StackAgent>
36 limits: StackLimit[]
37 tracked: Record<string, number>
38 backfill: Record<string, number>
39}
40
41export const monthKey = (ms: number): string => {
42 const d = new Date(ms)
43 return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
44}
45
46export const previousMonthKey = (ms: number): string => {
47 const d = new Date(ms)
48 return monthKey(new Date(d.getFullYear(), d.getMonth() - 1, 1).getTime())
49}
50
51const monthIndex = (key: string): number => Number(key.slice(5, 7)) - 1
52
53// The mod's own ledger starts the month it was installed; ccusage knows what came before.
54// Both count the same local Claude Code spend, so the larger figure is the more complete one.
55export const monthTotal = (data: Pick<StackData, 'tracked' | 'backfill'>, key: string): number =>
56 Math.max(data.tracked[key] ?? 0, data.backfill[key] ?? 0)
57
58export const usd = (value: number): string => {
59 const [whole = '0', cents = '00'] = value.toFixed(2).split('.')
60 return `$${whole.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}.${cents}`
61}
62
63export const formatReset = (iso: string | undefined, now: number): string => {
64 const at = iso === undefined ? NaN : Date.parse(iso)
65 if (Number.isNaN(at)) return 'no reset time'
66 const d = new Date(at)
67 const hours = d.getHours()
68 const minutes = d.getMinutes()
69 const clock = `${hours % 12 || 12}${minutes ? `:${String(minutes).padStart(2, '0')}` : ''} ${hours < 12 ? 'AM' : 'PM'}`
70 return at - now < DAY_MS ? clock : `${DAYS[d.getDay()]} ${clock}`
71}
72
73const LIMIT_ROWS: Record<string, { id: string; label: string; short: string }> = {
74 five_hour: { id: 'session', label: 'Session limit', short: '5h' },
75 seven_day: { id: 'weekly', label: 'Weekly limit', short: 'week' },
76 spend_limit: { id: 'spend', label: 'Spend limit', short: 'spend' },
77}
78
79const limitRow = (kind: string) => LIMIT_ROWS[kind] ?? { id: kind, label: kind, short: kind }
80
81const limitTone = (percent: number): Tone => (percent >= 90 ? 'danger' : percent >= 75 ? 'warn' : 'accent')
82
83const shortTool = (tool: string): string => tool.replace(/^mcp__.+?__/, '')
84
85const baseName = (path: string): string => path.split(/[\\/]/).pop() ?? path
86
87const oneLine = (text: string, length: number): string => {
88 const flat = text.replace(/\s+/g, ' ').trim()
89 return flat.length > length ? `${flat.slice(0, length - 1)}…` : flat
90}
91
92// What a subagent's tool call is about, in a few words: the file it reads, the pattern it
93// searches, the command it runs.
94export const toolDetail = (tool: string, args: Record<string, unknown>): string => {
95 const pick = (key: string) => (typeof args[key] === 'string' ? (args[key] as string) : undefined)
96 const path = pick('file_path') ?? pick('notebook_path') ?? pick('path')
97 const subject = (path && baseName(path)) ?? pick('pattern') ?? pick('command') ?? pick('url') ?? pick('query') ?? pick('description')
98 return oneLine(subject ? `${shortTool(tool)} ${subject}` : shortTool(tool), 48)
99}
100
101const percentRight = (percent: number): string => `${Math.round(percent)}%`
102
103export const buildRows = (data: StackData, now: number): RowSpec[] => {
104 const rows: RowSpec[] = []
105 const base = { steps: 0, tone: 'accent' as Tone, phase: 0 }
106
107 if (data.tasks.length > 0) {
108 const done = data.tasks.filter(task => task.status === 'completed').length
109 const percent = (done / data.tasks.length) * 100
110 const pill = `Tasks ${done}/${data.tasks.length}`
111 rows.push({ ...base, id: 'tasks', label: 'Tasks', percent, pill, right: percentRight(percent), summary: pill, steps: data.tasks.length })
112 }
113
114 for (const [id, agent] of Object.entries(data.agents)) {
115 rows.push({
116 ...base,
117 id: `agent:${id}`,
118 label: agent.label,
119 percent: null,
120 pill: agent.detail ?? shortTool(agent.tool),
121 right: `${agent.calls} ${agent.calls === 1 ? 'call' : 'calls'}`,
122 summary: '',
123 phase: agent.calls,
124 })
125 }
126
127 for (const limit of data.limits) {
128 const meta = limitRow(limit.kind)
129 rows.push({
130 ...base,
131 id: meta.id,
132 label: meta.label,
133 percent: limit.percentUsed,
134 pill: formatReset(limit.resetsAt, now),
135 right: percentRight(limit.percentUsed),
136 summary: `${meta.short} ${percentRight(limit.percentUsed)}`,
137 tone: limitTone(limit.percentUsed),
138 })
139 }
140
141 const thisKey = monthKey(now)
142 const lastKey = previousMonthKey(now)
143 const thisMonth = monthTotal(data, thisKey)
144 const lastMonth = monthTotal(data, lastKey)
145 if (thisMonth > 0 || lastMonth > 0) {
146 const percent = lastMonth > 0 ? (thisMonth / lastMonth) * 100 : 100
147 rows.push({
148 ...base,
149 id: 'costs',
150 label: `${MONTHS_LONG[monthIndex(thisKey)]} so far`,
151 percent,
152 pill: lastMonth > 0 ? `${usd(thisMonth)} / ${MONTHS[monthIndex(lastKey)]} ${usd(lastMonth)}` : usd(thisMonth),
153 right: percentRight(percent),
154 summary: `${MONTHS[monthIndex(thisKey)]} ${usd(thisMonth)}`,
155 tone: percent > 100 ? 'warn' : 'accent',
156 })
157 }
158
159 return rows
160}
161
162export const summaryParts = (rows: RowSpec[]): string[] => {
163 const agents = rows.filter(row => row.id.startsWith('agent:')).length
164 const parts = rows.filter(row => row.summary !== '').map(row => row.summary)
165 if (agents > 0) parts.splice(rows[0]?.id === 'tasks' ? 1 : 0, 0, `${agents} ${agents === 1 ? 'agent' : 'agents'}`)
166 return parts
167}
168
169export const ROW_NAMES: Record<string, string[]> = {
170 tasks: ['tasks'],
171 task: ['tasks'],
172 subagents: ['subagents'],
173 agents: ['subagents'],
174 session: ['session'],
175 '5h': ['session'],
176 weekly: ['weekly'],
177 week: ['weekly'],
178 spend: ['spend'],
179 limits: ['session', 'weekly', 'spend'],
180 costs: ['costs'],
181 cost: ['costs'],
182}
183
184export const isHidden = (id: string, hidden: readonly string[]): boolean =>
185 hidden.includes(id) || (id.startsWith('agent:') && hidden.includes('subagents'))
186
187export const fit = (text: string, cells: number): string =>
188 text.length > cells ? `${text.slice(0, Math.max(0, cells - 1))}…` : text.padEnd(cells)
189
190// The row grid ----------------------------------------------------------------
191
192// ● label ⣿⣷⡿ pill ····│···· 40% ×, in character cells. Both surfaces draw this grid:
193// the terminal as text, the desktop as an image in a monospaced font.
194export type RowGrid = { labelCells: number; trackCells: number; rightCells: number }
195
196export const rowGrid = (rows: RowSpec[], cells: number): RowGrid => {
197 const labelCells = Math.min(22, Math.max(0, ...rows.map(row => row.label.length)))
198 const rightCells = Math.max(4, ...rows.map(row => row.right.length))
199 // 2 for the dot, 2 after the label, 1 before and 1 after the right column.
200 return { labelCells, rightCells, trackCells: Math.max(10, cells - 2 - labelCells - 2 - 1 - rightCells - 1) }
201}
202
203export type SegmentKind = 'fill' | 'pill' | 'empty' | 'tickFill' | 'tickEmpty'
204export type Segment = { kind: SegmentKind; text: string; start: number }
205
206// Mostly full cells, with a dot missing here and there: the texture of the reference.
207const BRAILLE = ['⣿', '⣿', '⣿', '⣷', '⡿', '⣿', '⡷', '⣾']
208
209const hash = (text: string): number =>
210 [...text].reduce((sum, char) => Math.imul(sum ^ char.charCodeAt(0), 16777619), 2166136261)
211
212// The top bits of a multiplicative hash: the low ones of cell × odd repeat every few cells.
213const dither = (cell: number, seed: number): string =>
214 BRAILLE[(Math.imul(cell + 1, 2654435761) ^ seed) >>> 29] ?? '⣿'
215
216// The braille dither up to the progress point, the pill ending at it (or just after it, while
217// the fill is too short to show around the pill), a tick at each step boundary and a dim dot
218// for every empty cell. With no known total, a short dither run moves along with the pill.
219export const terminalTrack = (row: RowSpec, cells: number): Segment[] => {
220 const text = row.pill.length > cells - 4 ? `${row.pill.slice(0, Math.max(1, cells - 5))}…` : row.pill
221 const pill = ` ${text} `
222 let fillStart = 0
223 let fillEnd = 0
224 if (row.percent === null) {
225 const run = Math.max(3, Math.round(cells / 10))
226 const room = Math.max(1, cells - run - pill.length + 1)
227 fillStart = (row.phase * 3) % room
228 fillEnd = fillStart + run
229 } else if (row.percent > 0) {
230 fillEnd = Math.max(1, Math.round((cells * Math.min(100, row.percent)) / 100))
231 }
232 const isPillInside = fillEnd - fillStart >= pill.length + 2
233 const pillStart = Math.min(isPillInside ? fillEnd - pill.length : fillEnd, cells - pill.length)
234 const ticks = new Set<number>()
235 for (let i = 1; i < row.steps; i++) ticks.add(Math.round((cells * i) / row.steps))
236 const seed = hash(row.id)
237
238 const segments: Segment[] = []
239 const push = (kind: SegmentKind, char: string, cell: number) => {
240 const last = segments[segments.length - 1]
241 if (last?.kind === kind) last.text += char
242 else segments.push({ kind, text: char, start: cell })
243 }
244 for (let cell = 0; cell < cells; cell++) {
245 const isFill = cell >= fillStart && cell < fillEnd
246 if (cell >= pillStart && cell < pillStart + pill.length) push('pill', pill[cell - pillStart] ?? ' ', cell)
247 else if (ticks.has(cell)) push(isFill ? 'tickFill' : 'tickEmpty', '│', cell)
248 else if (isFill) push('fill', dither(cell, seed), cell)
249 else push('empty', '·', cell)
250 }
251 return segments
252}
253
254// Desktop row -----------------------------------------------------------------
255
256export const CELL_PX = 8.4
257export const ROW_PX = 26
258const FONT_PX = 14
259
260const escapeXml = (text: string): string =>
261 text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
262
263// One braille character as its dots: the low byte of its code point is one bit per dot,
264// dots 1-3 and 7 down the left column, 4-6 and 8 down the right.
265const BRAILLE_DOTS: Array<[number, number]> = [[0, 0], [0, 1], [0, 2], [1, 0], [1, 1], [1, 2], [0, 3], [1, 3]]
266
267const brailleSymbol = (char: string): string => {
268 const bits = (char.codePointAt(0) ?? 0x2800) - 0x2800
269 const dots = BRAILLE_DOTS.filter((_, bit) => bits & (1 << bit)).map(
270 ([column, row]) => `<rect x="${(1.6 + column * 3.4).toFixed(1)}" y="${(-7 + row * 4).toFixed(1)}" width="2" height="2" rx="0.5"/>`,
271 )
272 return `<g id="b${char.codePointAt(0)}" class="fill">${dots.join('')}</g>`
273}
274
275// The terminal's grid as an image: each run of characters is pinned to its cells with
276// textLength, so the braille, the dots and the pill line up as they do in a terminal.
277export const desktopRowSvg = (row: RowSpec, grid: RowGrid): string => {
278 const cells = 2 + grid.labelCells + 2 + grid.trackCells + 1 + grid.rightCells
279 const width = Math.ceil(cells * CELL_PX)
280 const mid = ROW_PX / 2
281 const trackX = (2 + grid.labelCells + 2) * CELL_PX
282 const run = (x: number, text: string, cls: string) =>
283 `<text class="${cls}" x="${x.toFixed(1)}" y="${mid}" textLength="${(text.length * CELL_PX).toFixed(1)}" lengthAdjust="spacingAndGlyphs" dominant-baseline="central" xml:space="preserve">${escapeXml(text)}</text>`
284
285 const track = terminalTrack(row, grid.trackCells).map(segment => {
286 const x = trackX + segment.start * CELL_PX
287 if (segment.kind === 'fill') {
288 return [...segment.text].map((char, i) => `<use href="#b${char.codePointAt(0)}" x="${(x + i * CELL_PX).toFixed(1)}" y="${mid}"/>`).join('')
289 }
290 if (segment.kind !== 'pill') return run(x, segment.text, segment.kind)
291 const w = segment.text.length * CELL_PX
292 return `<rect x="${x.toFixed(1)}" y="${mid - 9}" width="${w.toFixed(1)}" height="18" rx="2" fill="${PILL_BG}"/>${run(x, segment.text, 'pill')}`
293 })
294 const label = row.label.length > grid.labelCells ? `${row.label.slice(0, grid.labelCells - 1)}…` : row.label
295 const right = row.right.padStart(grid.rightCells)
296 const rightClass = row.tone === 'accent' ? 'right' : `right ${row.tone}`
297
298 return [
299 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${ROW_PX}" viewBox="0 0 ${width} ${ROW_PX}"`,
300 ` font-family="'Cascadia Mono', 'Cascadia Code', Consolas, 'SF Mono', Menlo, ui-monospace, monospace" font-size="${FONT_PX}">`,
301 '<style>',
302 `.label{fill:#ECE9E2}.right{fill:#C9C5BD}.fill,.tickFill{fill:${ORANGE}}.pill{fill:${PILL_FG};font-weight:600}`,
303 `.empty{fill:#6E6A64}.tickEmpty{fill:#8A6A58}.right.warn{fill:${TONES.warn}}.right.danger{fill:${TONES.danger}}`,
304 '@media (prefers-color-scheme: light){.label{fill:#2A2925}.right{fill:#4A4740}.empty{fill:#B3AEA5}.tickEmpty{fill:#B98A70}',
305 '.right.warn{fill:#B7791F}.right.danger{fill:#C93C37}}',
306 '</style>',
307 `<defs>${[...new Set(BRAILLE)].map(brailleSymbol).join('')}</defs>`,
308 `<circle cx="${(CELL_PX * 0.6).toFixed(1)}" cy="${mid}" r="3.5" fill="${ORANGE}"/>`,
309 run(2 * CELL_PX, label, 'label'),
310 ...track,
311 run((cells - grid.rightCells) * CELL_PX, right, rightClass),
312 '</svg>',
313 ].join('')
314}
315
316// ccusage -------------------------------------------------------------------
317
318// `ccusage claude monthly --json` answers { monthly: [{ period | month: "YYYY-MM", totalCost }] }.
319export const parseCcusage = (stdout: string): Record<string, number> => {
320 const parsed: unknown = JSON.parse(stdout.slice(stdout.indexOf('{')))
321 const monthly = (parsed as { monthly?: unknown }).monthly
322 if (!Array.isArray(monthly)) throw new Error('ccusage JSON has no "monthly" array')
323 const months: Record<string, number> = {}
324 for (const entry of monthly as Array<Record<string, unknown>>) {
325 const key = entry.period ?? entry.month
326 if (typeof key === 'string' && /^\d{4}-\d{2}$/.test(key) && typeof entry.totalCost === 'number') {
327 months[key] = entry.totalCost
328 }
329 }
330 return months
331}
332types/index.d.ts 26 lines1export type StackTaskStatus = 'pending' | 'in_progress' | 'completed'
2
3// `activeForm` is how the todo tool phrases a task while it runs ("Writing tests").
4export type StackTask = { id: string; subject: string; status: StackTaskStatus; activeForm?: string }
5
6// `detail` is the current tool call in a few words ("Read register.tsx").
7export type StackAgent = { label: string; tool: string; detail?: string; calls: number }
8
9export type StackLimit = { kind: string; percentUsed: number; resetsAt?: string }
10
11declare module 'claude-code' {
12 interface PluginState {
13 stack: {
14 tasks: StackTask[]
15 agents: Record<string, StackAgent>
16 limits: StackLimit[]
17 tracked: Record<string, number>
18 backfill: Record<string, number>
19 backfillStatus: string
20 lastCostUsd: number | null
21 isExpanded: boolean
22 hidden: string[]
23 }
24 }
25}
26