Status line for Claude Code built as a mod: user, project, git, model, context, usage, extra credits and cost, drawn under the prompt with colours that follow…

A status line for Claude Code built as a mod: a hooks module that gathers everything natively and draws one coloured line. Version 3 replaces the old 1,000-line bash renderer, with its curl polling, usage cache and git cache.
stonefish ⌂ agent-toolkit ⑂ feat/statusline-v3 !2 ✦ Sonnet 5.5 ▰▰▰▱▱▱▱▱▱▱ 33% ◷ 12% 3h26m ▦ 86% 1d1h
claude plugin install St0nefish/agent-toolkit/statusline
That's it. Start a new session and the line appears from the session after that. Claude Code plugins can't set statusLine themselves, so a SessionStart hook does the setup for you the first time it runs:
statusline.sh) to ~/.config/claude-statusline/ and adds statusLine to ~/.claude/settings.json.statusLine (yours or another tool's), nothing is written.A mod can draw coloured text above the prompt but not under it, and the statusLine slot is the one place that draws coloured text under the prompt. So the work is split:
hooks/register.tsx) reads git, context, usage windows, cost and extra credits, and writes the finished ANSI line to ~/.cache/claude-statusline/<session-id>.scripts/statusline.sh) reads the session id from the JSON Claude Code passes it and prints that file. It polls nothing, caches nothing and needs no jq.The same hook keeps the installed stub current. After claude plugin update the new stub is live from your next session start. Files are swapped in atomically. Upgrading from a version before 3.0 replaces the old bash renderer with the stub automatically; an old config.json is left alone but no longer read.
/statusline:statusline-teardown removes the status line and leaves a marker so it is not reinstalled at the next session start./statusline:statusline-setup runs the same install by hand (with dependency checks and visible output) and clears that marker.CLAUDE_STATUSLINE_NO_AUTO_INSTALL=1 (for example under env in your settings) to stop the hook installing anything.| Command | Description |
|---|---|
/statusline:statusline-setup | Install the stub and configure Claude Code's statusLine |
/statusline:statusline-config | Show how to choose a style |
/statusline:statusline-teardown | Remove the status line (--clean to also delete the stub and cache) |
Defaults are the standard setup; use /statusline:statusline-config only to override.
| Option | Default | Effect |
|---|---|---|
style | below | Where and how the line is drawn (see /statusline:statusline-config) |
gitPollSeconds | 3 | Idle re-check of git for changes made elsewhere; 0 turns it off |
segmentGap | 1 | Spaces between segments; 2 spreads them out on wide terminals |
leftPad | 2 | Spaces before the first segment in the below style; 0 is flush left |
The statusLine entry the installer writes sets refreshInterval to 3 seconds so Claude Code re-reads the line while the session is idle. The entry is added at session start whenever settings have none. The plugin's own entry gets refreshInterval filled in if it lacks one; a value you set, or an entry that is not the plugin's, is never edited.
| Segment | Shows |
|---|---|
| user | Username, user@host over SSH, bright yellow as root |
| project | Project name (the main repo's name inside a linked worktree, marked ⧉) |
| branch | Branch with the git-branch glyph, always green, ellipsized in the middle when long |
| git chips | ⇣ behind and ⇡ ahead (green), + staged and ! modified (yellow), ? untracked (blue), in Powerlevel10k's order |
| model | Friendly name (Sonnet 5.5), not the model id |
| context | Fill bar and percentage |
| limits | 5h (◷) and 7d (▦) usage with reset countdowns |
| extra | ⊕ $used/$limit monthly extra credits, shown only once you have spent some |
| cost | Session cost, only when no rate limits are reported |
Severity colours follow Claude Code's own success, warning and error; the rest are colour names (yellow, green, blueBright), so everything follows your terminal palette and theme rather than fixed values.
Choose one with /config (or claude plugin configure statusline@agent-toolkit).
| Style | Where | Notes |
|---|---|---|
below (default) | Under the prompt | Coloured, via the statusLine slot; needs the stub installed |
flat-left | Above the prompt | Flat text packed left; works with no statusLine setup |
powerline | Above the prompt | One dark band with Nerd Font arrows |
plain | Above the prompt | Coloured text with thin bars; no patched font needed |
If another tool already owns your statusLine, the stub is not installed and below shows nothing; pick flat-left instead.
Context, usage windows and cost come from the session itself (session.measure). Extra credits are not part of that data, so they are read from the OAuth usage endpoint at most every 5 minutes, using the session's own credential; the plugin never reads a token. Git status is read with git status --porcelain=v2 after each Bash call and each turn.
| Tool | Required | Purpose |
|---|---|---|
git | Yes | Branch and status info |
jq | Install only | Editing settings.json (the stub itself does not need it) |
hooks/register.tsx 622 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Extra, Git, Limit, Snap, Who } from '../types'
5
6const gitAtom = atom({ plugin: 'statusline', key: 'git' } as const, null)
7const snapAtom = atom({ plugin: 'statusline', key: 'snap' } as const, null)
8const nowAtom = atom({ plugin: 'statusline', key: 'now' } as const, 0)
9const whoAtom = atom({ plugin: 'statusline', key: 'who' } as const, null)
10const extraAtom = atom({ plugin: 'statusline', key: 'extra' } as const, null)
11
12// Extra credits are not in session.measure, so they come from the same usage
13// endpoint the old script polled, at most this often.
14const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
15const EXTRA_TTL_MS = 300_000
16
17const BAR_CELLS = 10
18const COST_WARN = 5
19const COST_ERROR = 20
20
21// Powerline glyphs (Nerd Font / Powerline-patched fonts), all sharp:
22// solid arrows cap each band, thin arrows divide segments inside it.
23const CAP_START = '' // <
24const CAP_END = '' // >
25const THIN_RIGHT = '' // inside a band that reads left to right
26const THIN_LEFT = '' // inside a band that reads right to left
27
28// One quiet background with the colour carried by the text, as in the zsh
29// Powerlevel10k prompt. Colours are names, not hex, so they follow the
30// terminal palette and Claude Code's own theme instead of drifting from them.
31// The band background is the one fixed value: p10k's 236, which no palette
32// name stands in for.
33const BG = '#303030'
34const RULE = 'gray'
35const DIM = 'inactive'
36const TRACK = 'subtle'
37const YELLOW = 'yellow'
38const BLUE = 'blueBright'
39const GREEN = 'green'
40const RED = 'red'
41const ORANGE = 'claude'
42const SILVER = 'text'
43// Severity follows Claude Code's own success / warning / error colours.
44const OK = 'success'
45const WARN = 'warning'
46const BAD = 'error'
47
48type Part = { t: string; fg: string; bold?: boolean }
49// A segment is a run of coloured parts; `shrink` names the one part that may
50// ellipsize when the band runs out of room (the branch name).
51type Seg = { parts: Part[]; shrink?: number }
52
53const P = (t: string, fg: string, bold?: boolean): Part => ({ t, fg, bold })
54
55const levelColor = (pct: number) => (pct >= 80 ? BAD : pct >= 50 ? WARN : OK)
56
57// "claude-sonnet-5-5" -> "Sonnet 5.5", "claude-3-5-haiku-20241022" -> "Haiku 3.5",
58// "claude-opus-4-1[1m]" -> "Opus 4.1 (1M)".
59const friendlyModel = (id: string) => {
60 const ctx = /\[(\d+)m\]/i.exec(id)
61 const parts = id
62 .replace(/\[.*\]/, '')
63 .replace(/^claude-/, '')
64 .replace(/-\d{8}$/, '')
65 .split('-')
66 const family = parts.find(p => /^[a-z]/i.test(p))
67 const version = parts.filter(p => /^\d+$/.test(p)).join('.')
68 if (!family) return id
69 const name = family.charAt(0).toUpperCase() + family.slice(1)
70 return `${name}${version ? ` ${version}` : ''}${ctx ? ` (${ctx[1]}M)` : ''}`
71}
72
73const countdown = (iso: string | undefined, now: number) => {
74 if (!iso) return ''
75 const mins = Math.max(0, Math.round((Date.parse(iso) - now) / 60000))
76 const d = Math.floor(mins / 1440)
77 const h = Math.floor((mins % 1440) / 60)
78 const m = mins % 60
79 return d > 0 ? `${d}d${h}h` : h > 0 ? `${h}h${String(m).padStart(2, '0')}m` : `${m}m`
80}
81
82const layouts = new Map<string, { stdout: string }>()
83
84const layoutOf = async ($: any, cwd: string) => {
85 const known = layouts.get(cwd)
86 if (known) return known
87 const res = await $.process.run(
88 ['git', 'rev-parse', '--path-format=absolute', '--git-dir', '--git-common-dir', '--show-toplevel'],
89 { cwd, timeoutMs: 3000 },
90 )
91 // Only a successful answer is worth keeping: outside a repo it may become one.
92 if (res.exitCode === 0) layouts.set(cwd, { stdout: res.stdout })
93 return res
94}
95
96const queryGit = async ($: any, cwd: string) => {
97 const status = await $.process.run(['git', 'status', '--porcelain=v2', '--branch'], {
98 cwd,
99 timeoutMs: 3000,
100 })
101 if (status.exitCode !== 0) return null
102
103 let branch = ''
104 let ahead = 0
105 let behind = 0
106 let staged = 0
107 let unstaged = 0
108 let untracked = 0
109 for (const line of status.stdout.split('\n')) {
110 if (line.startsWith('# branch.head ')) branch = line.slice(14)
111 else if (line.startsWith('# branch.ab ')) {
112 const m = /\+(\d+) -(\d+)/.exec(line)
113 if (m) [ahead, behind] = [Number(m[1]), Number(m[2])]
114 } else if (line.startsWith('?')) untracked += 1
115 else if (line.startsWith('1 ') || line.startsWith('2 ')) {
116 const xy = line.split(' ')[1]
117 if (xy[0] !== '.') staged += 1
118 if (xy[1] !== '.') unstaged += 1
119 }
120 }
121 return { branch, ahead, behind, staged, unstaged, untracked }
122}
123
124const readGit = async ($: any): Promise<Git> => {
125 const cwd: string = await $.session.cwd()
126 // The repo layout does not change under a running session, so ask once per
127 // directory; the status query is the only call that must run each time.
128 const [c, paths] = await Promise.all([queryGit($, cwd), layoutOf($, cwd)])
129 // Outside a repo there is no branch, but the project marker must still show.
130 if (!c) {
131 return {
132 inRepo: false,
133 branch: '',
134 staged: 0,
135 unstaged: 0,
136 untracked: 0,
137 ahead: 0,
138 behind: 0,
139 isWorktree: false,
140 project: cwd.split('/').filter(Boolean).pop() ?? cwd,
141 }
142 }
143 const { branch, ahead, behind, staged, unstaged, untracked } = c
144
145 const [gitDir, common, top] = paths.stdout.trim().split('\n')
146 const isWorktree = !!gitDir && !!common && gitDir !== common
147 const mainRoot = common?.replace(/\/\.git\/?$/, '') ?? top ?? cwd
148 const project = (isWorktree ? mainRoot : (top ?? cwd)).split('/').pop() ?? ''
149
150 return { inRepo: true, branch, staged, unstaged, untracked, ahead, behind, isWorktree, project }
151}
152
153let lastGit = ''
154let lastNowAt = 0
155
156// Returns whether anything the line shows moved, so a poll that found nothing new
157// can leave the file and the screen alone.
158const refreshGit = async ($: any): Promise<boolean> => {
159 const git = await readGit($)
160 const key = JSON.stringify(git)
161 const changed = key !== lastGit
162 if (changed) {
163 lastGit = key
164 await update($, gitAtom, () => git)
165 }
166 // Reset countdowns are measured against this: keep it fresh to the minute.
167 const at = Date.now()
168 if (changed || at - lastNowAt > 30_000) {
169 lastNowAt = at
170 await update($, nowAtom, () => at)
171 return true
172 }
173 return false
174}
175
176let polling = false
177
178// The idle poll: git can change with no event here (another terminal, a subagent),
179// so look again and only redraw when something the line shows moved.
180const pollGit = async ($: any, isBelow: boolean) => {
181 if (polling) return // a slow status must not stack up behind itself
182 polling = true
183 try {
184 if ((await refreshGit($)) && isBelow) await publish($)
185 } catch {
186 // the next tick tries again
187 } finally {
188 polling = false
189 }
190}
191
192// After a tool that can change the working tree: re-read git and redraw.
193const afterTool = async ($: any, isBelow: boolean) => {
194 try {
195 await refreshGit($)
196 if (isBelow) await publish($)
197 } catch {
198 // a failed git refresh must never lose the tool result
199 }
200}
201
202const readWho = async ($: any): Promise<Who> => {
203 const user = (await $.process.run(['id', '-un'], { timeoutMs: 2000 })).stdout.trim()
204 const uid = (await $.process.run(['id', '-u'], { timeoutMs: 2000 })).stdout.trim()
205 const isRemote = !!(await $.env.get('SSH_CONNECTION'))
206 const host = isRemote
207 ? (await $.process.run(['hostname', '-s'], { timeoutMs: 2000 })).stdout.trim()
208 : null
209 return { user, host, isRoot: uid === '0' }
210}
211
212const refreshSnap = async (
213 $: any,
214 u: { context: { percent?: number }; rateLimits: Limit[]; cost?: { usd: number } },
215) => {
216 const snap: Snap = {
217 model: friendlyModel(await $.session.model()),
218 contextPercent: u.context.percent ?? null,
219 limits: u.rateLimits,
220 costUsd: u.cost?.usd ?? null,
221 }
222 await update($, snapAtom, () => snap)
223 await update($, nowAtom, () => Date.now())
224}
225
226// /model changes no usage figure, so session.measure never fires for it: re-read the
227// model on the events that do fire around a switch, and report whether it moved.
228const refreshModel = async ($: any) => {
229 const model = friendlyModel(await $.session.model())
230 let changed = false
231 await update($, snapAtom, (s: Snap | null) => {
232 if (!s || s.model === model) return s
233 changed = true
234 return { ...s, model }
235 })
236 return changed
237}
238
239const syncModel = async ($: any, isBelow: boolean) => {
240 try {
241 if ((await refreshModel($)) && isBelow) await publish($)
242 } catch {
243 // a failed model refresh must never block the prompt
244 }
245}
246
247const buildSegs = (
248 git: Git | null,
249 snap: Snap,
250 now: number,
251 who: Who | null,
252 extra: Extra | null,
253 cols: number,
254 // The git-branch glyph needs a Nerd Font; the plain style passes a standard one.
255 branchIcon = '\ue725',
256): { left: Seg[]; right: Seg[]; usage: Seg[] } => {
257 const isWide = cols >= 110
258 const isMedium = cols >= 80
259 const barCells = isWide ? BAR_CELLS : isMedium ? 6 : 0
260
261 const five = snap.limits.find(l => l.kind === 'five_hour')
262 const week = snap.limits.find(l => l.kind === 'seven_day')
263 const pct = snap.contextPercent
264
265 const limitSeg = (icon: string, l: Limit, withCountdown: boolean): Seg => ({
266 parts: [
267 P(`${icon} `, DIM),
268 P(`${Math.round(l.percentUsed)}%`, levelColor(l.percentUsed)),
269 ...(withCountdown
270 ? [P(` ${countdown(l.resetsAt, now)}`, DIM)]
271 : []),
272 ],
273 })
274
275 const left: Seg[] = []
276 if (who && isWide) {
277 left.push({
278 parts: [P(`${who.user}${who.host ? `@${who.host}` : ''}`, who.isRoot ? RED : YELLOW)],
279 })
280 }
281 if (git) {
282 left.push({ parts: [P(`${git.isWorktree ? '⧉' : '⌂'} ${git.project}`, BLUE, true)] })
283 }
284 if (git?.inRepo) {
285 // The same grammar as the zsh prompt: the branch is always green and the
286 // chips carry the colour (behind/ahead green, +/! yellow, ? blue).
287 const chips: Part[] = [
288 git.behind > 0 ? P(` ⇣${git.behind}`, GREEN) : null,
289 git.ahead > 0 ? P(` ⇡${git.ahead}`, GREEN) : null,
290 git.staged > 0 ? P(` +${git.staged}`, YELLOW) : null,
291 git.unstaged > 0 ? P(` !${git.unstaged}`, YELLOW) : null,
292 git.untracked > 0 ? P(` ?${git.untracked}`, BLUE) : null,
293 ].filter((c): c is Part => c !== null)
294 left.push({
295 parts: [P(`${branchIcon} ${git.branch}`, GREEN), ...chips],
296 shrink: 0,
297 })
298 }
299
300 const right: Seg[] = [{ parts: [P(`✦ ${snap.model}`, ORANGE, true)] }]
301 if (pct !== null) {
302 const filled = Math.round((Math.min(100, Math.max(0, pct)) / 100) * barCells)
303 right.push({
304 parts: [
305 ...(barCells > 0
306 ? [P('▰'.repeat(filled), levelColor(pct)), P('▱'.repeat(barCells - filled) + ' ', TRACK)]
307 : []),
308 P(`${Math.round(pct)}%`, levelColor(pct)),
309 ],
310 })
311 }
312 // What you have left to spend: kept apart from the model and context so a
313 // style can draw it somewhere else (the below style appends it after them).
314 const usage: Seg[] = []
315 if (five) usage.push(limitSeg('◷', five, isMedium))
316 if (week && isMedium) usage.push(limitSeg('▦', week, true))
317 // Only once you have actually spilled into extra credits.
318 if (extra && extra.used > 0) {
319 usage.push({
320 parts: [
321 P('⊕ ', DIM),
322 P(`$${extra.used.toFixed(2)}/$${extra.limit.toFixed(2)}`, levelColor(extra.pct)),
323 ],
324 })
325 }
326 if (!five && snap.costUsd !== null && snap.costUsd > 0) {
327 const color = snap.costUsd >= COST_ERROR ? BAD : snap.costUsd >= COST_WARN ? WARN : OK
328 usage.push({ parts: [P(`$${snap.costUsd.toFixed(2)}`, color)] })
329 }
330 return { left, right, usage }
331}
332
333// The "below" style: a mod cannot draw coloured text under the prompt, but the
334// engine's own statusLine slot can. The mod renders the finished line here, as
335// ANSI, into a per-session file and a one-line statusLine command prints it.
336// Names map to the 16-colour palette (or Claude's brand colour), so the line
337// still follows the terminal theme rather than fixed hex values.
338const ESC = String.fromCharCode(27)
339const SGR: Record<string, string> = {
340 yellow: '33',
341 green: '32',
342 red: '31',
343 blueBright: '94',
344 gray: '90',
345 success: '32',
346 warning: '33',
347 error: '31',
348 inactive: '90',
349 subtle: '2;90',
350 text: '39',
351 claude: '38;2;215;119;87',
352}
353const ansiPart = (part: Part) =>
354 `${ESC}[${part.bold ? '1;' : ''}${SGR[part.fg] ?? '39'}m${part.t}${ESC}[0m`
355
356let dirReady = false
357let lastLine = ''
358let segmentGap = 1
359let leftPad = 2
360
361const publish = async ($: any) => {
362 const snap = await read($, snapAtom)
363 if (!snap) return
364 const { left, right, usage } = buildSegs(
365 await read($, gitAtom),
366 snap,
367 await read($, nowAtom),
368 await read($, whoAtom),
369 await read($, extraAtom),
370 200,
371 )
372 const segs = [...left, ...right, ...usage]
373 // The default two leading spaces line the text up with the footer hint line above it.
374 const line = `${' '.repeat(leftPad)}${segs.map(s => s.parts.map(ansiPart).join('')).join(' '.repeat(segmentGap))}`
375 const id = await $.session.id()
376 if (lastLine === `${id}\n${line}`) return // the file already says this
377 const dir = await cacheDir($)
378 if (!dirReady) {
379 await $.process.run(['mkdir', '-p', dir], { timeoutMs: 2000 })
380 dirReady = true
381 }
382 await $.fs.write(`${dir}/${id}`, `${line}\n`)
383 lastLine = `${id}\n${line}`
384}
385
386// Where the per-session lines live; scripts/statusline.sh reads the same place.
387const cacheDir = async ($: any) => {
388 const base = (await $.env.get('XDG_CACHE_HOME')) || `${await $.env.get('HOME')}/.cache`
389 return `${base}/claude-statusline`
390}
391
392// Extra credits (cents -> dollars) from the usage endpoint, spent with the
393// session's own credential so no token is ever read. Null off a subscription
394// or when extra usage is not enabled; a failed request throws and the caller
395// keeps the last value.
396const readExtra = async ($: any): Promise<Extra | null> => {
397 const auth = await $.session.authorize()
398 if (!auth) return null
399 const res = await $.http.fetch(USAGE_URL, {
400 auth: auth.handle,
401 headers: { 'anthropic-beta': 'oauth-2025-04-20' },
402 })
403 if (!res.ok) throw new Error(`usage ${res.status}`)
404 const extra = JSON.parse(res.text).extra_usage
405 if (!extra?.is_enabled) return null
406 return {
407 used: (extra.used_credits ?? 0) / 100,
408 limit: (extra.monthly_limit ?? 0) / 100,
409 pct: Math.round(extra.utilization ?? 0),
410 }
411}
412
413let lastExtraAt = 0
414
415const refreshExtra = async ($: any) => {
416 const at = await $.clock.now()
417 if (at - lastExtraAt < EXTRA_TTL_MS) return false
418 lastExtraAt = at
419 try {
420 const extra = await readExtra($)
421 await update($, extraAtom, () => extra)
422 return true
423 } catch {
424 // offline or rate-limited: keep showing the last value
425 return false
426 }
427}
428
429// The fetch is slow and rare, so the line goes out first and again if it brought news.
430const refreshExtraAndPublish = async ($: any, isBelow: boolean) => {
431 if ((await refreshExtra($)) && isBelow) await publish($)
432}
433
434export const register: Register = (on, options) => {
435 // Where and how the line is drawn:
436 // below under the prompt, in the statusLine slot (default). The mod
437 // renders the line and scripts/statusline.sh prints it.
438 // flat-left above the prompt, flat coloured text packed left; needs no
439 // statusLine setup
440 // powerline above the prompt, one dark band with Nerd Font arrows
441 // plain above the prompt, coloured text with thin bars, no patched font
442 const style = typeof options.style === 'string' ? options.style : 'below'
443 const isBelow = style === 'below'
444 const isLeft = style === 'flat-left'
445 const isPowerline = style === 'powerline'
446 // Flat text separates segments with space alone, no divider glyph.
447 const isFlat = isLeft
448 // Seconds between idle git checks; 0 turns the poll off.
449 const pollSeconds =
450 typeof options.gitPollSeconds === 'number' && options.gitPollSeconds >= 0
451 ? options.gitPollSeconds
452 : 3
453 // Spaces between segments; 1 packs tighter for narrow terminals.
454 segmentGap =
455 typeof options.segmentGap === 'number' && options.segmentGap >= 1
456 ? Math.floor(options.segmentGap)
457 : 1
458 // Spaces before the first segment of the below style.
459 leftPad =
460 typeof options.leftPad === 'number' && options.leftPad >= 0 ? Math.floor(options.leftPad) : 2
461 let pollTimer: { cancel: () => void } | undefined
462
463 on('session.start', async ($, e, next) => {
464 const [who] = await Promise.all([
465 readWho($),
466 refreshGit($),
467 refreshSnap($, await $.session.usage()),
468 ])
469 await update($, whoAtom, () => who)
470 $.ui.status(undefined) // clear anything a previous version pinned
471 if (isBelow) await publish($)
472 else {
473 // Drop a line left by an earlier "below" run, which the stub would keep printing.
474 const stale = `${await cacheDir($)}/${await $.session.id()}`
475 await $.process.run(['rm', '-f', stale], { timeoutMs: 2000 })
476 }
477 pollTimer?.cancel()
478 if (pollSeconds > 0) pollTimer = $.clock.every(pollSeconds * 1000, () => pollGit($, isBelow))
479 await refreshExtraAndPublish($, isBelow)
480 return next(e)
481 })
482
483 on('session.measure', async ($, e, next) => {
484 await refreshSnap($, e)
485 if (isBelow) await publish($)
486 await refreshExtraAndPublish($, isBelow)
487 return next(e)
488 })
489
490 on('prompt.submit', async ($, e, next) => {
491 await syncModel($, isBelow)
492 return next(e)
493 })
494
495 on('turn.start', async ($, e, next) => {
496 await syncModel($, isBelow)
497 return next(e)
498 })
499
500 on('turn.complete', async ($, e, next) => {
501 await refreshGit($)
502 if (isBelow) await publish($)
503 return next(e)
504 })
505
506 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
507 const result = await next(e)
508 await afterTool($, isBelow)
509 return result
510 })
511
512 // Edits change the dirty counts too; without these they lag until the turn ends.
513 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
514 const result = await next(e)
515 await afterTool($, isBelow)
516 return result
517 })
518
519 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
520 const result = await next(e)
521 await afterTool($, isBelow)
522 return result
523 })
524
525 on('session.end', async ($, e, next) => {
526 pollTimer?.cancel()
527 lastLine = ''
528 if (isBelow) {
529 const dir = await cacheDir($)
530 await $.process.run(['rm', '-f', `${dir}/${await $.session.id()}`], { timeoutMs: 2000 })
531 }
532 return next(e)
533 })
534
535 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
536 const git = await read($, gitAtom)
537 const snap = await read($, snapAtom)
538 const now = await read($, nowAtom)
539 const who = await read($, whoAtom)
540 const extra = await read($, extraAtom)
541 if (isBelow || e.props.hasSurvey || !snap) return next(e)
542
543 const { Box, Text } = $.ui.resolve(e)
544
545 // Width tiers: the band sheds detail from least to most important.
546 const cols: number = e.props.bodyColumns
547 const built = buildSegs(git, snap, now, who, extra, cols, style === 'plain' ? '⎇' : undefined)
548 const left = built.left
549 const right = [...built.right, ...built.usage]
550
551 // ---- drawing ----------------------------------------------------------
552 const bg = isPowerline ? BG : undefined
553
554 const text = (part: Part, key: string, shrinks: boolean) => (
555 <Box key={key} flexShrink={shrinks ? 1 : 0}>
556 <Text
557 color={part.fg}
558 backgroundColor={bg}
559 bold={part.bold}
560 wrap={shrinks ? 'truncate-middle' : undefined}
561 >
562 {part.t}
563 </Text>
564 </Box>
565 )
566
567 const pad = (key: string) => text(P(' ', SILVER), key, false)
568
569 const divider = (glyph: string, key: string) =>
570 isPowerline ? (
571 <Box key={key} flexShrink={0}>
572 <Text color={RULE} backgroundColor={BG}>
573 {glyph}
574 </Text>
575 </Box>
576 ) : (
577 <Box key={key} flexShrink={0}>
578 <Text color={RULE}>│</Text>
579 </Box>
580 )
581
582 const cap = (glyph: string, key: string) =>
583 isPowerline ? (
584 <Box key={key} flexShrink={0}>
585 <Text color={BG}>{glyph}</Text>
586 </Box>
587 ) : null
588
589 // One dark band per side: cap, segments split by thin arrows, cap. The
590 // arrows on both sides point inward, towards the gap between them.
591 const band = (segs: Seg[], thin: string, tag: string) => (
592 <Box flexShrink={tag === 'l' ? 1 : 0}>
593 {cap(CAP_START, `${tag}-start`)}
594 {isPowerline ? pad(`${tag}-pad0`) : null}
595 {segs.flatMap((s, i) => [
596 ...(i > 0
597 ? isFlat
598 ? Array.from({ length: segmentGap }, (_, k) => pad(`${tag}${i}-gap${k}`))
599 : [pad(`${tag}${i}-pre`), divider(thin, `${tag}${i}-div`), pad(`${tag}${i}-post`)]
600 : []),
601 ...s.parts.map((part, j) => text(part, `${tag}${i}-${j}`, s.shrink === j)),
602 ])}
603 {isPowerline ? pad(`${tag}-pad1`) : null}
604 {cap(CAP_END, `${tag}-end`)}
605 </Box>
606 )
607
608 if (isLeft) {
609 const all = [...left, ...right]
610 return <Box marginTop={1}>{band(all, THIN_RIGHT, 'l')}</Box>
611 }
612
613 return (
614 <Box marginTop={1}>
615 {left.length ? band(left, THIN_RIGHT, 'l') : null}
616 <Box flexGrow={1} />
617 {band(right, THIN_LEFT, 'r')}
618 </Box>
619 )
620 })
621}
622types/index.d.ts 34 lines1export type Git = {
2 inRepo: boolean
3 branch: string
4 staged: number
5 unstaged: number
6 untracked: number
7 ahead: number
8 behind: number
9 isWorktree: boolean
10 project: string
11}
12export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
13export type Snap = {
14 model: string
15 contextPercent: number | null
16 limits: Limit[]
17 costUsd: number | null
18}
19export type Who = { user: string; host: string | null; isRoot: boolean }
20// Monthly extra credits, in dollars; pct is how much of the monthly limit is used.
21export type Extra = { used: number; limit: number; pct: number }
22
23declare module 'claude-code' {
24 interface PluginState {
25 'statusline': {
26 git: Git | null
27 snap: Snap | null
28 now: number
29 who: Who | null
30 extra: Extra | null
31 }
32 }
33}
34