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

Leo's personal mods for Claude Code (terminal and the desktop app's Code tab), packaged as one plugin with the ID leo-mods.
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:

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.
| Part | What it shows | File |
|---|---|---|
active 12m | Time 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 agents | Subagents running right now; hidden when none are. | hooks/features/activity.ts |
cache | How 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 |
context | How full the context window is. Near 100%, the conversation gets compacted. | hooks/features/usage.ts |
5h / week | How 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.13 | The 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.
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
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.
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)
See the Claude Code docs on mods: https://code.claude.com/docs/en/plugins/mods
hooks/register.tsx 109 lines1import { 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}
109hooks/features/activity.ts 69 lines1import { 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}
69hooks/features/mascot.ts 144 lines1import { 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}
144hooks/features/usage.ts 239 lines1import { 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}
239hooks/mascot.ts 319 lines1// 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}
319hooks/meters.ts 155 lines1// 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, '&').replace(/</g, '<').replace(/>/g, '>')
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
155types/index.d.ts 26 lines1export 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