SLOPSHOPPER

statusline

A status line as a mod: 5-hour and weekly limits, context, tokens and cost above the prompt; folder, branch, model, session name and id under it.

newbandspinnerprocesstimer
v0.1.2Apache-2.0updated 2026-10-05newtonmunene99/claudekit/plugins/statusline
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · statusline
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM 5h ██░░░░░░ 31% ctx ████░░░░ 4 ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
5h ██░░░░░░ 31% ⟨Claude Code's own drawing⟩
README

statusline

A status line as a mod, the same on every install: no statusLine script to set up per machine.

Needs a Claude Code build with mod support (function hooks).

Above the prompt

A row of pills, wrapping when the window is narrow:

PillShows
5h5-hour limit: bar, % used, reset time (↻ 19:20)
7dweekly limit: bar, % used, reset day and time (↻ Thu 19:00)
ctxcontext window fill
↑ ↓ ≋this session's input, output and cache-read tokens
$this session's cost at API prices (what /cost shows)

Bars turn yellow at 70% and red at 90%. A figure with no reading yet is left out. Token totals count every model request of this session's turns and start at 0 when a session is resumed, so they can run below what the cost covers. Another plugin's band (Conductor's track band) stays, under the pills.

Under the prompt

At the end of the hint line (terminal only): folder (branch✗) · model · name · id. ✗ means git has uncommitted changes; a detached HEAD shows the short commit. The session's name shows when it has one (a /rename shows from the next prompt), then the first 8 characters of the session id.

Once it works for you, remove statusLine from ~/.claude/settings.json.

Writing a mod in claudekit

One mod per plugin, in plugins/<name>/: .claude-plugin/plugin.json (with "types": "./types/index.d.ts" when it keeps $.state), hooks/hooks.json naming ./register.tsx, the module and its pure helpers in hooks/, tests in tests/. Run claude plugin validate plugins/<name> and claude plugin test plugins/<name> before a version bump. A mod that serves one plugin lives inside that plugin (the Conductor HUD lives in conductor).

Two engine rules shape this:

  • A plugin hooks each event once without a matcher, so two mods that both react to turn.complete or a prompt cannot share a plugin cleanly.
  • $ is never passed into a function imported from another file; helpers that take $ are top-level function declarations in the module itself.
Source 4 files
hooks/register.tsx 158 lines
1// The status line, as a mod: usage pills above the prompt and folder ·
2// branch · model · name · id under it. Engine
3// figures are read live while drawing ($.session.usage() is free); events and
4// a timer only ask for a redraw.
5
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, Register } from 'claude-code'
8
9import { ZERO_TOKENS, addUsage, identityText, modelName } from './format'
10import { buildPills } from './pills'
11import type { Tone } from './pills'
12
13export const tokens = atom({ plugin: 'statusline', key: 'tokens' } as const, ZERO_TOKENS)
14
15// Soft backgrounds like the reference screenshot; bar colours by level.
16const TONE: Record<Tone, string> = {
17  green: '#d9eedf',
18  purple: '#e6def5',
19  grey: '#e6e6e6',
20  red: '#f6dcd6',
21  blue: '#dbe3f6',
22  yellow: '#f6ecd2',
23}
24const LEVEL = { ok: '#5a9a68', warn: '#c9a227', high: '#c8553d' } as const
25const INK = '#2b2b2b'
26
27export const identity = atom({ plugin: 'statusline', key: 'identity' } as const, null)
28const title = atom({ plugin: 'statusline', key: 'title' } as const, null)
29
30async function git($: EngineInterface, root: string, args: string[]): Promise<string | null> {
31  try {
32    const { stdout } = await $.process.run(['git', '-C', root, '--no-optional-locks', ...args], { timeoutMs: 3000 })
33    return stdout.trim()
34  } catch {
35    return null
36  }
37}
38
39// Folder, branch (short sha when detached, none outside a repo), dirty mark
40// and model, as the old status line script showed them.
41async function refreshIdentity($: EngineInterface): Promise<void> {
42  const root = await $.session.root()
43  const folder = root.split('/').filter(Boolean).pop() ?? root
44  const model = modelName(await $.session.model())
45  let branch: string | null = null
46  let isDirty = false
47  if ((await git($, root, ['rev-parse', '--is-inside-work-tree'])) === 'true') {
48    const [current, status] = await Promise.all([
49      git($, root, ['branch', '--show-current']),
50      git($, root, ['status', '--porcelain']),
51    ])
52    branch = current || (await git($, root, ['rev-parse', '--short', 'HEAD']))
53    isDirty = Boolean(status)
54  }
55  const text = identityText(folder, branch, isDirty, model, await read($, title), await $.session.id())
56  if (text !== (await read($, identity))) await update($, identity, () => text)
57}
58
59// Git can be slow in a big repo: refresh in the background so no turn waits.
60function refreshIdentityLater($: EngineInterface): void {
61  refreshIdentity($).catch(() => {})
62}
63
64async function noteTitle($: EngineInterface, name: string | undefined): Promise<void> {
65  const next = name?.trim() || null
66  if (next === (await read($, title))) return
67  await update($, title, () => next)
68  refreshIdentityLater($)
69}
70
71function redraw($: EngineInterface): void {
72  $.ui.invalidate('ui.render')
73}
74
75export const register: Register = on => {
76  // A passed reset time drops off with the clock, not with an event.
77  on('session.start', async ($, e, next) => {
78    const started = await next(e)
79    $.clock.every(30_000, () => redraw($))
80    refreshIdentityLater($)
81    return started
82  })
83
84  on('turn.complete', async ($, e, next) => {
85    const done = await next(e)
86    // A subagent's turn changes nothing the line shows.
87    if (!e.agentId) refreshIdentityLater($)
88    return done
89  })
90
91  // The session's name only arrives on these settings-hook events; a /rename
92  // shows from the next prompt on.
93  on('classic.SessionStart', async ($, e, next) => {
94    await noteTitle($, e.session_title)
95    return next(e)
96  })
97
98  on('classic.UserPromptSubmit', async ($, e, next) => {
99    await noteTitle($, e.session_title)
100    return next(e)
101  })
102
103  // Dim at the end of the engine's own hint line; the terminal draws it,
104  // other surfaces ignore tail for now.
105  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
106    const tail = await read($, identity)
107    return tail ? next({ ...e, props: { ...e.props, tail } }) : next(e)
108  })
109
110  on('session.measure', async ($, e, next) => {
111    const measured = await next(e)
112    redraw($)
113    return measured
114  })
115
116  // Every model request, main thread and subagents: the cost figure counts
117  // them all, so the token totals do too. Writing state redraws the band.
118  on('turn.step', async function* ($, e, next) {
119    const result = yield* next(e)
120    const usage = result?.usage
121    if (usage) await update($, tokens, t => addUsage(t ?? ZERO_TOKENS, usage))
122    return result
123  })
124
125  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
126    if (e.props.hasSurvey) return next(e)
127    const usage = await $.session.usage()
128    const pills = buildPills(usage, await read($, tokens), Date.now())
129    const beneath = await next(e)
130    if (pills.length === 0) return beneath
131    const { Box, Text } = $.ui.resolve(e)
132    return (
133      // No width on this outer box: the engine refuses its own drawing
134      // (beneath) under a Box with a width, and would drop the whole band.
135      <Box flexDirection="column">
136        <Box flexDirection="row" flexWrap="wrap" gap={1} width={e.props.bodyColumns}>
137          {pills.map(p => (
138            <Box key={p.key} flexDirection="row" gap={1} paddingX={1} backgroundColor={TONE[p.tone]}>
139              {p.parts.map((part, i) => (
140                <Text
141                  key={`${p.key}-${i}`}
142                  color={part.level ? LEVEL[part.level] : INK}
143                  backgroundColor={TONE[p.tone]}
144                  bold={part.isBold}
145                  dimColor={part.isDim}
146                >
147                  {part.text}
148                </Text>
149              ))}
150            </Box>
151          ))}
152        </Box>
153        {beneath}
154      </Box>
155    )
156  })
157}
158
hooks/format.ts 73 lines
1// Pure formatting for the usage band and identity line. No `$`, so tests feed
2// it plain values.
3
4import type { UsageTokens } from '../types'
5
6export const ZERO_TOKENS: UsageTokens = { input: 0, output: 0, cacheRead: 0 }
7
8export function shortNumber(n: number): string {
9  if (n < 1_000) return String(Math.round(n))
10  if (n < 1_000_000) return `${(n / 1_000).toFixed(1)}k`
11  return `${(n / 1_000_000).toFixed(2)}M`
12}
13
14const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
15
16// When a limit resets, in local time: "19:20", or "Thu 19:00" with the day
17// for the weekly one. Null when unknown or already past.
18export function resetTime(resetsAt: string | undefined, now: number, isWithDay: boolean): string | null {
19  if (!resetsAt) return null
20  const at = new Date(resetsAt)
21  if (!Number.isFinite(at.getTime()) || at.getTime() <= now) return null
22  const hm = `${String(at.getHours()).padStart(2, '0')}:${String(at.getMinutes()).padStart(2, '0')}`
23  return isWithDay ? `${DAYS[at.getDay()]} ${hm}` : hm
24}
25
26export function barCells(percent: number, width = 8): string {
27  const filled = Math.min(width, Math.max(0, Math.round((percent / 100) * width)))
28  return '█'.repeat(filled) + '░'.repeat(width - filled)
29}
30
31export function level(percent: number): 'ok' | 'warn' | 'high' {
32  if (percent >= 90) return 'high'
33  if (percent >= 70) return 'warn'
34  return 'ok'
35}
36
37export function addUsage(
38  t: UsageTokens,
39  u: {
40    input_tokens: number
41    output_tokens: number
42    cache_read_input_tokens: number
43    cache_creation_input_tokens: number
44  },
45): UsageTokens {
46  return {
47    input: t.input + u.input_tokens + u.cache_creation_input_tokens,
48    output: t.output + u.output_tokens,
49    cacheRead: t.cacheRead + u.cache_read_input_tokens,
50  }
51}
52
53// "claude-opus-5-5" -> "Opus 5.5"; anything else is returned as given.
54export function modelName(id: string): string {
55  const [, family, major, minor] = /^claude-([a-z]+)-(\d+)-(\d+)/.exec(id) ?? []
56  if (!family || !major || !minor) return id
57  return `${family.charAt(0).toUpperCase()}${family.slice(1)} ${major}.${minor}`
58}
59
60// "<folder> (<branch>[✗]) · <model>[ · <title>] · <id8>": the session's name
61// when it has one, and the id's first 8 characters, enough to find or resume it.
62export function identityText(
63  folder: string,
64  branch: string | null,
65  isDirty: boolean,
66  model: string,
67  title: string | null,
68  sessionId: string,
69): string {
70  const where = branch ? `${folder} (${branch}${isDirty ? '✗' : ''})` : folder
71  return [where, model, title, sessionId.slice(0, 8)].filter(Boolean).join(' · ')
72}
73
hooks/pills.ts 46 lines
1// The band's content as plain data: which pills, in which order, with which
2// text. The render hook only maps these to elements.
3
4import type { UsageTokens } from '../types'
5import { barCells, level, resetTime, shortNumber } from './format'
6
7export type Tone = 'green' | 'purple' | 'grey' | 'red' | 'blue' | 'yellow'
8export type PillPart = { text: string; level?: 'ok' | 'warn' | 'high'; isBold?: boolean; isDim?: boolean }
9export type PillSpec = { key: string; tone: Tone; parts: PillPart[] }
10
11// The slice of $.session.usage() the band reads.
12export type UsageFigures = {
13  context: { percent?: number }
14  rateLimits: { kind: string; percentUsed: number; resetsAt?: string }[]
15  cost?: { usd: number }
16}
17
18function meter(key: string, label: string, tone: Tone, percent: number, reset: string | null): PillSpec {
19  const parts: PillPart[] = [
20    { text: label, isDim: true },
21    { text: barCells(percent), level: level(percent) },
22    { text: `${Math.round(percent)}%`, isBold: true },
23  ]
24  if (reset) parts.push({ text: `│ ↻ ${reset}`, isDim: true })
25  return { key, tone, parts }
26}
27
28export function buildPills(usage: UsageFigures, tokens: UsageTokens, now: number): PillSpec[] {
29  const pills: PillSpec[] = []
30  const limit = (kind: string) => usage.rateLimits.find(r => r.kind === kind)
31  const five = limit('five_hour')
32  const week = limit('seven_day')
33  if (five) pills.push(meter('5h', '5h', 'green', five.percentUsed, resetTime(five.resetsAt, now, false)))
34  if (week) pills.push(meter('7d', '7d', 'purple', week.percentUsed, resetTime(week.resetsAt, now, true)))
35  if (usage.context.percent !== undefined) {
36    pills.push(meter('ctx', 'ctx', 'grey', usage.context.percent, null))
37  }
38  if (tokens.input + tokens.output + tokens.cacheRead > 0) {
39    pills.push({ key: 'in', tone: 'red', parts: [{ text: `↑ ${shortNumber(tokens.input)}` }] })
40    pills.push({ key: 'out', tone: 'green', parts: [{ text: `↓ ${shortNumber(tokens.output)}` }] })
41    pills.push({ key: 'cache', tone: 'blue', parts: [{ text: `≋ ${shortNumber(tokens.cacheRead)}` }] })
42  }
43  if (usage.cost) pills.push({ key: 'cost', tone: 'yellow', parts: [{ text: `$ ${usage.cost.usd.toFixed(2)}` }] })
44  return pills
45}
46
types/index.d.ts 22 lines
1// State the statusline plugin keeps for a session.
2
3export type UsageTokens = {
4  // Input tokens sent uncached, including those written to the cache.
5  input: number
6  output: number
7  cacheRead: number
8}
9
10declare module 'claude-code' {
11  interface PluginState {
12    statusline: {
13      tokens: UsageTokens
14      // "<folder> (<branch>[✗]) · <model>[ · <title>] · <id8>", or null before
15      // the first reading.
16      identity: string | null
17      // The session's name as the last session start or prompt carried it.
18      title: string | null
19    }
20  }
21}
22