Session panel: context and usage limits, model, mode, agents and notifications, beside the transcript

Mods for Claude Code, built on its function-hook plugin API: they draw inside the terminal and react to what Claude is doing.
Installing with an AI assistant: tell Claude Code "Install the pixel-witch mod from https://github.com/Kyubey64/claude-code-mods". The steps it follows are in INSTALL.md.
A chibi pixel-art witch who lives in your terminal.
! when one fails, a happy hop when a reply lands, and a nap when things go quiet.classic (round chibi), starlight (huge floppy hat, glowing star, cloud of lavender hair), moonbroom (rides a broom side-saddle with a cat), bluebell (super-deformed, giant eyes, gem staff, cat on her shoulder), goldie (tall and slender, long golden hair, upright broom).Commands: /witch, /witch skin <name>, /witch summon, /witch fight.
A panel docked on the right while the prompt stays at the bottom:
On narrow or non-fullscreen terminals it falls back to a one-line status bar. Commands: /hud, /usage-ping (ping now), /usage-ping off / on (automatic pings in every console).
Each mod is a plugin folder (.claude-plugin/plugin.json, hooks/, types/). Check one with:
claude plugin validate mods/pixel-witch
claude plugin test mods/pixel-witch
The witch styles are drawn in tools/witch_styles.py, which renders a preview sheet and regenerates mods/pixel-witch/hooks/styles.ts.
hooks/register.tsx 415 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
3
4import { crossed, limitName, meter, shortModel, span, statusLine, tokens } from './format'
5import type { Extras } from './format'
6import { PINGS_OFF_KEY, SHARED_KEY, asShared, freshest, isPaused, isPingDue, nextIdle, pickLimits, sameLimits } from './fresh'
7import type { Shared } from './fresh'
8import { addNote, agentRows, clock, levelColor, modeFrom, restOfHint, statusMark } from './panel'
9import type { Hud } from '../types'
10
11const PANE = 'session-hud'
12const REFRESH_MS = 30_000
13const PANEL_MS = 1000
14const PUSH_GAP_MS = 15_000
15
16const hud = atom({ plugin: 'usage-bar', key: 'hud' } as const, {
17 limits: [],
18 mode: 'default',
19 modes: [],
20 isWorking: false,
21 agents: [],
22 notes: [],
23} as Hud)
24
25const extras: Extras = {}
26const warned = new Map<string, number>()
27const ping = { isOn: true, everyMs: 10 * 60_000, isRunning: false }
28// When this session last got usage figures of its own, what brought them (a
29// model response is `use`), and the idle-ping streak they carry.
30let ownAt = 0
31let ownSource: 'use' | 'ping' = 'use'
32let ownIdle = 0
33let pushedAt = 0
34let pushed: SessionRateLimit[] = []
35
36// The panel: whether it is docked beside the transcript (then the prompt's
37// footer lines and toasts move into it), and what it shows.
38const panel = { isDocked: false, view: undefined as Hud | undefined }
39// Whether the pings are off in every console, as the store last said.
40let arePingsOff = false
41let pending: Partial<Hud> = {}
42let notes: Hud['notes'] = []
43let agentStatus = new Map<string, string>()
44
45async function notify($: EngineInterface, text: string, from?: string) {
46 if (panel.isDocked) {
47 notes = addNote(notes, { at: clock(await $.clock.now()), text, ...(from ? { from } : {}) })
48 } else {
49 $.ui.toast(text, { timeoutMs: 8000 })
50 }
51}
52
53// Reads the figures, shares this session's, and updates the line or the
54// panel; resolves the reading the store held and when the freshest reading
55// from any source came in.
56async function refresh($: EngineInterface): Promise<{ shared: Shared | undefined; lastFresh: number }> {
57 const [usage, now, model, stored, off] = await Promise.all([
58 $.session.usage(),
59 $.clock.now(),
60 $.session.model(),
61 $.store.get(SHARED_KEY),
62 $.store.get(PINGS_OFF_KEY),
63 ])
64 arePingsOff = off === true
65 const shared = asShared(stored)
66 extras.model = model
67 extras.startedAt = usage.startedAt
68
69 const own = usage.rateLimits
70 const isNewer = ownAt > (shared?.at ?? 0)
71 const isWorthWriting = ownAt - pushedAt >= PUSH_GAP_MS || !sameLimits(own, pushed)
72 if (own.length > 0 && isNewer && ownAt > pushedAt && isWorthWriting) {
73 await $.store.set(SHARED_KEY, { at: ownAt, rateLimits: own, source: ownSource, idlePings: ownIdle })
74 pushedAt = ownAt
75 pushed = own
76 }
77
78 const rateLimits = pickLimits(own, ownAt, shared)
79 const latest: Shared | undefined = isNewer
80 ? { at: ownAt, rateLimits: own, source: ownSource, idlePings: ownIdle }
81 : shared
82 const isIdle = ping.isOn && !arePingsOff && isPaused(latest)
83 extras.note = arePingsOff ? 'pings off' : isIdle ? 'ping paused (idle)' : undefined
84 $.ui.status(panel.isDocked ? undefined : statusLine({ ...usage, rateLimits }, extras, now))
85
86 const fromElsewhere = !isNewer && shared !== undefined && shared.at > ownAt
87 pending = {
88 ...pending,
89 context: usage.context,
90 limits: rateLimits.map(limit => ({
91 name: limitName(limit.kind),
92 percent: limit.percentUsed,
93 ...(limit.resetsAt ? { resetsIn: span(Date.parse(limit.resetsAt) - now) } : {}),
94 })),
95 limitsNote: arePingsOff
96 ? 'pings off in every console'
97 : isIdle
98 ? 'pings paused: everything idle'
99 : fromElsewhere
100 ? `from another console ${span(now - (shared?.at ?? now))} ago`
101 : undefined,
102 model: shortModel(model),
103 effort: extras.effort === undefined ? undefined : String(extras.effort),
104 branch: extras.branch,
105 uptime: span(now - usage.startedAt),
106 }
107
108 const ctx = usage.context.percent
109 const ctxStep = ctx === undefined ? undefined : crossed(warned, 'context', ctx)
110 if (ctxStep !== undefined) await notify($, `Context is ${ctx}% full`)
111 for (const limit of rateLimits) {
112 const step = crossed(warned, limit.kind, limit.percentUsed)
113 if (step !== undefined) await notify($, `${limitName(limit.kind)} usage limit at ${limit.percentUsed}%`)
114 }
115
116 return { shared: latest, lastFresh: freshest(ownAt, shared) }
117}
118
119// One tiny request to the cheapest model, for the usage figures its response
120// carries; the before and after readings, or undefined when it went
121// unanswered. A ping someone asked for starts the idle streak over.
122async function sendPing($: EngineInterface, isAsked: boolean) {
123 if (ping.isRunning) return undefined
124 ping.isRunning = true
125 try {
126 const last = asShared(await $.store.get(SHARED_KEY))
127 const before = pickLimits((await $.session.usage()).rateLimits, ownAt, last)
128 const reply = await $.model.complete({
129 model: 'haiku',
130 prompt: 'Reply with the single word: ok',
131 maxTokens: 5,
132 timeoutMs: 30_000,
133 })
134 if (!reply.isAnswered) return undefined
135 const after = (await $.session.usage()).rateLimits
136 ownAt = await $.clock.now()
137 ownSource = 'ping'
138 ownIdle = isAsked ? 0 : nextIdle(last, before, after)
139 await refresh($)
140
141 return { before, after }
142 } finally {
143 ping.isRunning = false
144 }
145}
146
147async function tick($: EngineInterface) {
148 const { shared, lastFresh } = await refresh($)
149 if (!ping.isOn || arePingsOff || isPaused(shared)) return
150 if (isPingDue(await $.clock.now(), lastFresh, ping.everyMs)) await sendPing($, false)
151}
152
153async function readBranch($: EngineInterface) {
154 const cwd = await $.session.cwd()
155 const ran = await $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { cwd, timeoutMs: 3000 })
156 extras.branch = ran.exitCode === 0 ? ran.stdout.trim() || undefined : undefined
157}
158
159// The agents of this session, with a note when one finishes.
160async function readAgents($: EngineInterface) {
161 const list = await $.agent.list()
162 for (const agent of list) {
163 const was = agentStatus.get(agent.id)
164 if (was === 'running' && agent.status !== 'running') {
165 await notify($, `Agent ${agent.status}: ${agent.description}`, agent.type)
166 }
167 }
168 agentStatus = new Map(list.map(agent => [agent.id, agent.status]))
169 pending = {
170 ...pending,
171 agents: list.map(agent => ({ id: agent.id, label: agent.description, type: agent.type, status: agent.status })),
172 }
173}
174
175// Writes what changed to the panel's state, which redraws it.
176async function publish($: EngineInterface) {
177 await update($, hud, old => {
178 const next: Hud = { ...old, ...pending, notes }
179 return JSON.stringify(next) === JSON.stringify(old) ? old : next
180 })
181}
182
183// Turns the automatic pings off or on in every console.
184async function setPingsOff($: EngineInterface, isOff: boolean) {
185 await $.store.set(PINGS_OFF_KEY, isOff)
186 arePingsOff = isOff
187 await refresh($).catch(() => undefined)
188 await publish($).catch(() => undefined)
189}
190
191function describe(limits: SessionRateLimit[]): string {
192 return limits.map(limit => `${limitName(limit.kind)} ${limit.percentUsed}%`).join(', ') || 'none'
193}
194
195export const register: Register = (on, options) => {
196 ping.isOn = options.ping !== false
197 const minutes = Number(options.pingMinutes ?? 10)
198 ping.everyMs = Math.max(1, Number.isFinite(minutes) ? minutes : 10) * 60_000
199
200 on('session.start', async ($, e, next) => {
201 await $.command.register({
202 name: 'usage-ping',
203 description: 'Ping for fresh usage now; `off` / `on` stops or resumes automatic pings in every console',
204 })
205 await $.command.register({ name: 'hud', description: 'Open the session panel' })
206 await readBranch($).catch(() => undefined)
207 await refresh($)
208 $.clock.every(REFRESH_MS, () => void tick($))
209 $.clock.every(PANEL_MS, () => {
210 void (async () => {
211 await readAgents($).catch(() => undefined)
212 await publish($)
213 })()
214 })
215 void $.ui.open({ id: PANE, title: 'Session' })
216
217 return next(e)
218 })
219
220 on('command.run', { command: 'hud' }, async $ => {
221 const opened = await $.ui.open({ id: PANE, title: 'Session' })
222
223 return { text: opened.isPlaced ? 'Session panel open.' : `Session panel waits: ${opened.reason}` }
224 })
225
226 on('command.run', { command: 'usage-ping' }, async ($, e) => {
227 const verb = e.args.trim().toLowerCase()
228 if (verb === 'off' || verb === 'on') {
229 await setPingsOff($, verb === 'off')
230 return {
231 text:
232 verb === 'off'
233 ? 'Automatic usage pings are off in every console. `/usage-ping on` turns them back on.'
234 : 'Automatic usage pings are on again in every console.',
235 }
236 }
237 const pinged = await sendPing($, true)
238 if (pinged === undefined) return { text: 'Usage ping got no answer (or one is already running).' }
239 const moved = sameLimits(pinged.before, pinged.after) ? 'unchanged' : 'updated'
240
241 return { text: `Usage ping ${moved}: before ${describe(pinged.before)}; after ${describe(pinged.after)}.` }
242 })
243
244 // Every model response carries fresh usage figures; the main thread's
245 // request also says the session's effort.
246 on('turn.step', async function* ($, e, next) {
247 if (e.agentId === undefined) extras.effort = e.effort
248 const response = yield* next(e)
249 ownAt = await $.clock.now()
250 ownSource = 'use'
251 ownIdle = 0
252
253 return response
254 })
255
256 on('session.measure', async ($, e, next) => {
257 await refresh($)
258
259 return next(e)
260 })
261
262 on('prompt.submit', ($, e, next) => {
263 pending = { ...pending, isWorking: true }
264
265 return next(e)
266 })
267
268 on('turn.complete', async ($, e, next) => {
269 pending = { ...pending, isWorking: false }
270 await readBranch($).catch(() => undefined)
271 await refresh($)
272
273 return next(e)
274 })
275
276 // Every plugin's toast lands in the panel while it is docked.
277 on('ui.toast', async ($, e, next) => {
278 if (!panel.isDocked) return next(e)
279 notes = addNote(notes, { at: clock(await $.clock.now()), text: e.text, from: next.origin.plugin })
280
281 return { value: undefined }
282 })
283
284 on('ui.close', ($, e, next) => {
285 if (e.id === PANE) {
286 panel.isDocked = false
287 void refresh($)
288 }
289
290 return next(e)
291 })
292
293 // The prompt's footer: its hint line (the mode pill, shortcuts) and mode
294 // labels move into the panel while it is docked, so the prompt sits last.
295 on('ui.render', { component: 'PromptHint' }, ($, e, next) => {
296 pending = { ...pending, mode: modeFrom(e.props.hint) ?? 'default', hint: restOfHint(e.props.hint) }
297 if (!panel.isDocked) return next(e)
298
299 return next({ ...e, props: { ...e.props, hint: '' } })
300 })
301
302 on('ui.render', { component: 'SessionMode' }, ($, e, next) => {
303 pending = { ...pending, modes: [...e.props.modes] }
304 if (!panel.isDocked) return next(e)
305
306 return next({ ...e, props: { ...e.props, modes: [] } })
307 })
308
309 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
310 const wasDocked = panel.isDocked
311 panel.isDocked = e.props.placement === 'dock'
312 if (panel.isDocked) $.ui.status(undefined)
313 if (wasDocked !== panel.isDocked) $.ui.invalidate('ui.render')
314 const { Box, Text, Button } = $.ui.resolve(e)
315 const view = await read($, hud)
316 const width = Math.max(10, e.props.bodyColumns - 22)
317 const bar = Math.max(6, Math.min(20, width))
318 const head = (title: string) => (
319 <Text bold color="#c9b3ff">
320 {title}
321 </Text>
322 )
323 const row = (label: string, value: string, color?: string) => (
324 <Box flexDirection="row">
325 <Box width={9}>
326 <Text dimColor>{label}</Text>
327 </Box>
328 {color === undefined ? <Text wrap="truncate">{value}</Text> : <Text color={color} wrap="truncate">{value}</Text>}
329 </Box>
330 )
331 const gauge = (label: string, percent: number, detail?: string) => (
332 <Box flexDirection="column">
333 {row(label, `${meter(percent, bar)} ${percent}%`, levelColor(percent))}
334 {detail === undefined ? null : row('', detail)}
335 </Box>
336 )
337 const ctx = view.context
338 const agents = agentRows(view.agents)
339 const running = view.agents.filter(a => a.status === 'running').length
340
341 return (
342 <Box flexDirection="column">
343 {head('USAGE')}
344 {ctx?.percent !== undefined && ctx.tokens !== undefined
345 ? gauge('Context', ctx.percent, `${tokens(ctx.tokens)} / ${tokens(ctx.window)}`)
346 : row('Context', ctx ? `– / ${tokens(ctx.window)}` : '–')}
347 {view.limits.map(limit =>
348 gauge(limit.name === 'wk' ? 'Weekly' : limit.name === '5h' ? '5-hour' : limit.name, limit.percent,
349 limit.resetsIn ? `resets in ${limit.resetsIn}` : undefined),
350 )}
351 {view.limits.length === 0 ? row('Limits', 'after the first reply') : null}
352 {view.limitsNote ? <Text dimColor>{view.limitsNote}</Text> : null}
353 <Text> </Text>
354 {head('SESSION')}
355 {row('Model', view.model ? `${view.model}${view.effort ? ` · ${view.effort}` : ''}` : '–')}
356 {row('Mode', view.mode, view.mode === 'default' ? undefined : '#ffb347')}
357 {view.modes.length > 0 ? row('', view.modes.join(' & ')) : null}
358 {view.branch ? row('Branch', view.branch) : null}
359 {row('Uptime', view.uptime ?? '–')}
360 {row('Status', view.isWorking ? 'working…' : 'idle', view.isWorking ? '#7af6ff' : undefined)}
361 <Text> </Text>
362 {head(`AGENTS${running > 0 ? ` (${running} running)` : ''}`)}
363 {agents.length === 0 ? <Text dimColor>none</Text> : null}
364 {agents.map(agent => (
365 <Box flexDirection="row">
366 <Text color={agent.status === 'running' ? '#7af6ff' : agent.status === 'completed' ? '#7ee08a' : '#ff5f6d'}>
367 {statusMark(agent.status)}{' '}
368 </Text>
369 <Text wrap="truncate">
370 {agent.type} · {agent.label}
371 </Text>
372 </Box>
373 ))}
374 <Text> </Text>
375 <Box flexDirection="row">
376 {head('NOTIFICATIONS ')}
377 {view.notes.length > 0 ? (
378 <Button
379 key="clear"
380 label="clear"
381 plain
382 onPress={() => {
383 notes = []
384 void publish($)
385 }}
386 />
387 ) : null}
388 </Box>
389 {view.notes.length === 0 ? <Text dimColor>none</Text> : null}
390 {[...view.notes].reverse().slice(0, 12).map(note => (
391 <Box flexDirection="row">
392 <Text dimColor>{note.at} </Text>
393 <Text wrap="wrap">
394 {note.from ? `${note.from}: ` : ''}
395 {note.text}
396 </Text>
397 </Box>
398 ))}
399 {view.hint ? <Text> </Text> : null}
400 {view.hint ? <Text dimColor wrap="wrap">{view.hint}</Text> : null}
401 <Text> </Text>
402 <Box flexDirection="row">
403 <Button key="ping" label="ping usage now" onPress={() => void sendPing($, true)} />
404 <Text> </Text>
405 <Button
406 key="pings"
407 label={arePingsOff ? 'turn pings on' : 'turn pings off'}
408 onPress={() => void setPingsOff($, !arePingsOff)}
409 />
410 </Box>
411 </Box>
412 )
413 })
414}
415hooks/format.ts 72 lines1import type { SessionUsage } from 'claude-code'
2
3export type Extras = { model?: string; effort?: string | number; branch?: string; startedAt?: number; note?: string }
4
5const LIMIT_NAMES: Record<string, string> = { five_hour: '5h', seven_day: 'wk', spend_limit: 'spend' }
6
7export function tokens(n: number): string {
8 if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(2)}M`
9 if (n >= 100_000) return `${Math.round(n / 1000)}k`
10 if (n >= 1000) return `${+(n / 1000).toFixed(1)}k`
11 return String(n)
12}
13
14export function meter(percent: number, width = 8): string {
15 const filled = Math.min(width, Math.max(0, Math.round((percent / 100) * width)))
16 return '▰'.repeat(filled) + '▱'.repeat(width - filled)
17}
18
19export function span(ms: number): string {
20 const minutes = Math.max(0, Math.round(ms / 60_000))
21 const days = Math.floor(minutes / 1440)
22 const hours = Math.floor((minutes % 1440) / 60)
23 const mins = minutes % 60
24 if (days > 0) return `${days}d${hours}h`
25 if (hours > 0) return `${hours}h${String(mins).padStart(2, '0')}m`
26 return `${mins}m`
27}
28
29export function limitName(kind: string): string {
30 return LIMIT_NAMES[kind] ?? kind
31}
32
33export function shortModel(model: string): string {
34 return model.replace(/^claude-/, '').replace(/\[.*\]$/, '')
35}
36
37export function statusLine(usage: SessionUsage, extras: Extras, now: number): string {
38 const parts: string[] = []
39 const { context } = usage
40
41 if (context.tokens !== undefined && context.percent !== undefined) {
42 parts.push(`ctx ${meter(context.percent)} ${context.percent}% ${tokens(context.tokens)}/${tokens(context.window)}`)
43 } else {
44 parts.push(`ctx ${meter(0)} –/${tokens(context.window)}`)
45 }
46
47 for (const limit of usage.rateLimits) {
48 const reset = limit.resetsAt ? ` · ${span(Date.parse(limit.resetsAt) - now)} left` : ''
49 parts.push(`${limitName(limit.kind)} ${meter(limit.percentUsed, 5)} ${limit.percentUsed}%${reset}`)
50 }
51
52 if (extras.model) {
53 const effort = extras.effort === undefined ? '' : ` · ${extras.effort}`
54 parts.push(`${shortModel(extras.model)}${effort}`)
55 }
56 if (extras.branch) parts.push(`git:${extras.branch}`)
57 if (extras.startedAt !== undefined) parts.push(`up ${span(now - extras.startedAt)}`)
58 if (extras.note) parts.push(extras.note)
59
60 return parts.join(' │ ')
61}
62
63// The thresholds a figure has newly crossed since the last reading, highest
64// first; `seen` remembers the highest crossed per figure.
65export function crossed(seen: Map<string, number>, name: string, percent: number): number | undefined {
66 const top = [100, 90, 80].find(step => percent >= step) ?? 0
67 const was = seen.get(name) ?? 0
68 seen.set(name, top)
69
70 return top > was ? top : undefined
71}
72hooks/fresh.ts 70 lines1import type { SessionRateLimit } from 'claude-code'
2
3// The newest usage reading any session of this machine has seen, kept in
4// $.store so idle terminals pick up what busy ones learned. `source` says
5// whether real use or a ping brought it; `idlePings` counts pings in a row
6// that found nothing moving.
7export type Shared = {
8 at: number
9 rateLimits: SessionRateLimit[]
10 source?: 'use' | 'ping'
11 idlePings?: number
12}
13
14// An hour of 10-minute pings with nothing moving: everything is idle.
15export const IDLE_PINGS = 6
16// Points a window must rise by between pings to count as use elsewhere.
17export const RISE_POINTS = 1
18
19export const SHARED_KEY = 'latest'
20// True in the store turns the automatic pings off in every console.
21export const PINGS_OFF_KEY = 'pingsOff'
22
23export function asShared(value: unknown): Shared | undefined {
24 const v = value as Partial<Shared> | undefined
25 return typeof v?.at === 'number' && Array.isArray(v.rateLimits) ? (v as Shared) : undefined
26}
27
28// This session's own reading unless another session has a newer one.
29export function pickLimits(own: SessionRateLimit[], ownAt: number, shared: Shared | undefined): SessionRateLimit[] {
30 if (shared && shared.at > ownAt && shared.rateLimits.length > 0) return shared.rateLimits
31 return own
32}
33
34// When the last fresh reading came in, from whichever source; 0 for never,
35// so a new session with nothing recent pings at once.
36export function freshest(ownAt: number, shared: Shared | undefined): number {
37 return Math.max(ownAt, shared?.at ?? 0)
38}
39
40export function isPingDue(now: number, lastFresh: number, everyMs: number): boolean {
41 return now - lastFresh >= everyMs
42}
43
44export function sameLimits(a: SessionRateLimit[], b: SessionRateLimit[]): boolean {
45 return JSON.stringify(a) === JSON.stringify(b)
46}
47
48// Whether any window rose by `points` or more from `before` to `after`.
49export function rose(before: SessionRateLimit[], after: SessionRateLimit[], points = RISE_POINTS): boolean {
50 return after.some(limit => {
51 const was = before.find(old => old.kind === limit.kind)
52 return was !== undefined && limit.percentUsed - was.percentUsed >= points
53 })
54}
55
56// The idle-ping count after a ping: back to 0 when usage rose (someone is
57// busy elsewhere), else one more than the streak the last reading carried.
58export function nextIdle(last: Shared | undefined, before: SessionRateLimit[], after: SessionRateLimit[]): number {
59 if (rose(before, after)) return 0
60 const streak = last?.source === 'ping' ? (last.idlePings ?? 0) : 0
61
62 return streak + 1
63}
64
65// Pings pause once a streak of them found everything idle; any real use
66// (here or in another terminal) writes a `use` reading and ends the pause.
67export function isPaused(shared: Shared | undefined): boolean {
68 return shared?.source === 'ping' && (shared.idlePings ?? 0) >= IDLE_PINGS
69}
70hooks/panel.ts 51 lines1import type { HudAgent, HudNote } from '../types'
2
3// The permission mode the prompt's hint line names (`⏵⏵ auto mode on`, `⏸
4// plan mode on`, ...), or `default` when it names none.
5export function modeFrom(hint: string | undefined): string | undefined {
6 if (hint === undefined) return undefined
7 const named = /(auto mode|accept edits|plan mode|bypass permissions|don't ask)[^·(]*\bon\b/i.exec(hint)
8 return named ? named[0].replace(/\s+/g, ' ').trim() : 'default'
9}
10
11// What is left of the hint line once the mode pill is taken out of it.
12export function restOfHint(hint: string | undefined): string | undefined {
13 if (hint === undefined) return undefined
14 const rest = hint
15 .replace(/[⏵⏸»]*\s*(auto mode|accept edits|plan mode|bypass permissions|don't ask)[^·(]*\bon\b(\s*\([^)]*\))?/i, '')
16 .replace(/^\s*·\s*|\s*·\s*$/g, '')
17 .trim()
18
19 return rest === '' ? undefined : rest
20}
21
22export function levelColor(percent: number): string {
23 if (percent >= 90) return '#ff5f6d'
24 if (percent >= 75) return '#ffb347'
25 return '#7ee08a'
26}
27
28export function clock(ms: number): string {
29 const at = new Date(ms)
30 return `${String(at.getHours()).padStart(2, '0')}:${String(at.getMinutes()).padStart(2, '0')}`
31}
32
33// Running agents first, then the last few that finished.
34export function agentRows(agents: HudAgent[], finished = 4): HudAgent[] {
35 const running = agents.filter(a => a.status === 'running')
36 const done = agents.filter(a => a.status !== 'running').slice(-finished)
37
38 return [...running, ...done]
39}
40
41export function statusMark(status: string): string {
42 if (status === 'running') return '●'
43 if (status === 'completed') return '✓'
44 if (status === 'failed' || status === 'killed') return '✗'
45 return '·'
46}
47
48export function addNote(notes: HudNote[], note: HudNote, keep = 30): HudNote[] {
49 return [...notes, note].slice(-keep)
50}
51types/index.d.ts 29 lines1export type HudLimit = { name: string; percent: number; resetsIn?: string }
2
3export type HudAgent = { id: string; label: string; type: string; status: string }
4
5export type HudNote = { at: string; text: string; from?: string }
6
7// Everything the session panel shows.
8export type Hud = {
9 context?: { percent?: number; tokens?: number; window: number }
10 limits: HudLimit[]
11 limitsNote?: string
12 model?: string
13 effort?: string
14 branch?: string
15 uptime?: string
16 mode: string
17 hint?: string
18 modes: string[]
19 isWorking: boolean
20 agents: HudAgent[]
21 notes: HudNote[]
22}
23
24declare module 'claude-code' {
25 interface PluginState {
26 'usage-bar': { hud: Hud }
27 }
28}
29