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

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…):

| Segment | What it shows | Color |
|---|---|---|
| Branch | Current branch, or the short SHA on a detached HEAD | Branch icon colored |
| Diff | Lines added and removed against the merge base with the default branch, uncommitted work included | +N green, -N red |
| Pull request | The branch's PR number and state, as #42 (Open), linked to the PR. The state is Merged, Closed, Draft or Open | Purple, red, gray, green |
| CI | Passed checks out of the total, with an icon for all passed, any failed, or still running | Passed count green when all passed, red when any failed |
| Usage | Your plan's 5-hour session usage and weekly usage | Orange 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.
git, and the GitHub CLI (gh) logged in for the PR and CI segments.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.
gh pr, git push, git checkout or git switch run by Claude.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.

| Option | Default | What it does |
|---|---|---|
showBranch | true | Show the branch |
showDiff | true | Show the lines added and removed (off, the mod skips git diff) |
showPullRequest | true | Show the pull request number, linked, and its state |
showChecks | true | Show the CI checks (with the pull request off too, the mod never calls gh) |
showUsage | true | Show the plan usage |
iconBranch | Nerd Font branch | Drawn before the branch name |
iconDiff | none | Drawn before the added and removed lines |
iconPullRequest | Nerd Font GitHub | Drawn before the pull request number |
iconChecksPassed | Nerd Font check | Drawn before the checks when every one passed |
iconChecksFailed | Nerd Font cross | Drawn before the checks when any failed |
iconChecksPending | Nerd Font hourglass | Drawn before the checks while some are still running |
iconUsage | none | Drawn 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.
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.
claude plugin validate .
claude plugin test .
hooks/register.tsx 196 lines1import { 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}
196hooks/format.ts 281 lines1import 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}
281types/index.d.ts 14 lines1/**
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