SLOPSHOPPER

statusline

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…

newbandguardstatuspromptprocess
★ 2v3.2.0AGPL-3.0updated 2026-10-08St0nefish/agent-toolkit/plugins-claude/statusline
A shopper browsing a rack in a slop shop
README

Statusline

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

Installation

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:

  • It copies a tiny stub (statusline.sh) to ~/.config/claude-statusline/ and adds statusLine to ~/.claude/settings.json.
  • It is add-only: if your settings already define a statusLine (yours or another tool's), nothing is written.
  • It is silent.

How it works

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:

  • The mod (hooks/register.tsx) reads git, context, usage windows, cost and extra credits, and writes the finished ANSI line to ~/.cache/claude-statusline/<session-id>.
  • The stub (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.

Updates

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.

Opting out

  • /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.
  • Set CLAUDE_STATUSLINE_NO_AUTO_INSTALL=1 (for example under env in your settings) to stop the hook installing anything.

Commands

CommandDescription
/statusline:statusline-setupInstall the stub and configure Claude Code's statusLine
/statusline:statusline-configShow how to choose a style
/statusline:statusline-teardownRemove the status line (--clean to also delete the stub and cache)

Options

Defaults are the standard setup; use /statusline:statusline-config only to override.

OptionDefaultEffect
stylebelowWhere and how the line is drawn (see /statusline:statusline-config)
gitPollSeconds3Idle re-check of git for changes made elsewhere; 0 turns it off
segmentGap1Spaces between segments; 2 spreads them out on wide terminals
leftPad2Spaces 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.

What it shows

SegmentShows
userUsername, user@host over SSH, bright yellow as root
projectProject name (the main repo's name inside a linked worktree, marked ⧉)
branchBranch 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
modelFriendly name (Sonnet 5.5), not the model id
contextFill bar and percentage
limits5h (◷) and 7d (▦) usage with reset countdowns
extra⊕ $used/$limit monthly extra credits, shown only once you have spent some
costSession 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.

Styles

Choose one with /config (or claude plugin configure statusline@agent-toolkit).

StyleWhereNotes
below (default)Under the promptColoured, via the statusLine slot; needs the stub installed
flat-leftAbove the promptFlat text packed left; works with no statusLine setup
powerlineAbove the promptOne dark band with Nerd Font arrows
plainAbove the promptColoured 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.

Data sources

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.

Dependencies

ToolRequiredPurpose
gitYesBranch and status info
jqInstall onlyEditing settings.json (the stub itself does not need it)
Source 2 files
hooks/register.tsx 622 lines
1import { 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}
622
types/index.d.ts 34 lines
1export 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