SLOPSHOPPER

leo-mods

Leo's Claude Code mods: usage bars, limits, cost, prompt cache countdown, active time and running agents

newbandspinnerprompttimeragents
★ 1v0.6.0MITupdated 2026-10-02quango2304/leo_claude_mod
A shopper browsing a rack in a slop shop
README

leo_claude_mod

Leo's personal mods for Claude Code (terminal and the desktop app's Code tab), packaged as one plugin with the ID leo-mods.

Features

One clean line of live session info. In the desktop app it sits above the prompt: the cost over the active time at the left, and a bar for each meter at the right, its name over it in its color and the number inside. Here it is before the first response, mid-session and near the limits, in the dark and light themes:

The usage line in the desktop app

In the terminal it's one footer label, with text bars:

active 12m · 2 agents · cache ▰▰▰▰▱ 42m · context ▰▰▱▱▱ 42% · 5h ▰▱▱▱▱ 23% 1h20m · week ▱▱▱▱▱ 12% Sat 9:00 PM · $0.13

The desktop app doesn't draw a mod's footer labels, so there the line goes above the prompt instead.

PartWhat it showsFile
active 12mTime Claude has spent working this session: the main conversation's turn durations added up (not time since the session opened), in whole minutes. Counted from when the plugin loads, which is the session start in a new session.hooks/features/activity.ts
2 agentsSubagents running right now; hidden when none are.hooks/features/activity.ts
cacheHow long the prompt cache stays warm: a bar that drains (green, then amber under 40% left, red under 15%) and the minutes left, or a red cold once it expired.hooks/features/usage.ts
contextHow full the context window is. Near 100%, the conversation gets compacted.hooks/features/usage.ts
5h / weekHow much of your subscription's 5-hour and weekly usage limits you've used, and when each resets: the time left for the 5-hour window (1h20m, ticks every minute), the day and local time for the weekly one (Sat 9:00 PM). Hidden off a subscription (API key).hooks/features/usage.ts
$0.13The session's total cost, in dollars and cents: green under $50, amber under $100, red from $100.hooks/features/usage.ts

Each bar has its own color (context blue, 5h violet, week teal), then turns amber from 50% and red from 80% (hooks/meters.ts).

Usage numbers are read when the session starts and updated after each turn; the 5-hour countdown ticks every minute. Numbers keep a fixed width (05%, 07m, 1h20m, $0.00) so the line doesn't shift. Until the first response, a new session shows the line with empty bars and zeros (00m, 00%, 0h00m, --- -:-- --, $0.00, active 0m).

