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.

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).
A row of pills, wrapping when the window is narrow:
| Pill | Shows |
|---|---|
5h | 5-hour limit: bar, % used, reset time (↻ 19:20) |
7d | weekly limit: bar, % used, reset day and time (↻ Thu 19:00) |
ctx | context 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.
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.
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:
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.hooks/register.tsx 158 lines1// 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}
158hooks/format.ts 73 lines1// 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}
73hooks/pills.ts 46 lines1// 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}
46types/index.d.ts 22 lines1// 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