SLOPSHOPPER

status-bar

Status line with git branch, diff size, PR state, CI checks and plan usage

newspinnerguardstatusprocesstimer
v0.6.0MITupdated 2026-10-05victor-falcon/claude-status-bar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · status-bar
› 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 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ⟨Claude Code's own drawing⟩ Session usage 31%

Draws

Prompt hint
⟨Claude Code's own drawing⟩ Session usage 31%
README

status-bar

A Claude Code mod that adds a colored status line under the prompt, with the engine's own hint line (? for shortcuts, esc to interrupt…):

The status line under the prompt: branch, diff, pull request state, CI checks and plan usage

SegmentWhat it showsColor
BranchCurrent branch, or the short SHA on a detached HEADBranch icon colored
DiffLines added and removed against the merge base with the default branch, uncommitted work included+N green, -N red
Pull requestThe branch's PR number and state, as #42 (Open), linked to the PR. The state is Merged, Closed, Draft or OpenPurple, red, gray, green
CIPassed checks out of the total, with an icon for all passed, any failed, or still runningPassed count green when all passed, red when any failed
UsageYour plan's 5-hour session usage and weekly usageOrange from 65%, red from 75% (whichever window is fuller)

Segments with nothing to show are left out: outside a git repository there is no git part, without a PR there is no PR or CI part, and off a Claude subscription there is no usage part.

Requirements

  • Claude Code with function hooks (mods). They are early access; this mod is tested on 2.1.289.
  • A Nerd Font in your terminal, for the icons.
  • git, and the GitHub CLI (gh) logged in for the PR and CI segments.

Install

claude plugin marketplace add victor-falcon/claude-status-bar
claude plugin install status-bar@claude-status-bar

Start a new session, or run /reload-plugins in a running one.

If you had a statusLine command in your settings, you can remove it: this mod replaces it.

How it refreshes

  • Branch and diff: every 5 seconds and after every turn.
  • Pull request and CI: every 60 seconds, and right after a gh pr, git push, git checkout or git switch run by Claude.
  • Usage: whenever Claude Code measures it again.

Configure

Every section can be switched off and every icon changed from /config, where each option is a row. Claude Code stores them in settings.json under pluginConfigs, and the mod reloads as soon as one changes.

The mod's options in /config: a switch per section and a field per icon

OptionDefaultWhat it does
showBranchtrueShow the branch
showDifftrueShow the lines added and removed (off, the mod skips git diff)
showPullRequesttrueShow the pull request number, linked, and its state
showCheckstrueShow the CI checks (with the pull request off too, the mod never calls gh)
showUsagetrueShow the plan usage
iconBranchNerd Font branchDrawn before the branch name
iconDiffnoneDrawn before the added and removed lines
iconPullRequestNerd Font GitHubDrawn before the pull request number
iconChecksPassedNerd Font checkDrawn before the checks when every one passed
iconChecksFailedNerd Font crossDrawn before the checks when any failed
iconChecksPendingNerd Font hourglassDrawn before the checks while some are still running
iconUsagenoneDrawn before the plan usage

Icons are used as written, spaces included. Leave one empty for its default, or write none to draw no icon. The dialog Claude Code opens on install shows every icon field empty, as it never fills a text field with its default: save it as it is to keep the default icons.

Customize

