SLOPSHOPPER

usage-bar

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

newpanespinnercommandtoaststatus
v0.3.0MITupdated 2026-10-05Kyubey64/claude-code-mods/mods/usage-bar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-bar
│ ┃ Session ✕ › fix the failing auth test and add an audit log call │ ┃ USAGE │ ┃ Context – ⏺ Read(src/auth.ts) │ ┃ Limits after the first reply ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ SESSION ⎿ Added 2 lines, removed 1 line │ ┃ Model – ⏺ Bash(bun test) │ ┃ Mode default ⎿ 3 pass, 1 fail │ ┃ Uptime – │ ┃ Status idle ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ AGENTS ✻ Worked for 42s · done 4:20 PM │ ┃ none │ ┃ › /usage-ping │ ┃ NOTIFICATIONS ⎿ usage-bar: Usage ping unchanged: before 5h 31%; after 5h 31%. │ ┃ none │ ┃ │ ┃ [ ping usage now ] [ turn pings off ] │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Session
USAGE Context – Limits after the first reply SESSION Model – Mode default Uptime – Status idle AGENTS none NOTIFICATIONS none [ ping usage now ] [ turn pings off ]
README

Claude Code mods

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.

pixel-witch

A chibi pixel-art witch who lives in your terminal.

  • Wanders over the conversation (fullscreen) or a strip above the prompt, and reacts: a thinking bubble while Claude works, spell sparkles during tool calls, a ! when one fails, a happy hop when a reply lands, and a nap when things go quiet.
  • Drag her with the mouse and she grumbles ("hmph!", "put me down!") and sulks after. Right-click to hide her.
  • Follows you between consoles: she leaps out of one and drops into the one you are typing in.
  • Five styles, each its own drawing: 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).
  • With two consoles open, a second witch sometimes visits. Rarely they fight: the victim's console is visually wrecked by glitch streaks until a giant hand comes down, grabs the attacker and hauls her off, and a new witch drops in. (Only the drawing; your text is never touched.)

Commands: /witch, /witch skin <name>, /witch summon, /witch fight.

usage-bar (session panel)

A panel docked on the right while the prompt stays at the bottom:

  • Usage: context fill, 5-hour and weekly limits with reset countdowns, as colored meters. Readings are shared between your consoles, and an optional tiny Haiku ping keeps them fresh while idle (pausing after an idle hour).
  • Session: model and effort, permission mode (auto, plan, ...), git branch, uptime, working or idle.
  • Agents: running subagents, then the last few finished.
  • Notifications: usage warnings, finished agents, and every mod's toasts, timestamped.

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

Requirements

  • Claude Code 2.1.288 or newer. The function-hook API is early access and may change between releases.
  • The visuals are tuned for Claude Code's fullscreen terminal layout.

Development

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.

License

MIT

Source 5 files
hooks/register.tsx 415 lines
1import { 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}
415
hooks/format.ts 72 lines
1import 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}
72
hooks/fresh.ts 70 lines
1import 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}
70
hooks/panel.ts 51 lines
1import 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}
51
types/index.d.ts 29 lines
1export 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