SLOPSHOPPER

stack

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

newbandguardcommandprocesstimer
v0.3.2no licenseupdated 2026-10-02ShahriarBijoy/claude-mods/stack
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · stack
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /stack ⎿ stack: stack expanded. Backfill: not run. ● Session limit ⣿⣾⣿⣿⡷⣷⡿ no reset time ················································· 31% × e: collapse ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
● Session limit ⣿⣾⣿⣿⡷⣷⡿ no reset time ················································· 31% × e: collapse
README

claude-mods

Claude Code mods by Shahriar.

Install

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).

stack

A progress band above the prompt with one row per tracked thing:

  • Tasks: progress through the todo list, with a tick per task.
  • Subagents: one row per running subagent, showing its current tool.
  • Session and weekly limits: usage, and when each resets.
  • Monthly cost: this month's spend so far against last month's.

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.

wartezeit

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.

  • Words from your session: once your prompt, the files Claude touches and the commands it runs make the topic clear, a background 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.
  • Checked articles: every noun's article is checked against a bundled list of German noun genders, and cards that disagree are dropped.
  • Card types: English → German, German → English, der/die/das, and fill-the-gap sentences. In a gap for a noun, the other options all have a different gender, so only one fits.
  • Spaced repetition: each word moves through five Leitner boxes. Three of every five questions are new words from the session and two are due reviews from earlier sessions.
  • Goal and streak: 20 right answers a day, a streak for every day with a right answer, and 10 XP per right answer.

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 topic

Note: 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.

Source 3 files
hooks/register.tsx 394 lines
1import { 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}
394
hooks/model.ts 332 lines
1import 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
332
types/index.d.ts 26 lines
1export 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