Default icons, colors and thresholds are constants at the top of hooks/format.ts (DEFAULT_ICONS, COLOR_*, PULL_REQUEST_COLORS, USAGE_WARNING_PERCENT, USAGE_CRITICAL_PERCENT). Colors are a Claude Code theme key or a raw color (green, #ff8700).

To run your own copy, clone this repository and load it from disk instead of installing it:

claude --plugin-dir /path/to/claude-status-bar

or, for every session, set CLAUDE_CODE_PLUGIN_DIRS to that path in the env block of ~/.claude/settings.json. The folder is watched, so saving a file reloads the mod.

Development

claude plugin validate .
claude plugin test .

License

MIT

Source 3 files
hooks/register.tsx 196 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { StatusBarSpans } from '../types'
5import { formatStatus, parseNumstat, toPullRequest, toStatusOptions, toUsage } from './format'
6import type { DiffSize, GhPullRequest, StatusInfo, StatusOptions } from './format'
7
8const GIT_REFRESH_MS = 5_000
9const PULL_REQUEST_REFRESH_MS = 60_000
10const TOUCHES_PULL_REQUEST = /\bgh\s+pr\b|\bgit\s+(push|checkout|switch)\b/
11const HINT_SEPARATOR = '  '
12
13const statusSpans = atom({ plugin: 'status-bar', key: 'spans' } as const, null as StatusBarSpans)
14
15const info: StatusInfo = { usage: {} }
16const progress = { isRefreshingGit: false, isRefreshingPullRequest: false }
17let lastLine: string | undefined
18let defaultBranch: string | undefined
19let statusOptions: StatusOptions = toStatusOptions({})
20
21/** Stores the line for the PromptHint hook; writing it redraws the hint. */
22async function paint($: EngineInterface): Promise<void> {
23  const spans = formatStatus(info, statusOptions) ?? null
24  const line = JSON.stringify(spans)
25
26  if (line !== lastLine) {
27    lastLine = line
28    await update($, statusSpans, () => spans)
29  }
30}
31
32async function git($: EngineInterface, args: readonly string[]): Promise<string | undefined> {
33  const { exitCode, stdout } = await $.process.run(['git', ...args], { timeoutMs: 5_000 })
34
35  return exitCode === 0 ? stdout.trim() : undefined
36}
37
38async function readBranchAndDiff($: EngineInterface): Promise<boolean> {
39  if ((await git($, ['rev-parse', '--show-toplevel'])) === undefined) {
40    const wasInRepository = info.branch !== undefined
41    info.branch = undefined
42    info.diff = undefined
43    info.pullRequest = undefined
44
45    return wasInRepository
46  }
47
48  defaultBranch ??= (await git($, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])) ?? 'origin/main'
49
50  const branch =
51    (await git($, ['branch', '--show-current'])) || (await git($, ['rev-parse', '--short', 'HEAD']))
52  const hasBranchChanged = branch !== info.branch
53
54  info.branch = branch
55  info.diff = statusOptions.show.diff ? await readDiff($, defaultBranch) : undefined
56
57  if (hasBranchChanged) {
58    info.pullRequest = undefined
59  }
60
61  return hasBranchChanged
62}
63
64/** Lines changed against the merge base with the default branch, uncommitted work included. */
65async function readDiff($: EngineInterface, baseBranch: string): Promise<DiffSize | undefined> {
66  const base = (await git($, ['merge-base', 'HEAD', baseBranch])) ?? 'HEAD'
67  const numstat = await git($, ['diff', '--numstat', base])
68
69  return numstat === undefined ? undefined : parseNumstat(numstat)
70}
71
72/** Reads branch and diff size; resolves whether the branch changed since the last read. */
73async function refreshGit($: EngineInterface): Promise<boolean> {
74  if (progress.isRefreshingGit) {
75    return false
76  }
77
78  progress.isRefreshingGit = true
79
80  try {
81    return await readBranchAndDiff($)
82  } catch (error) {
83    $.ui.log(`status-bar: git failed: ${String(error)}`, { to: 'debug' })
84
85    return false
86  } finally {
87    progress.isRefreshingGit = false
88    await paint($)
89  }
90}
91
92async function refreshPullRequest($: EngineInterface): Promise<void> {
93  const branch = info.branch
94  const isWanted = statusOptions.show.pullRequest || statusOptions.show.checks
95
96  if (!isWanted || branch === undefined || `origin/${branch}` === defaultBranch || progress.isRefreshingPullRequest) {
97    return
98  }
99
100  progress.isRefreshingPullRequest = true
101
102  try {
103    const { exitCode, stdout } = await $.process.run(
104      ['gh', 'pr', 'view', '--json', 'number,url,state,isDraft,statusCheckRollup'],
105      { timeoutMs: 15_000 },
106    )
107
108    if (info.branch === branch) {
109      info.pullRequest = exitCode === 0 ? toPullRequest(JSON.parse(stdout) as GhPullRequest) : undefined
110    }
111  } catch (error) {
112    $.ui.log(`status-bar: gh failed: ${String(error)}`, { to: 'debug' })
113  } finally {
114    progress.isRefreshingPullRequest = false
115    await paint($)
116  }
117}
118
119async function refreshAll($: EngineInterface): Promise<void> {
120  await refreshGit($)
121  await refreshPullRequest($)
122}
123
124async function refreshGitThenPullRequestOnBranchChange($: EngineInterface): Promise<void> {
125  if (await refreshGit($)) {
126    await refreshPullRequest($)
127  }
128}
129
130export const register: Register = (on, options) => {
131  statusOptions = toStatusOptions(options)
132
133  on('session.start', async ($, e, next) => {
134    const started = await next(e)
135
136    defaultBranch = undefined
137    $.ui.status(undefined)
138    info.usage = toUsage((await $.session.usage()).rateLimits)
139    await paint($)
140
141    $.clock.after(0, () => void refreshAll($))
142    $.clock.every(GIT_REFRESH_MS, () => void refreshGitThenPullRequestOnBranchChange($))
143    $.clock.every(PULL_REQUEST_REFRESH_MS, () => void refreshPullRequest($))
144
145    return started
146  })
147
148  on('session.measure', async ($, e, next) => {
149    info.usage = toUsage(e.rateLimits)
150    await paint($)
151
152    return next(e)
153  })
154
155  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
156    const result = await next(e)
157
158    if (TOUCHES_PULL_REQUEST.test(e.command)) {
159      $.clock.after(0, () => void refreshAll($))
160    }
161
162    return result
163  })
164
165  on('turn.complete', ($, e, next) => {
166    $.clock.after(0, () => void refreshAll($))
167
168    return next(e)
169  })
170
171  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
172    const spans = await read($, statusSpans)
173
174    if (spans === null) {
175      return next(e)
176    }
177
178    const engineHint = await next(e)
179    const { Box, Link, Text } = $.ui.resolve(e)
180
181    return (
182      <Box>
183        {engineHint}
184        <Text wrap="truncate-end">
185          <Text dimColor>{HINT_SEPARATOR}</Text>
186          {spans.map(span => {
187            const text = span.href === undefined ? span.text : <Link href={span.href} label={span.text} />
188
189            return span.color === undefined ? <Text dimColor>{text}</Text> : <Text color={span.color}>{text}</Text>
190          })}
191        </Text>
192      </Box>
193    )
194  })
195}
196
hooks/format.ts 281 lines
1import type { PluginOptions, SessionRateLimit } from 'claude-code'
2
3import type { StatusBarSpan } from '../types'
4
5export type PullRequestState = 'Merged' | 'Draft' | 'Open' | 'Closed'
6
7export type ChecksSummary = { passed: number; failed: number; pending: number; total: number }
8
9export type PullRequest = { number: number; url: string; state: PullRequestState; checks: ChecksSummary }
10
11export type DiffSize = { added: number; removed: number }
12
13export type Usage = { session?: number; week?: number }
14
15export type StatusInfo = {
16  branch?: string
17  diff?: DiffSize
18  pullRequest?: PullRequest
19  usage: Usage
20}
21
22/** One entry of `gh pr view --json statusCheckRollup`: a CheckRun or a StatusContext. */
23export type RollupEntry = {
24  __typename?: string
25  status?: string | null
26  conclusion?: string | null
27  state?: string | null
28}
29
30export type GhPullRequest = {
31  number: number
32  url: string
33  state: string
34  isDraft: boolean
35  statusCheckRollup?: RollupEntry[] | null
36}
37
38export type StatusSections = {
39  branch: boolean
40  diff: boolean
41  pullRequest: boolean
42  checks: boolean
43  usage: boolean
44}
45
46export type StatusIcons = {
47  branch: string
48  diff: string
49  pullRequest: string
50  checksPassed: string
51  checksFailed: string
52  checksPending: string
53  usage: string
54}
55
56export type StatusOptions = { show: StatusSections; icons: StatusIcons }
57
58/** Nerd Font glyphs, written as code points so an editor or font that cannot draw them keeps them. */
59const DEFAULT_ICONS: StatusIcons = {
60  branch: '\u{EA64} ',
61  diff: '',
62  pullRequest: ' \u{E709}',
63  checksPassed: ' \u{F012C}',
64  checksFailed: ' \u{F467}',
65  checksPending: ' \u{F051F}',
66  usage: '',
67}
68const NO_ICON = 'none'
69
70const COLOR_BRANCH = 'blue'
71const COLOR_ADDED = 'green'
72const COLOR_REMOVED = 'red'
73const COLOR_CHECKS_PASSED = 'green'
74const COLOR_CHECKS_FAILED = 'red'
75const PULL_REQUEST_COLORS: Record<PullRequestState, string> = {
76  Merged: 'magenta',
77  Closed: 'red',
78  Draft: 'gray',
79  Open: 'green',
80}
81const COLOR_USAGE_WARNING = '#ff8700'
82const COLOR_USAGE_CRITICAL = 'red'
83const USAGE_WARNING_PERCENT = 65
84const USAGE_CRITICAL_PERCENT = 75
85
86const PASSING_CONCLUSIONS = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED'])
87const PENDING_STATES = new Set(['PENDING', 'EXPECTED'])
88
89/** Sums the `git diff --numstat` output; binary files (`-\t-`) count as zero. */
90export function parseNumstat(output: string): DiffSize {
91  let added = 0
92  let removed = 0
93
94  for (const line of output.split('\n')) {
95    const [addedText, removedText] = line.split('\t')
96    added += Number.parseInt(addedText ?? '', 10) || 0
97    removed += Number.parseInt(removedText ?? '', 10) || 0
98  }
99
100  return { added, removed }
101}
102
103function classifyCheck(entry: RollupEntry): 'passed' | 'failed' | 'pending' {
104  if (entry.__typename === 'StatusContext' || (entry.status == null && entry.state != null)) {
105    const state = entry.state ?? ''
106
107    if (state === 'SUCCESS') {
108      return 'passed'
109    }
110
111    return PENDING_STATES.has(state) ? 'pending' : 'failed'
112  }
113
114  if (entry.status !== 'COMPLETED') {
115    return 'pending'
116  }
117
118  return PASSING_CONCLUSIONS.has(entry.conclusion ?? '') ? 'passed' : 'failed'
119}
120
121export function summarizeChecks(rollup: readonly RollupEntry[]): ChecksSummary {
122  const summary: ChecksSummary = { passed: 0, failed: 0, pending: 0, total: rollup.length }
123
124  for (const entry of rollup) {
125    summary[classifyCheck(entry)] += 1
126  }
127
128  return summary
129}
130
131export function toPullRequest(pullRequest: GhPullRequest): PullRequest {
132  const state: PullRequestState =
133    pullRequest.state === 'MERGED'
134      ? 'Merged'
135      : pullRequest.state === 'CLOSED'
136        ? 'Closed'
137        : pullRequest.isDraft
138          ? 'Draft'
139          : 'Open'
140
141  return {
142    number: pullRequest.number,
143    url: pullRequest.url,
144    state,
145    checks: summarizeChecks(pullRequest.statusCheckRollup ?? []),
146  }
147}
148
149export function toUsage(rateLimits: readonly SessionRateLimit[]): Usage {
150  const percentOf = (kind: string): number | undefined =>
151    rateLimits.find(limit => limit.kind === kind)?.percentUsed
152
153  return { session: percentOf('five_hour'), week: percentOf('seven_day') }
154}
155
156/**
157 * Reads the mod's `userConfig` values. A missing section stays shown. An empty icon
158 * draws its default, since Claude Code's install dialog shows text fields blank
159 * whatever their default; `none` draws no icon.
160 */
161export function toStatusOptions(options: PluginOptions): StatusOptions {
162  const isShown = (key: string): boolean => options[key] !== false
163  const iconFor = (key: string, fallback: string): string => {
164    const value = options[key]
165
166    if (typeof value !== 'string' || value === '') {
167      return fallback
168    }
169
170    return value.trim().toLowerCase() === NO_ICON ? '' : value
171  }
172
173  return {
174    show: {
175      branch: isShown('showBranch'),
176      diff: isShown('showDiff'),
177      pullRequest: isShown('showPullRequest'),
178      checks: isShown('showChecks'),
179      usage: isShown('showUsage'),
180    },
181    icons: {
182      branch: iconFor('iconBranch', DEFAULT_ICONS.branch),
183      diff: iconFor('iconDiff', DEFAULT_ICONS.diff),
184      pullRequest: iconFor('iconPullRequest', DEFAULT_ICONS.pullRequest),
185      checksPassed: iconFor('iconChecksPassed', DEFAULT_ICONS.checksPassed),
186      checksFailed: iconFor('iconChecksFailed', DEFAULT_ICONS.checksFailed),
187      checksPending: iconFor('iconChecksPending', DEFAULT_ICONS.checksPending),
188      usage: iconFor('iconUsage', DEFAULT_ICONS.usage),
189    },
190  }
191}
192
193/** The passed count is red when any check failed, green when all passed, and dim while some still run. */
194function formatChecks(checks: ChecksSummary, icons: StatusIcons): StatusBarSpan[] {
195  const passed = { text: `${checks.passed}` }
196  const total = { text: `/${checks.total}` }
197
198  if (checks.failed > 0) {
199    return [{ text: `${icons.checksFailed} ` }, { ...passed, color: COLOR_CHECKS_FAILED }, total]
200  }
201
202  if (checks.pending > 0) {
203    return [{ text: `${icons.checksPending} ` }, passed, total]
204  }
205
206  return [{ text: `${icons.checksPassed} ` }, { ...passed, color: COLOR_CHECKS_PASSED }, total]
207}
208
209/** Orange from 65% and red from 75% of whichever window is fuller, judged on the percent shown. */
210function usageColor(usage: Usage): string | undefined {
211  const highest = Math.max(Math.round(usage.session ?? 0), Math.round(usage.week ?? 0))
212
213  if (highest >= USAGE_CRITICAL_PERCENT) {
214    return COLOR_USAGE_CRITICAL
215  }
216
217  return highest >= USAGE_WARNING_PERCENT ? COLOR_USAGE_WARNING : undefined
218}
219
220function formatUsage(usage: Usage, icons: StatusIcons): StatusBarSpan | undefined {
221  if (usage.session === undefined) {
222    return undefined
223  }
224
225  const week = usage.week === undefined ? '' : ` (Week ${Math.round(usage.week)}%)`
226  const text = `${icons.usage}Session usage ${Math.round(usage.session)}%${week}`
227  const color = usageColor(usage)
228
229  return color === undefined ? { text } : { text, color }
230}
231
232function gitSegments(info: StatusInfo, { show, icons }: StatusOptions): StatusBarSpan[][] {
233  const segments: StatusBarSpan[][] = []
234
235  if (show.branch && info.branch !== undefined) {
236    segments.push([{ text: icons.branch, color: COLOR_BRANCH }, { text: ` ${info.branch}` }])
237  }
238
239  if (show.diff && info.diff !== undefined && (info.diff.added > 0 || info.diff.removed > 0)) {
240    segments.push([
241      { text: icons.diff },
242      { text: `+${info.diff.added}`, color: COLOR_ADDED },
243      { text: '' },
244      { text: `-${info.diff.removed}`, color: COLOR_REMOVED },
245    ])
246  }
247
248  if (show.pullRequest && info.pullRequest !== undefined) {
249    const { number, url, state } = info.pullRequest
250    const color = PULL_REQUEST_COLORS[state]
251    segments.push([
252      { text: `${icons.pullRequest} `, color },
253      { text: `#${number} (${state})`, href: url, color },
254    ])
255  }
256
257  if (show.checks && info.pullRequest !== undefined && info.pullRequest.checks.total > 0) {
258    segments.push(formatChecks(info.pullRequest.checks, icons))
259  }
260
261  return segments
262}
263
264function joinSpans(groups: StatusBarSpan[][], separator: string): StatusBarSpan[] {
265  return groups.flatMap((group, index) => (index === 0 ? group : [{ text: separator }, ...group]))
266}
267
268/** The line as colored spans; a span with no `color` is drawn dim, like the hint it follows. */
269export function formatStatus(info: StatusInfo, options: StatusOptions): StatusBarSpan[] | undefined {
270  const usage = options.show.usage ? formatUsage(info.usage, options.icons) : undefined
271  const sections = [joinSpans(gitSegments(info, options), ' '), usage === undefined ? [] : [usage]].filter(
272    section => section.length > 0,
273  )
274
275  return sections.length > 0 ? joinSpans(sections, ' · ') : undefined
276}
277
278export function spansToText(spans: readonly StatusBarSpan[] | undefined): string | undefined {
279  return spans?.map(span => span.text).join('')
280}
281
types/index.d.ts 14 lines
1/**
2 * One piece of the status line; `color` is a theme key or a raw color, absent for dim text,
3 * and `href` makes it a link.
4 */
5export type StatusBarSpan = { text: string; color?: string; href?: string }
6
7export type StatusBarSpans = readonly StatusBarSpan[] | null
8
9declare module 'claude-code' {
10  interface PluginState {
11    'status-bar': { spans: StatusBarSpans }
12  }
13}
14