How the cache countdown works. Each response from the main conversation restarts the prompt cache's timer, and the bar stays full while a turn runs. The TTL follows Claude Code's own rule: CLAUDE_CODE_PROMPT_CACHE_TTL, then the promptCacheTtl setting, then 1 hour on a Claude subscription within its usage limits and 5 minutes on an API key, Bedrock or Vertex. The countdown keeps running while the session sits idle (re-checked every 15 seconds against the clock, so it's right after the Mac wakes too), and the last-refresh time is saved per session, so a restarted or resumed session picks it back up. It's an estimate from the client side: the API doesn't report when an entry expires. Active time updates when each turn ends. The agent count refreshes when a subagent starts and when a turn ends, and every 2 seconds while any are running.

The cost is the same estimate /cost shows, priced at API list rates. On a Pro/Max subscription it is not what you pay.

Install

From a terminal:

claude plugin marketplace add quango2304/leo_claude_mod
claude plugin install leo-mods@quango2304

Or inside an interactive claude session: /plugin marketplace add quango2304/leo_claude_mod, then /plugin install leo-mods@quango2304.

Start a new session (in the terminal or the desktop app's Code tab). The line appears right away, zeroed until the first response: in the footer in the terminal, above the prompt in the desktop app.

Update to the latest version:

claude plugin marketplace update quango2304
claude plugin update leo-mods@quango2304

Uninstall:

claude plugin uninstall leo-mods@quango2304

Develop

Clone the repo and load it straight from disk instead of the installed copy, by adding it to the env block of ~/.claude/settings.json:

"env": {
  "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/leo_claude_mod",
  "CLAUDE_CODE_PLUGIN_DIR_WATCH": "1"
}

CLAUDE_CODE_PLUGIN_DIR_WATCH makes edits reload in a running session; without it, changes apply from the next new session. Don't have the installed plugin enabled at the same time, or both copies load and the usage line shows twice (claude plugin disable leo-mods@quango2304).

The README's picture (docs/preview.png) is drawn from the plugin's own code by bun scripts/preview.ts (needs Bun and Google Chrome). A pre-commit hook redraws and stages it whenever a commit touches hooks/; turn it on once per clone:

git config core.hooksPath .githooks

To publish a change, bump version in .claude-plugin/plugin.json so claude plugin update picks it up.

Layout

leo_claude_mod/
├── .claude-plugin/
│   ├── plugin.json              manifest (plugin ID "leo-mods")
│   └── marketplace.json         makes this repo installable as marketplace "quango2304"
├── .githooks/pre-commit         redraws docs/preview.png when the drawing code changes
├── hooks/
│   ├── hooks.json               points to register.tsx
│   ├── register.tsx             wires up the features and draws their labels
│   ├── meters.ts                draws a percentage as a bar (SVG on desktop, ▰▱ in the terminal)
│   └── features/
│       ├── usage.ts             cache countdown, context %, usage limits and resets, cost, alerts
│       └── activity.ts          active time, running subagents
├── docs/preview.png             the picture above, drawn by scripts/preview.ts
├── scripts/preview.ts           draws docs/preview.png from the plugin's own code (bun scripts/preview.ts)
├── tests/                       plugin tests (claude plugin test .)
├── types/index.d.ts             declares every value the plugin stores ($.state)
└── tsconfig.json                editor typings (the engine writes them to .claude-plugin/types)

Writing mods

See the Claude Code docs on mods: https://code.claude.com/docs/en/plugins/mods

Source 7 files
hooks/register.tsx 109 lines
1import { atom, read } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Limit, Mood, Percent, Usd } from '../types'
5import { activeLabel, agentsLabel, registerActivity } from './features/activity'
6import { registerMascot } from './features/mascot'
7import { cacheMeter, costColor, costLabel, registerUsage, usageMeters } from './features/usage'
8import { MASCOT_HEIGHT, MASCOT_WIDTH, MOOD_LABEL, svgMascot } from './mascot'
9import { type Meter, meterAlt, meterSize, meterText, PLACEHOLDER_METERS, type Summary, summarySize, summaryText, svgMeter, svgSummary, textMeter } from './meters'
10
11// The line reads each feature's value through its own atom on the same
12// state key: the engine only follows $ and state reads within one file.
13const contextPercent = atom({ plugin: 'leo-mods', key: 'contextPercent' } as const, null as Percent)
14const limits = atom({ plugin: 'leo-mods', key: 'limits' } as const, [] as Limit[])
15const costUsd = atom({ plugin: 'leo-mods', key: 'costUsd' } as const, null as Usd)
16const runningAgents = atom({ plugin: 'leo-mods', key: 'runningAgents' } as const, 0)
17const activeMs = atom({ plugin: 'leo-mods', key: 'activeMs' } as const, 0)
18const cacheLeftMs = atom({ plugin: 'leo-mods', key: 'cacheLeftMs' } as const, null as number | null)
19const cacheTtlMs = atom({ plugin: 'leo-mods', key: 'cacheTtlMs' } as const, null as number | null)
20const mascotMood = atom({ plugin: 'leo-mods', key: 'mascotMood' } as const, 'idle' as Mood)
21const mascotFrame = atom({ plugin: 'leo-mods', key: 'mascotFrame' } as const, 0)
22const nowMs = atom({ plugin: 'leo-mods', key: 'nowMs' } as const, 0)
23
24// What the line shows: the summary (cost, active time, agents) and
25// the meters (cache, context, 5h, week).
26async function readLine($: EngineInterface): Promise<{ summary: Summary; meters: Meter[] }> {
27  const active = await read($, activeMs)
28  const cache = cacheMeter(await read($, cacheLeftMs), await read($, cacheTtlMs))
29  const shown = [...(cache === null ? [] : [cache]), ...usageMeters(await read($, contextPercent), await read($, limits), await read($, nowMs))]
30  // Until the first response some meters have no reading yet (the cache, the
31  // context, the rate limits): each keeps its placeholder, so none pops in
32  // later and the line holds its shape.
33  const meters = cache === null ? PLACEHOLDER_METERS.map(placeholder => shown.find(m => m.name === placeholder.name) ?? placeholder) : shown
34  const cost = await read($, costUsd)
35  const summary = {
36    cost: cost === null ? '' : costLabel(cost),
37    costColor: costColor(cost ?? 0),
38    active: activeLabel(active),
39    agents: agentsLabel(await read($, runningAgents)),
40  }
41
42  // Before the first response there's no context reading and the cost is $0
43  // (never absent in the CLI).
44  if ((await read($, contextPercent)) === null && (cost ?? 0) === 0 && active === 0) {
45    return { summary: { ...summary, cost: costLabel(0) }, meters: PLACEHOLDER_METERS }
46  }
47
48  return { summary, meters }
49}
50
51// To add a feature: write hooks/features/<name>.ts exporting register<Name>(on)
52// and a formatter; declare its atom here and add its meter or label in readLine.
53export const register: Register = on => {
54  registerUsage(on)
55  registerActivity(on)
56  registerMascot(on)
57
58  // The terminal draws a mod's footer labels, so there the line is one label:
59  // "active 12m · 2 agents · context ▰▰▱▱▱ 42% · 5h ▰▱▱▱▱ 23% · $0.13".
60  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
61    const line = await readLine($)
62
63    if (e.surface !== 'terminal') {
64      return next(e)
65    }
66
67    const { summary } = line
68    const text = [
69      summary.active,
70      summary.agents,
71      ...line.meters.map(m => [m.name, textMeter(m.percent), meterText(m), m.detail].filter(Boolean).join(' ')),
72      summary.cost,
73    ].filter(Boolean).join(' · ')
74
75    return next({ ...e, props: { ...e.props, modes: [...e.props.modes, text] } })
76  })
77
78  // The desktop app asks for footer labels but doesn't draw them, so there
79  // the line goes above the prompt, all SVG (see meters.ts): the dog, then the cost
80  // over the active time and agents, at the left, the meters at the right.
81  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
82    const line = await readLine($)
83
84    if (e.surface !== 'desktop' || e.props.hasSurvey) {
85      return next(e)
86    }
87
88    const { Box, Svg } = $.ui.resolve(e)
89    const { summary } = line
90    const mood = await read($, mascotMood)
91    const frame = await read($, mascotFrame)
92    const size = summarySize(summary)
93
94    return (
95      <Box justifyContent="space-between" alignItems="center" gap={3}>
96        <Box alignItems="center" gap={2}>
97          <Svg source={svgMascot(mood, frame)} alt={`Mascot, ${MOOD_LABEL[mood]}`} width={MASCOT_WIDTH} height={MASCOT_HEIGHT} />
98          <Svg source={svgSummary(summary)} alt={summaryText(summary)} width={size.width} height={size.height} />
99        </Box>
100        <Box alignItems="center" gap={2} flexShrink={0}>
101          {line.meters.map(m => (
102            <Svg key={m.name} source={svgMeter(m)} alt={meterAlt(m)} {...meterSize(m)} />
103          ))}
104        </Box>
105      </Box>
106    )
107  })
108}
109
hooks/features/activity.ts 69 lines
1import { atom, update } from 'claude-code'
2import type { EngineInterface, On, Timer } from 'claude-code'
3
4import { noteTurnEnd } from './mascot'
5
6// What the session is doing: active time (the main loop's turn durations,
7// summed) and how many subagents are running. One feature, because both
8// need turn.complete and the engine takes one hook per event per plugin.
9const activeMs = atom({ plugin: 'leo-mods', key: 'activeMs' } as const, 0)
10const runningAgents = atom({ plugin: 'leo-mods', key: 'runningAgents' } as const, 0)
11
12const POLL_MS = 2000
13
14let poller: Timer | null = null
15
16// "active 0m", "active 12m", "active 1h 05m".
17export function activeLabel(ms: number) {
18  const minutes = Math.floor(ms / 60_000)
19
20  return minutes < 60 ? `active ${minutes}m` : `active ${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, '0')}m`
21}
22
23// "2 agents", or null when none are running.
24export function agentsLabel(running: number) {
25  if (running === 0) {
26    return null
27  }
28
29  return running === 1 ? '1 agent' : `${running} agents`
30}
31
32// Counts running subagents, and polls while any are, since a subagent can
33// finish between turns.
34async function refreshAgents($: EngineInterface) {
35  const agents = await $.agent.list()
36  const running = agents.filter(a => a.status === 'running').length
37  await update($, runningAgents, () => running)
38
39  if (running > 0 && poller === null) {
40    poller = $.clock.every(POLL_MS, () => void refreshAgents($))
41  } else if (running === 0 && poller !== null) {
42    poller.cancel()
43    poller = null
44  }
45}
46
47export function registerActivity(on: On) {
48  on('agent.spawn', async ($, e, next) => {
49    const result = await next(e)
50    await refreshAgents($)
51
52    return result
53  })
54
55  on('turn.complete', async ($, e, next) => {
56    const result = await next(e)
57
58    if (e.agentId === undefined) {
59      const ms = e.durationMs
60      await update($, activeMs, total => total + ms)
61    }
62
63    noteTurnEnd(await $.clock.now(), e)
64    await refreshAgents($)
65
66    return result
67  })
68}
69
hooks/features/mascot.ts 144 lines
1import { atom, update } from 'claude-code'
2import type { EngineInterface, On, Timer } from 'claude-code'
3
4import type { Mood } from '../../types'
5import { FRAME_TICKS, FRAMES } from '../mascot'
6
7// What the dog at the desktop line's left is doing, from what the session is
8// doing. The mood and the frame within it are two atoms the line reads; the
9// facts behind them (a turn running, when the last key and the last answer
10// were) stay here, and a timer lets the short moods run out and steps the
11// animation.
12const mascotMood = atom({ plugin: 'leo-mods', key: 'mascotMood' } as const, 'idle' as Mood)
13const mascotFrame = atom({ plugin: 'leo-mods', key: 'mascotFrame' } as const, 0)
14
15// The timer ticks often, to read the draft and the mood, so a stop in typing
16// shows fast; each mood steps its picture every FRAME_TICKS of them.
17const TICK_MS = 200
18const TYPING_MS = 500
19const DONE_MS = 2000
20
21type Facts = { isWorking: boolean; keyAt: number; doneAt: number }
22
23// The mood the facts add up to. She listens while you type, even mid-turn;
24// then works while a turn runs, cheers for a moment when it ends with an
25// answer, else dozes.
26export function moodAt(facts: Facts, now: number): Mood {
27  if (now - facts.keyAt < TYPING_MS) {
28    return 'typing'
29  }
30
31  if (facts.isWorking) {
32    return 'working'
33  }
34
35  return now - facts.doneAt < DONE_MS ? 'done' : 'idle'
36}
37
38const NEVER = -Infinity
39const facts: Facts = { isWorking: false, keyAt: NEVER, doneAt: NEVER }
40let current: Mood = 'idle'
41let frame = 0
42let ticks = 0
43let timer: Timer | null = null
44
45// The draft as of the last tick. The desktop reports an edit late (prompt.edit
46// fires well after the keys), so each tick also reads the draft, and a change
47// to it is typing. Only the timer reads it: from inside a hook (an edit, a tool
48// check) the read can wait on that very hook.
49let draft = ''
50
51async function pollDraft($: EngineInterface) {
52  const { text } = await $.prompt.read()
53
54  if (text !== draft && text !== '') {
55    facts.keyAt = await $.clock.now()
56  }
57
58  draft = text
59}
60
61// One timer tick: recomputes the mood, steps the animation, and writes either
62// only when it changed, so the line (a plain image, swapped per frame) redraws
63// only when the picture does.
64async function settle($: EngineInterface) {
65  const now = await $.clock.now()
66  const next = moodAt(facts, now)
67
68  if (next !== current) {
69    current = next
70    ticks = 0
71    await update($, mascotMood, () => next)
72  } else {
73    ticks += 1
74  }
75
76  const nextFrame = Math.floor(ticks / FRAME_TICKS[next]) % FRAMES[next].length
77
78  if (nextFrame !== frame) {
79    frame = nextFrame
80    await update($, mascotFrame, () => nextFrame)
81  }
82}
83
84// Starts the timer the first time one of her own hooks runs.
85function ensureTimer($: EngineInterface) {
86  timer ??= $.clock.every(TICK_MS, () => {
87    void pollDraft($).catch(() => undefined)
88    void settle($)
89  })
90}
91
92// The events another feature already hooks (the engine takes one hook per
93// event per plugin, and `$` can't cross a file) record the fact here, plain,
94// and the timer above turns it into a mood.
95export function noteTurnStart() {
96  facts.isWorking = true
97}
98
99// Only the main conversation's turns count: a subagent's end isn't hers.
100export function noteTurnEnd(now: number, e: { agentId?: string; reason: string }) {
101  if (e.agentId === undefined) {
102    facts.isWorking = false
103
104    if (e.reason === 'answer') {
105      facts.doneAt = now
106    }
107  }
108}
109
110export function registerMascot(on: On) {
111  on('session.end', async ($, e, next) => {
112    timer?.cancel()
113    timer = null
114
115    return next(e)
116  })
117
118  // Typing is an edit that puts text in or takes some out; moving the cursor
119  // isn't.
120  on('prompt.edit', async ($, e, next) => {
121    if (e.inputText !== '' || e.end > e.start) {
122      facts.keyAt = await $.clock.now()
123      ensureTimer($)
124    }
125
126    return next(e)
127  })
128
129  // Every row the conversation keeps (a prompt, a reply, a tool result) is a
130  // sign of life: it starts the timer.
131  on('session.append', async ($, e, next) => {
132    ensureTimer($)
133
134    return next(e)
135  })
136
137  on('prompt.submit', async ($, e, next) => {
138    facts.keyAt = NEVER
139    ensureTimer($)
140
141    return next(e)
142  })
143}
144
hooks/features/usage.ts 239 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On, SessionUsage, Timer } from 'claude-code'
3
4import type { Limit, Percent, Usd } from '../../types'
5import type { Meter } from '../meters'
6import { noteTurnStart } from './mascot'
7
8// Everything $.session.usage() reports (context fill, rate-limit windows,
9// cost) and the prompt cache's countdown, which runs off the same measurements.
10// One feature, because the engine takes one hook per event per plugin.
11const contextPercent = atom({ plugin: 'leo-mods', key: 'contextPercent' } as const, null as Percent)
12const limits = atom({ plugin: 'leo-mods', key: 'limits' } as const, [] as Limit[])
13const costUsd = atom({ plugin: 'leo-mods', key: 'costUsd' } as const, null as Usd)
14const cacheAt = atom({ plugin: 'leo-mods', key: 'cacheAt' } as const, null as number | null)
15const cacheTtlMs = atom({ plugin: 'leo-mods', key: 'cacheTtlMs' } as const, null as number | null)
16const cacheLeftMs = atom({ plugin: 'leo-mods', key: 'cacheLeftMs' } as const, null as number | null)
17const nowMs = atom({ plugin: 'leo-mods', key: 'nowMs' } as const, 0)
18
19// The cache timestamp also goes to $.store, per session, so a restarted or
20// resumed session still knows when its cache was last refreshed.
21const CACHE_STORE_KEY = 'cacheBySession'
22const CACHE_STORE_MAX = 50
23
24const CACHE_TICK_MS = 15_000
25const MINUTE = 60_000
26const DAY = 86_400_000
27const HOUR = 3_600_000
28const FIVE_MINUTES = 300_000
29
30const SHORT: Record<string, string> = { five_hour: '5h', seven_day: 'week', spend_limit: 'spend' }
31const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
32
33let cacheTimer: Timer | null = null
34let clockTimer: Timer | null = null
35let isTurnRunning = false
36
37// Time until a window resets, in a fixed width: "1h20m" under a day, "3d04h"
38// from a day; "0h00m" once it passed.
39export function resetLabel(ms: number) {
40  if (ms >= DAY) {
41    return `${Math.floor(ms / DAY)}d${String(Math.floor((ms % DAY) / HOUR)).padStart(2, '0')}h`
42  }
43
44  const left = Math.max(0, ms)
45
46  return `${Math.floor(left / HOUR)}h${String(Math.floor((left % HOUR) / MINUTE)).padStart(2, '0')}m`
47}
48
49// When a window resets, in local time: "Sat 9:00 PM".
50export function resetTimeLabel(at: number) {
51  const date = new Date(at)
52  const hours = date.getHours()
53
54  return `${WEEKDAYS[date.getDay()]} ${hours % 12 || 12}:${String(date.getMinutes()).padStart(2, '0')} ${hours < 12 ? 'AM' : 'PM'}`
55}
56
57// The percentages to draw as meters: context fill, then each rate-limit
58// window (only on a subscription) with when it resets: the time left for the
59// 5-hour window, the day and time for the weekly one. Each is left out until
60// it has a reading.
61export function usageMeters(context: Percent, windows: Limit[], now: number): Meter[] {
62  const meters: Meter[] = context === null ? [] : [{ name: 'context', percent: context }]
63
64  return [
65    ...meters,
66    ...windows.map(w => {
67      const resetsAt = w.resetsAt === undefined ? NaN : Date.parse(w.resetsAt)
68      const detail = Number.isNaN(resetsAt) ? undefined : w.kind === 'seven_day' ? resetTimeLabel(resetsAt) : resetLabel(resetsAt - now)
69
70      return { name: SHORT[w.kind] ?? w.kind, percent: w.percentUsed, detail }
71    }),
72  ]
73}
74
75// Keeps nowMs current to the minute, so reset countdowns tick between turns.
76async function tickClock($: EngineInterface) {
77  const now = await $.clock.now()
78  await update($, nowMs, () => now)
79
80  if (clockTimer === null) {
81    clockTimer = $.clock.every(MINUTE, () => void tickClock($))
82  }
83}
84
85// The prompt cache as a draining meter: "42m" (or "07m") left, or a red "cold"
86// once it lapsed.
87// Null before the first response, or with prompt caching off.
88export function cacheMeter(leftMs: number | null, ttlMs: number | null): Meter | null {
89  if (leftMs === null || ttlMs === null) {
90    return null
91  }
92
93  if (leftMs === 0) {
94    return { name: 'cache', percent: 0, text: 'cold', color: '#f87171' }
95  }
96
97  const fraction = leftMs / ttlMs
98  const minutes = Math.ceil(leftMs / 60_000)
99  const color = fraction > 0.4 ? '#34d399' : fraction > 0.15 ? '#fbbf24' : '#f87171'
100
101  return { name: 'cache', percent: fraction * 100, text: `${String(minutes).padStart(2, '0')}m`, color }
102}
103
104// The cost's color: green under $50, amber under $100, red from $100.
105export function costColor(total: number) {
106  if (total >= 100) {
107    return '#f87171'
108  }
109
110  return total >= 50 ? '#fbbf24' : '#4ade80'
111}
112
113// "$0.00", "$1.09": always cents, so the line doesn't shift.
114export function costLabel(total: number) {
115  return `$${total.toFixed(2)}`
116}
117
118// The main conversation's cache TTL, decided the way Claude Code decides it:
119// the env var, then the promptCacheTtl setting, then 1 hour on a subscription
120// within its limits and 5 minutes otherwise. Null with caching off.
121async function cacheTtl($: EngineInterface, windows: Limit[]) {
122  const fromEnv = await $.env.get('CLAUDE_CODE_PROMPT_CACHE_TTL')
123
124  if (fromEnv === '1h' || fromEnv === '5m') {
125    return fromEnv === '1h' ? HOUR : FIVE_MINUTES
126  }
127
128  if (await $.env.get('DISABLE_PROMPT_CACHING')) {
129    return null
130  }
131
132  const settings = (await $.settings.read()) as { promptCacheTtl?: string }
133
134  if (settings.promptCacheTtl === '1h' || settings.promptCacheTtl === '5m') {
135    return settings.promptCacheTtl === '1h' ? HOUR : FIVE_MINUTES
136  }
137
138  if (await $.env.get('FORCE_PROMPT_CACHING_5M')) {
139    return FIVE_MINUTES
140  }
141
142  const isSubscription = windows.length > 0 && windows.every(w => w.percentUsed < 100)
143
144  return isSubscription ? HOUR : FIVE_MINUTES
145}
146
147// Recomputes the time left, and keeps a timer running while it's warm.
148async function tickCache($: EngineInterface) {
149  const at = await read($, cacheAt)
150  const ttl = await read($, cacheTtlMs)
151
152  if (at === null || ttl === null) {
153    return
154  }
155
156  const left = isTurnRunning ? ttl : Math.max(0, at + ttl - (await $.clock.now()))
157  await update($, cacheLeftMs, () => left)
158
159  if (left > 0 && cacheTimer === null) {
160    cacheTimer = $.clock.every(CACHE_TICK_MS, () => void tickCache($))
161  } else if (left === 0) {
162    cacheTimer?.cancel()
163    cacheTimer = null
164  }
165}
166
167type CacheRecord = { at: number; ttl: number | null }
168
169async function saveCache($: EngineInterface, record: CacheRecord) {
170  const id = await $.session.id()
171  const saved = ((await $.store.get(CACHE_STORE_KEY)) ?? {}) as Record<string, CacheRecord>
172  const kept = Object.entries(saved).filter(([key]) => key !== id).slice(-(CACHE_STORE_MAX - 1))
173  await $.store.set(CACHE_STORE_KEY, Object.fromEntries([...kept, [id, record]]))
174}
175
176// After a restart or resume, picks the cache timestamp back up from $.store.
177async function loadCache($: EngineInterface) {
178  if ((await read($, cacheAt)) !== null) {
179    return
180  }
181
182  const saved = ((await $.store.get(CACHE_STORE_KEY)) ?? {}) as Record<string, CacheRecord>
183  const record = saved[await $.session.id()]
184
185  if (record !== undefined) {
186    await update($, cacheAt, () => record.at)
187    await update($, cacheTtlMs, () => record.ttl)
188  }
189}
190
191async function store($: EngineInterface, usage: Omit<SessionUsage, 'startedAt'>) {
192  const percent = usage.context.percent ?? null
193  const windows: Limit[] = usage.rateLimits.map(w => ({ ...w }))
194  const total = usage.cost?.usd ?? null
195
196  await update($, contextPercent, () => percent)
197  await update($, limits, () => windows)
198  await update($, costUsd, () => total)
199}
200
201export function registerUsage(on: On) {
202  on('session.start', async ($, e, next) => {
203    const result = await next(e)
204    await store($, await $.session.usage())
205    await tickClock($)
206    await loadCache($)
207    await tickCache($)
208
209    return result
210  })
211
212  on('turn.start', async ($, e, next) => {
213    isTurnRunning = true
214    noteTurnStart()
215    await tickCache($)
216
217    return next(e)
218  })
219
220  // Fires after each main-thread turn: a context change means the main
221  // conversation got a response, which restarts the cache's TTL.
222  on('session.measure', async ($, e, next) => {
223    isTurnRunning = false
224    await store($, e)
225    await tickClock($)
226
227    if (e.changed.includes('context')) {
228      const now = await $.clock.now()
229      const ttl = await cacheTtl($, e.rateLimits.map(w => ({ ...w })))
230      await update($, cacheAt, () => now)
231      await update($, cacheTtlMs, () => ttl)
232      await saveCache($, { at: now, ttl })
233      await tickCache($)
234    }
235
236    return next(e)
237  })
238}
239
hooks/mascot.ts 319 lines
1// The pixel dog at the desktop line's left. The desktop draws pictures as SVG
2// only, and a plain image is transparent but doesn't play SMIL, so she's a
3// pixel sprite of colored squares and the animation is a few frames swapped on
4// a timer (features/mascot.ts). The head is drawn in code (ears, face, muzzle,
5// eyes, brows, nose, mouth) with an automatic outline; a frame is a look, a
6// pair of ears, a shift and a few loose pixels (a "?", hearts, zzz).
7
8import type { Mood } from '../types'
9
10export const MOOD_LABEL: Record<Mood, string> = {
11  idle: 'dozing',
12  typing: 'listening to you type',
13  working: 'working',
14  done: 'cheering, the turn is done',
15}
16
17// The head is drawn into a 34×26 grid, set into a 40×30 board: three cells
18// each side for the ears' room and the loose pixels, two above and below.
19const HEAD_COLS = 34
20const HEAD_ROWS = 26
21const MARGIN_X = 3
22const MARGIN_Y = 2
23const COLS = HEAD_COLS + 2 * MARGIN_X
24const ROWS = HEAD_ROWS + 2 * MARGIN_Y
25const PIXEL = 1.3
26export const MASCOT_WIDTH = Math.round(COLS * PIXEL)
27export const MASCOT_HEIGHT = Math.round(ROWS * PIXEL)
28
29const COLORS: Record<string, string> = {
30  o: '#3b2a1e', // outline
31  P: '#e9b57d', // fur
32  L: '#f6d3a8', // fur shine
33  Q: '#b9783f', // eye patch
34  V: '#9a5f35', // ears
35  U: '#c98a5a', // inner ears
36  C: '#fff1de', // muzzle, blaze
37  b: '#ffb3a7', // blush
38  E: '#2a1f2d', // pupils, nose, lashes
39  I: '#7a4d2a', // iris
40  B: '#4a2e1a', // brows
41  w: '#ffffff', // shine
42  M: '#a8344f', // open mouth
43  T: '#ff8fa3', // tongue
44  y: '#f6b94d', // sparkles, the "?"
45  r: '#fb7185', // hearts
46  c: '#7dd3fc', // sweat
47  z: '#9b72e0', // zzz and dots
48  d: '#6b6f85', // dim dots
49}
50
51type Grid = string[][]
52const blank = (cols: number, rows: number): Grid => Array.from({ length: rows }, () => Array.from({ length: cols }, () => '.'))
53const put = (grid: Grid, x: number, y: number, ch: string) => {
54  if (grid[y]?.[x] !== undefined) {
55    grid[y]![x] = ch
56  }
57}
58
59// A rounded square: |dx/a|^n + |dy/b|^n <= 1, for the face and the ears.
60const inside = (x: number, y: number, cx: number, cy: number, a: number, b: number, n = 2) =>
61  Math.abs((x - cx) / a) ** n + Math.abs((y - cy) / b) ** n <= 1
62
63type Eyes = 'open' | 'wide' | 'narrow' | 'blink' | 'happy' | 'sleep'
64type Brows = 'none' | 'raised' | 'worried' | 'focus' | 'think'
65type Mouth = 'smile' | 'grin' | 'tongue' | 'yawn' | 'o' | 'pantA' | 'pantB' | 'sad' | 'flat' | 'tiny'
66type Dot = [x: number, y: number, color: string]
67type Frame = { eyes: Eyes; wink?: boolean; look?: [number, number]; brows?: Brows; mouth: Mouth; ears?: number | [left: number, right: number]; dx?: number; dy?: number; dots?: Dot[] }
68
69const CX = 17 // the face's center column
70const EYE_Y = 12
71const EYE_X = [11, 23]
72
73// An eye around (cx, EYE_Y): brown iris, dark pupil that follows `look`, and
74// a shine; or a stroke when it is shut.
75function drawEye(grid: Grid, cx: number, eyes: Eyes, look: [number, number]) {
76  const arch = (dx: number, height: number) => Math.round(height * (1 - (dx / 3.4) ** 2))
77
78  if (eyes === 'blink') {
79    for (let dx = -3; dx <= 3; dx++) {
80      put(grid, cx + dx, EYE_Y + 2, 'E')
81    }
82
83    return
84  }
85
86  if (eyes === 'happy' || eyes === 'sleep') {
87    for (let dx = -3; dx <= 3; dx++) {
88      const y = eyes === 'happy' ? EYE_Y + 1 - arch(dx, 2) : EYE_Y + arch(dx, 2)
89      put(grid, cx + dx, y, 'E')
90      put(grid, cx + dx, y + 1, 'E')
91    }
92
93    return
94  }
95
96  const isNarrow = eyes === 'narrow'
97  const isWide = eyes === 'wide'
98  const [lx, ly] = look
99  const [ry, py] = isNarrow ? [2.5, 1] : isWide ? [4.5, 0] : [3.7, 0]
100  const [pry, pcy] = isNarrow ? [1.7, 1] : isWide ? [3.5, 0] : [2.9, 0]
101
102  for (let dy = -5; dy <= 5; dy++) {
103    for (let dx = -4; dx <= 4; dx++) {
104      if (inside(dx, dy, 0, py, 3.2, ry)) {
105        put(grid, cx + dx, EYE_Y + dy, 'I')
106      }
107
108      if (inside(dx, dy, lx, pcy + ly * (isNarrow ? 0 : 1), 2.3, pry)) {
109        put(grid, cx + dx, EYE_Y + dy, 'E')
110      }
111    }
112  }
113
114  if (isNarrow) {
115    for (let dx = -3; dx <= 3; dx++) {
116      put(grid, cx + dx, EYE_Y - 1, 'E')
117    }
118  }
119
120  const [sx, sy] = [cx + lx, EYE_Y + (isNarrow ? 1 : ly)]
121  const shine: [number, number][] = isNarrow ? [[-1, 0], [0, 0]] : isWide ? [[-1, -3], [0, -3], [-1, -2], [0, -2], [-1, -1], [1, 2], [1, 1]] : [[-1, -2], [0, -2], [-1, -1], [0, -1], [1, 2]]
122
123  for (const [dx, dy] of shine) {
124    put(grid, sx + dx, sy + dy, 'w')
125  }
126}
127
128// The left eye's brow as offsets from the eye; the inner end is at +dx, and
129// the right eye mirrors it.
130const BROWS: Record<Exclude<Brows, 'none' | 'think'>, [number, number][]> = {
131  raised: [[-2, -6], [-1, -7], [0, -7], [1, -7], [2, -6]],
132  worried: [[-2, -5], [-1, -6], [0, -6], [1, -7], [2, -8]],
133  focus: [[-2, -8], [-1, -7], [0, -7], [1, -6], [2, -5]],
134}
135
136function drawBrows(grid: Grid, brows: Brows) {
137  if (brows === 'none') {
138    return
139  }
140
141  EYE_X.forEach((cx, side) => {
142    const shape = brows === 'think' ? (side === 0 ? BROWS.raised : [[-2, -5], [-1, -5], [0, -5], [1, -5], [2, -5]] as [number, number][]) : BROWS[brows]
143
144    for (const [dx, dy] of shape) {
145      put(grid, cx + (side === 0 ? dx : -dx), EYE_Y + dy, 'B')
146    }
147  })
148}
149
150// The mouth hangs from the nose: a stem, then a line or an open mouth, as
151// [offset from the center column, row, color].
152const MOUTHS: Record<Mouth, [number, number, string][]> = {
153  smile: [[0, 21, 'E'], [0, 22, 'E'], [-2, 23, 'E'], [-1, 23, 'E'], [1, 23, 'E'], [2, 23, 'E']],
154  tongue: [[0, 21, 'E'], [-2, 22, 'E'], [-1, 22, 'E'], [0, 22, 'T'], [1, 22, 'E'], [2, 22, 'E'], ...[-1, 0, 1].map((x): [number, number, string] => [x, 23, 'T'])],
155  yawn: [[0, 21, 'E'], ...[-2, -1, 0, 1, 2].map((x): [number, number, string] => [x, 22, 'M']), ...[-3, -2, -1, 0, 1, 2, 3].map((x): [number, number, string] => [x, 23, 'M']), ...[-2, -1, 0, 1, 2].map((x): [number, number, string] => [x, 24, Math.abs(x) <= 1 ? 'T' : 'M'])],
156  grin: [[0, 21, 'E'], ...[-2, -1, 0, 1, 2].flatMap((x): [number, number, string][] => [[x, 22, 'M'], [x, 23, 'M']]), ...[-1, 0, 1].map((x): [number, number, string] => [x, 24, 'T']), [0, 25, 'T']],
157  o: [[0, 21, 'E'], ...[-1, 0, 1].flatMap((x): [number, number, string][] => [[x, 22, 'M'], [x, 23, 'M']])],
158  pantA: [[0, 21, 'E'], ...[-2, -1, 0, 1, 2].map((x): [number, number, string] => [x, 22, 'M']), ...[-1, 0, 1].flatMap((x): [number, number, string][] => [[x, 23, 'T'], [x, 24, 'T']])],
159  pantB: [[0, 21, 'E'], ...[-2, -1, 0, 1, 2].map((x): [number, number, string] => [x, 22, 'M']), ...[-1, 0, 1].flatMap((x): [number, number, string][] => [[x, 23, 'T'], [x, 24, 'T'], [x, 25, 'T']])],
160  sad: [[0, 21, 'E'], [-1, 22, 'E'], [1, 22, 'E'], [-2, 23, 'E'], [2, 23, 'E'], [-3, 24, 'E'], [3, 24, 'E']],
161  flat: [[0, 21, 'E'], [-2, 22, 'E'], [-1, 22, 'E'], [0, 22, 'E'], [1, 22, 'E'], [2, 22, 'E']],
162  tiny: [[0, 21, 'E'], [-1, 22, 'E'], [0, 22, 'E'], [1, 22, 'E']],
163}
164
165// The whole head: floppy ears behind (`earDy` lifts or drops them), the face,
166// an eye patch, a blaze and a muzzle, then eyes, brows, nose, blush and mouth,
167// and the outline around it all.
168function drawHead(frame: Frame): Grid {
169  const grid = blank(HEAD_COLS, HEAD_ROWS)
170  const [earLeft, earRight] = Array.isArray(frame.ears) ? frame.ears : [frame.ears ?? 0, frame.ears ?? 0]
171
172  for (let y = 0; y < HEAD_ROWS; y++) {
173    for (let x = 0; x < HEAD_COLS; x++) {
174      const [px, py] = [x + 0.5, y + 0.5]
175
176      if (inside(px, py, 5, 13.5 + earLeft, 4.3, 9.3) || inside(px, py, 29, 13.5 + earRight, 4.3, 9.3)) {
177        grid[y]![x] = inside(px, py, 3, 14.5 + earLeft, 1.7, 6.3) || inside(px, py, 31, 14.5 + earRight, 1.7, 6.3) ? 'U' : 'V'
178      }
179
180      if (inside(px, py, CX, 14, 12.2, 11.4, 2.6)) {
181        const isShine = ((px - 12) / 3.5) ** 2 + ((py - 5) / 1.2) ** 2 <= 1
182        const isPatch = inside(px, py, 23, 12, 5, 5, 2)
183        const isBlaze = Math.abs(px - CX) <= 1 + (py - 2) * 0.12 && py < 14
184        grid[y]![x] = isBlaze ? 'C' : isPatch ? 'Q' : isShine ? 'L' : 'P'
185      }
186
187      if (inside(px, py, CX, 20, 7.6, 5.4, 2.2) && grid[y]![x] !== '.' && grid[y]![x] !== 'V' && grid[y]![x] !== 'U') {
188        grid[y]![x] = 'C'
189      }
190    }
191  }
192
193  for (const cx of [7, 27]) {
194    for (let dx = -2; dx <= 2; dx++) {
195      if (grid[17]?.[cx + dx] === 'P') {
196        grid[17]![cx + dx] = 'b'
197      }
198    }
199  }
200
201  EYE_X.forEach((cx, side) => drawEye(grid, cx, frame.wink === true && side === 1 ? 'happy' : frame.eyes, frame.look ?? [0, 0]))
202
203  drawBrows(grid, frame.brows ?? 'none')
204
205  for (const [dx, dy] of [[-2, 0], [-1, 0], [0, 0], [1, 0], [2, 0], [-1, 1], [0, 1], [1, 1], [0, 2]] as const) {
206    put(grid, CX + dx, 17 + dy, 'E')
207  }
208
209  put(grid, CX - 1, 17, 'w')
210
211  for (const [dx, y, ch] of MOUTHS[frame.mouth]) {
212    put(grid, CX + dx, y, ch)
213  }
214
215  const filled = grid.map(row => [...row])
216
217  for (let y = 0; y < HEAD_ROWS; y++) {
218    for (let x = 0; x < HEAD_COLS; x++) {
219      const isEmpty = filled[y]![x] === '.'
220      const hasNeighbor = [[1, 0], [-1, 0], [0, 1], [0, -1]].some(([dx, dy]) => (filled[y + dy!]?.[x + dx!] ?? '.') !== '.')
221
222      if (isEmpty && hasNeighbor) {
223        grid[y]![x] = 'o'
224      }
225    }
226  }
227
228  return grid
229}
230
231// Loose pixels live in the board's right margin (cells 37-39) and its corners.
232const shape = (rows: string[], x: number, y: number, color: string): Dot[] =>
233  rows.flatMap((row, dy) => [...row].flatMap((ch, dx): Dot[] => (ch === '#' ? [[x + dx, y + dy, color]] : [])))
234
235const HEART = ['#.#', '###', '.#.']
236const Z = ['###', '.#.', '###']
237const DROP = ['.#.', '###', '###', '.#.']
238const PLUS = ['.#.', '###', '.#.']
239const BUBBLE = ['.##.', '#..#', '#..#', '.##.']
240
241const hearts = (step: number) => [...shape(HEART, 37, 14 - 3 * (step % 4), 'r'), ...shape(HEART, 0, 8 - 2 * ((step + 2) % 4), 'r')]
242const sparkle = (step: number) => (step % 2 === 0 ? [...shape(PLUS, 0, 3, 'y'), ...shape(PLUS, 37, 10, 'y')] : [...shape(PLUS, 1, 1, 'y'), ...shape(PLUS, 36, 12, 'y')])
243const drop = (fall: number) => shape(DROP, 37, 5 + fall * 3, 'c')
244const thought = (lit: number) => [[38, 9, 1], [37, 5, 2], [37, 0, 3]].flatMap(([x, y, size], i) => shape(Array.from({ length: size! }, () => '#'.repeat(size!)), x!, y!, i === lit ? 'z' : 'd'))
245const typingDots = (n: number) => Array.from({ length: n }, (_, i) => shape(['##', '##'], 37, 8 - i * 3, 'z')).flat()
246const bubble = (step: number) => (step === 1 ? shape(['##', '##'], 24, 21, 'c') : step === 2 ? shape(BUBBLE, 24, 20, 'c') : [])
247const zees = (step: number) => [...shape(Z, 37, 9 - (step % 2), 'z'), ...(step >= 2 ? shape(Z, 36, 3, 'z') : [])]
248
249// Each mood's loop, one frame per FRAME_TICKS of the timer (features/mascot.ts).
250
251export const FRAMES: Record<Mood, Frame[]> = {
252  // Dozing: eyes shut, slow breathing, zzz and a snot bubble.
253  idle: [0, 1, 2, 3, 4, 5].map((i): Frame => ({ eyes: 'sleep', mouth: i % 3 === 2 ? 'flat' : 'tiny', ears: 3, dy: i < 3 ? 0 : 1, dots: [...zees(i % 4), ...bubble(i % 3)] })),
254  // Listening: one ear straight up to hear, eyes up and thoughtful.
255  typing: [0, 1, 2, 3, 4, 5, 6, 7].map((i): Frame => ({
256    eyes: i === 6 ? 'blink' : 'open',
257    look: i < 4 ? [-1, -1] : [1, -1],
258    brows: 'think',
259    mouth: i % 4 === 3 ? 'tiny' : 'flat',
260    dx: i < 4 ? 0 : 1,
261    ears: [-4, 0],
262    dots: thought(i % 4),
263  })),
264  working: [0, 1, 2, 3, 4, 5].map((i): Frame => ({ eyes: 'narrow', look: [[-1, 0, 1, 0, -1, 1][i]!, 0], brows: 'focus', mouth: i % 2 === 0 ? 'pantA' : 'pantB', dy: i % 2, ears: i % 2 === 0 ? 0 : 2, dots: [...sparkle(i), ...(i % 3 === 2 ? drop(i % 2) : [])] })),
265  done: [0, 1, 2, 3, 4, 5, 6, 7].map((i): Frame => ({ eyes: 'happy', mouth: i % 4 < 2 ? 'grin' : 'tongue', ears: i % 2 === 0 ? -1 : 3, dy: [0, -3, -5, -3, 0, -3, -5, -3][i]!, dx: i % 4 === 1 ? -1 : i % 4 === 3 ? 1 : 0, dots: [...hearts(i), ...sparkle(i)] })),
266}
267
268// Ticks (features/mascot.ts) each frame of a mood stays: the lively moods step
269// fast, a sleeping dog breathes slowly.
270export const FRAME_TICKS: Record<Mood, number> = {
271  idle: 3,
272  typing: 2,
273  working: 1,
274  done: 1,
275}
276
277// The frame on the board as rows of cell codes.
278function rowsOf(frame: Frame) {
279  const board = blank(COLS, ROWS)
280
281  drawHead(frame).forEach((row, y) => row.forEach((ch, x) => ch !== '.' && put(board, x + MARGIN_X + (frame.dx ?? 0), y + MARGIN_Y + (frame.dy ?? 0), ch)))
282
283  for (const [x, y, key] of frame.dots ?? []) {
284    put(board, x, y, key)
285  }
286
287  return board
288}
289
290// One frame as an SVG: runs of one color in a row are one rect; transparent
291// where there is no pixel.
292export function svgMascot(mood: Mood, index: number) {
293  const frames = FRAMES[mood]
294  const rects: string[] = []
295
296  rowsOf(frames[index % frames.length]!).forEach((row, y) => {
297    for (let x = 0; x < row.length; ) {
298      const ch = row[x]!
299      let end = x
300
301      while (row[end + 1] === ch) {
302        end += 1
303      }
304
305      if (COLORS[ch] !== undefined) {
306        rects.push(`<rect x="${+(x * PIXEL).toFixed(2)}" y="${+(y * PIXEL).toFixed(2)}" width="${+((end - x + 1) * PIXEL).toFixed(2)}" height="${+PIXEL.toFixed(2)}" fill="${COLORS[ch]}"/>`)
307      }
308
309      x = end + 1
310    }
311  })
312
313  return (
314    `<svg xmlns="http://www.w3.org/2000/svg" width="${MASCOT_WIDTH}" height="${MASCOT_HEIGHT}" viewBox="0 0 ${+(COLS * PIXEL).toFixed(2)} ${+(ROWS * PIXEL).toFixed(2)}" shape-rendering="crispEdges">` +
315    rects.join('') +
316    `</svg>`
317  )
318}
319
hooks/meters.ts 155 lines
1// How the line is drawn on the desktop: SVG throughout, so every size is ours
2// (the app's Text has one font size). The summary (cost over active time and
3// agents) at the left; one SVG per meter, its name (left, in the bar's color)
4// and reset time (right) over a rounded bar with the number inside. In the
5// terminal a meter is a row of blocks. Each meter has its own color until it
6// gets high, then amber, then red.
7
8// `text` replaces the drawn percentage and `color` the level color, for a
9// meter that isn't a usage percentage (the cache countdown). `detail` is drawn
10// dim at the label's right end (when a rate-limit window resets).
11export type Meter = { name: string; percent: number; text?: string; color?: string; detail?: string }
12
13// Every size below is a base size times SCALE: change SCALE to grow or shrink
14// the whole line at once.
15const SCALE = 0.95
16const px = (base: number) => Math.round(base * SCALE * 10) / 10
17
18// A desktop meter keeps its size, placeholder or not, so the line never moves:
19// wide enough for "week Sat 12:00 PM" over the bar; cache and context, with
20// no reset time, narrower.
21const WIDTH = px(116)
22const NARROW: Record<string, number> = { cache: px(81), context: px(81) }
23const LABEL_PX = px(11)
24const LABEL_HEIGHT = px(14)
25const GAP = px(3)
26const BAR_HEIGHT = px(13)
27const NUMBER_PX = px(9.5)
28const HEIGHT = LABEL_HEIGHT + GAP + BAR_HEIGHT
29// The summary's two rows, as tall as a meter so the line's edges align.
30const SUMMARY_PX = px(13)
31const SUMMARY_CHAR_PX = px(7.4)
32const CELLS = 5
33const FONT = '-apple-system, BlinkMacSystemFont, system-ui, sans-serif'
34// The reset time's gray, secondary on dark and light backgrounds alike: the
35// SVG can't follow the app's theme.
36const LABEL_COLOR = '#8e8e93'
37const NUMBER_ON_FILL = '#111827'
38
39// Soft 400-weight hues that read on both dark and light backgrounds.
40const COLORS: Record<string, string> = {
41  context: '#60a5fa',
42  '5h': '#a78bfa',
43  week: '#2dd4bf',
44}
45
46// Active time's color: a soft pink, apart from every meter's hue.
47export const ACTIVE_COLOR = '#f9a8d4'
48
49const WARN = '#fbbf24'
50const DANGER = '#f87171'
51
52function meterColor(name: string, percent: number) {
53  if (percent >= 80) {
54    return DANGER
55  }
56
57  return percent >= 50 ? WARN : (COLORS[name] ?? '#9ca3af')
58}
59
60// Two digits ("05%"), so the line doesn't shift as the numbers change.
61export function meterText(meter: Meter) {
62  return meter.text ?? `${String(Math.round(meter.percent)).padStart(2, '0')}%`
63}
64
65function escapeXml(text: string) {
66  return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
67}
68
69function svgText(text: string, attributes: string) {
70  return `<text font-family="${FONT}" style="font-variant-numeric: tabular-nums" ${attributes}>${escapeXml(text)}</text>`
71}
72
73// The meter as one drawing: the label row (the name in the bar's color), then the bar with its number
74// centered inside, in the bar's color over the empty track and near-black
75// over the fill (clipped to it), so it reads on both.
76export function svgMeter(meter: Meter) {
77  const { width: WIDTH } = meterSize(meter)
78  const p = Math.min(100, Math.max(0, meter.percent))
79  const fill = p === 0 ? 0 : Math.max(BAR_HEIGHT, (WIDTH * p) / 100)
80  const r = BAR_HEIGHT / 2
81  const top = LABEL_HEIGHT + GAP
82  const color = meter.color ?? meterColor(meter.name, p)
83  const id = `fill-${meter.name.replace(/[^a-z0-9]/gi, '')}`
84  const label = (fillColor: string) => `font-size="${LABEL_PX}" y="${LABEL_HEIGHT - px(3)}" fill="${fillColor}"`
85  const number = (fillColor: string) =>
86    svgText(meterText(meter), `x="${WIDTH / 2}" y="${top + r}" dy="0.35em" text-anchor="middle" font-size="${NUMBER_PX}" font-weight="600" fill="${fillColor}"`)
87
88  return (
89    `<svg xmlns="http://www.w3.org/2000/svg" width="${WIDTH}" height="${HEIGHT}" viewBox="0 0 ${WIDTH} ${HEIGHT}">` +
90    `<defs><clipPath id="${id}"><rect y="${top}" width="${fill.toFixed(1)}" height="${BAR_HEIGHT}" rx="${r}"/></clipPath></defs>` +
91    svgText(meter.name, `x="1" font-weight="600" ${label(color)}`) +
92    (meter.detail === undefined ? '' : svgText(meter.detail, `x="${WIDTH - 1}" text-anchor="end" ${label(LABEL_COLOR)}`)) +
93    `<rect y="${top}" width="${WIDTH}" height="${BAR_HEIGHT}" rx="${r}" fill="${color}" fill-opacity="0.18"/>` +
94    number(color) +
95    `<g clip-path="url(#${id})"><rect y="${top}" width="${WIDTH}" height="${BAR_HEIGHT}" fill="${color}"/>${number(NUMBER_ON_FILL)}</g>` +
96    `</svg>`
97  )
98}
99
100// The summary at the line's left: the cost (bold, in its color) over the
101// active time (pink) and running agents (gray). Its width follows its longer
102// row; text runs from the left edge, so a generous width costs nothing.
103export type Summary = { cost: string; costColor: string; active: string; agents: string | null }
104
105export function summaryText(summary: Summary) {
106  return [summary.cost, summary.active, summary.agents].filter(Boolean).join(' · ')
107}
108
109export function summarySize(summary: Summary) {
110  const first = summary.cost.length
111  const second = summary.active.length + (summary.agents === null ? 0 : summary.agents.length + 3)
112
113  return { width: Math.ceil(Math.max(first, second) * SUMMARY_CHAR_PX) + 2, height: HEIGHT }
114}
115
116export function svgSummary(summary: Summary) {
117  const { width } = summarySize(summary)
118  const row = `font-size="${SUMMARY_PX}" x="0"`
119  const agents = summary.agents === null ? '' : `<tspan fill="${LABEL_COLOR}"> · ${escapeXml(summary.agents)}</tspan>`
120
121  return (
122    `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${HEIGHT}" viewBox="0 0 ${width} ${HEIGHT}">` +
123    `<text font-family="${FONT}" style="font-variant-numeric: tabular-nums" ${row} y="${HEIGHT / 2 - 2}">` +
124    `<tspan font-weight="700" fill="${summary.costColor}">${escapeXml(summary.cost)}</tspan></text>` +
125    `<text font-family="${FONT}" style="font-variant-numeric: tabular-nums" ${row} y="${HEIGHT - 2}" fill="${ACTIVE_COLOR}">${escapeXml(summary.active)}${agents}</text>` +
126    `</svg>`
127  )
128}
129
130// What the drawing says, for a reader that can't see it.
131export function meterAlt(meter: Meter) {
132  return [meter.name, meterText(meter), meter.detail].filter(Boolean).join(' ')
133}
134
135export function textMeter(percent: number) {
136  const filled = Math.round((Math.min(100, Math.max(0, percent)) / 100) * CELLS)
137
138  return '▰'.repeat(filled) + '▱'.repeat(CELLS - filled)
139}
140
141export function meterSize(meter: Meter) {
142  return { width: NARROW[meter.name] ?? WIDTH, height: HEIGHT }
143}
144
145// Until the first response (a new session, before its first turn) the line
146// keeps its shape: the same meters at the same size, with empty bars, zeroed
147// numbers and a $0.00 cost.
148export const PLACEHOLDER_METERS: Meter[] = [
149  { name: 'cache', percent: 0, text: '00m', color: '#34d399' },
150  { name: 'context', percent: 0 },
151  { name: '5h', percent: 0, detail: '0h00m' },
152  { name: 'week', percent: 0, detail: '--- -:-- --' },
153]
154
155
types/index.d.ts 26 lines
1export type Mood = 'idle' | 'typing' | 'working' | 'done'
2
3export type Usd = number | null
4
5export type Percent = number | null
6
7export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
8
9declare module 'claude-code' {
10  interface PluginState {
11    'leo-mods': {
12      costUsd: Usd
13      contextPercent: Percent
14      limits: Limit[]
15      runningAgents: number
16      activeMs: number
17      cacheAt: number | null
18      cacheTtlMs: number | null
19      cacheLeftMs: number | null
20      nowMs: number
21      mascotMood: Mood
22      mascotFrame: number
23    }
24  }
25}
26