Directory, git, model, context and rate-limit bars above the prompt, in the terminal and in Claude Desktop

A rich statusline for Claude Code that shows your directory, git status, model, and usage limits at a glance.

There are two versions:
| Version | What it is | Where it shows |
|---|---|---|
| v1 | A shell script that Claude Code runs as its statusLine command | The terminal, below the prompt |
| v2 | A Claude Code mod in mod/ | The terminal and the Claude Desktop Code tab, above the prompt |
The sections from Features to Updating describe v1. For v2, go to v2: mod for the terminal and Claude Desktop. The release notes are in CHANGELOG.md.
| Line | Segments |
|---|---|
| Line 1 | Directory, git branch with ahead/behind + +N -M lines + untracked count, model name + reasoning effort level |
| Line 2 | Context window usage bar with absolute token counts (e.g. 118k/1m), 5-hour usage dot bar, 7-day usage dot bar, countdown + absolute reset time |
All data comes from the JSON that Claude Code pipes to the statusline script on stdin. No API calls, no auth tokens, no caching.
When Claude Code is running inside a linked git worktree, the branch chip switches from orange to purple and is prefixed with ⎇, so you can tell at a glance whether you're on the main checkout or a worktree branch:

This is driven by the workspace.git_worktree field in Claude Code's stdin JSON — which is populated whenever the session's current directory is inside a linked worktree (not the main repo). The directory chip on the left already shows the worktree's folder name, so the colored branch chip just adds the "this is a worktree branch" signal without taking extra horizontal space.
When the active model supports a reasoning effort setting, the level is shown in cyan right after the model name, separated by a dim · (e.g. Opus 4.8 · high). This is driven by the effort.level field in Claude Code's stdin JSON. If the field is absent — because the model doesn't expose an effort setting — nothing is added and only the model name is shown.
Each bar colors its filled dots (●) differently, depending on what the bar is measuring:
Context bar — per-dot gradient. Each filled dot's color reflects the usage level its position represents, so the bar visually scales from green (low) to red (full):
| Dot position | Color |
|---|---|
| 0-49% | Green |
| 50-69% | Orange |
| 70-89% | Yellow |
| 90%+ | Red |
5-hour / 7-day bars — pace coloring. Filled dots are a single color reflecting how fast you're consuming quota relative to time elapsed in the window:
| Pace delta | Color | Meaning |
|---|---|---|
| Below pace | Blue | Under pace (healthy buffer) |
| 0-20% above | Green | Within 20% of pace (on track) |
| 20-50% above | Yellow | Outpacing |
| 50%+ above | Red | Significantly outpacing |
Empty dots (○) — all bars. Colored by overall usage:
| Usage | Color |
|---|---|
| <50% | Dim |
| 50-69% | Orange |
| 70-89% | Yellow |
| 90%+ | Red |
jq — JSON processorgit — for branch/diff info (pre-installed on most systems)git clone git@github.com:TheBabaYaga/claude-statusline.git
cd claude-statusline
./install.sh
The install script will:
jq if missing (via Homebrew, apt, dnf, or pacman)statusline-command.sh to ~/.claude/~/.claude/settings.json with the statusline commandjq: # macOS
brew install jq
# Debian/Ubuntu
sudo apt-get install jq
# Fedora
sudo dnf install jq
# Arch
sudo pacman -S jq
cp statusline-command.sh ~/.claude/statusline-command.sh
chmod +x ~/.claude/statusline-command.sh
~/.claude/settings.json: {
"statusLine": {
"type": "command",
"command": "bash ~/.claude/statusline-command.sh"
}
}
git pull && ./install.sh
The mod/ folder holds v2: a Claude Code mod (a plugin of function hooks). Claude Desktop does not run a statusLine command, so v1 shows only in the terminal. v2 draws one line in the band above the prompt. That band shows in the terminal and in the Claude Desktop Code tab.
v2 uses the same colors as v1. It gets its data from the mod API, not from stdin JSON:
| Segment | Source |
|---|---|
| Directory, model | $.session.cwd(), $.session.model() |
| Effort level | CLAUDE_EFFORT at session start, then the turn.step event |
| Git chip | git status --porcelain=v2, git diff --numstat and git rev-parse through $.process.run |
| Context and rate-limit bars | $.session.usage() and the session.measure event |
The bars are smooth instead of dotted. Claude Desktop draws each bar as an SVG, exact to the pixel. The terminal draws it with block characters in 1/8-cell steps (███▊░░░). The colors follow the v1 rules. The context bar fills from tokens / window, so it is finer than a whole percent.
To fit on one line, v2 uses short labels (ctx, 5h, 7d). When the band is too narrow, the line breaks between segments. A | shows only between two segments on the same line:
claude-statusline feat/v2-mod +43 -0 ?9 Opus 5.5 · high | ctx ██▍░░░░░░░░░░░░ 16% 161k/1m | 5h ▎░░░░░░░░░ 3% ( 4h 21min - 2:00pm ) | 7d █░░░░░░░░░ 10% ( 4d 2h - Sun 12:00pm )
The effort level has a color for each level: low is blue, medium is green, high is yellow, xhigh is orange, and max is red. In the terminal, a dot in that color comes before the effort button, because a button cannot take a color.
The mod refreshes after each turn, when usage changes, and every 30 seconds. It needs no jq.
claude plugin marketplace add TheBabaYaga/claude-statusline
claude plugin install rich-statusline@thebabayaga
In the terminal, the model name and the effort level are buttons. The model name opens /model, and the effort level opens /effort. Click a button in fullscreen mode, or press ctrl+x tab and then m for the model or e for the effort.
In the terminal, v1 and v2 both show if you keep the statusLine setting. Remove statusLine from ~/.claude/settings.json to show only v2.
claude plugin marketplace update thebabayaga
claude plugin update rich-statusline@thebabayaga
Then start a new session.
To run v2 from your clone, add the absolute path of its mod folder to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json. Claude Desktop and the terminal both read this value. If you also installed v2 from the marketplace, uninstall that copy first. Otherwise two copies of v2 can load.
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/absolute/path/to/claude-statusline/mod"
}
}
A change in mod/ loads in your next session. To try it in one terminal session only, use claude --plugin-dir ./mod.
Check and test the mod:
claude plugin validate mod
claude plugin test mod
The layout rules are in mod/hooks/format.ts. The hooks are in mod/hooks/register.tsx.
Claude Code invokes the statusline script after every response and pipes it a JSON payload on stdin containing the current directory, model, context-window usage, and rate-limit state (percentages and reset epochs for the 5-hour and 7-day windows). The script reads that JSON with jq, runs git locally for branch stats, and prints a formatted string.
v2 runs inside Claude Code. It reads the same data through the mod API and runs only git on your machine. It makes no network calls and reads no credentials.
The rate_limits block is only populated for Claude.ai Pro/Max subscribers after the first API response; if it's absent, the 5-hour and 7-day bars are simply omitted.
MIT
hooks/register.tsx 186 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Git, View } from '../types'
5import { barSegs, barSvg, lines, parseStatus, row, sumNumstat } from './format'
6import type { Item, Seg } from './format'
7
8const EMPTY: View = { dir: '', git: null, model: '', context: { window: 0 }, rateLimits: [], now: 0 }
9const view = atom({ plugin: 'rich-statusline', key: 'view' } as const, EMPTY)
10
11async function readGit($: EngineInterface, cwd: string): Promise<Git | null> {
12 const git = (...args: string[]) => $.process.run(['git', ...args], { cwd })
13 try {
14 const [status, dirs, unstaged, staged] = await Promise.all([
15 git('status', '--porcelain=v2', '--branch', '--untracked-files=all'),
16 git('rev-parse', '--path-format=absolute', '--git-dir', '--git-common-dir'),
17 git('diff', '--numstat'),
18 git('diff', '--cached', '--numstat'),
19 ])
20 if (status.exitCode !== 0) return null
21 const [gitDir, commonDir] = dirs.stdout.trim().split('\n')
22 const a = sumNumstat(unstaged.stdout)
23 const b = sumNumstat(staged.stdout)
24 return {
25 ...parseStatus(status.stdout),
26 isWorktree: dirs.exitCode === 0 && gitDir !== commonDir,
27 added: a.added + b.added,
28 removed: a.removed + b.removed,
29 }
30 } catch {
31 return null // git is not installed, or it timed out
32 }
33}
34
35async function refresh($: EngineInterface) {
36 const [cwd, model, usage, now] = await Promise.all([
37 $.session.cwd(),
38 $.session.model(),
39 $.session.usage(),
40 $.clock.now(),
41 ])
42 const git = await readGit($, cwd)
43 const { tokens, window, percent } = usage.context
44 await update($, view, v => ({
45 ...v,
46 dir: cwd.split('/').filter(Boolean).at(-1) ?? cwd,
47 git,
48 model,
49 context: { tokens, window, percent },
50 rateLimits: usage.rateLimits.map(({ kind, percentUsed, resetsAt }) => ({ kind, percentUsed, resetsAt })),
51 now,
52 }))
53}
54
55// The effort in use, as Claude Code passes it to Bash. The effortLevel setting can differ: Claude Code may step it down.
56async function readEffort($: EngineInterface) {
57 const effort = await $.env.get('CLAUDE_EFFORT')
58 if (effort) await update($, view, v => ({ ...v, effort }))
59}
60
61const HOTKEYS = { model: 'm', effort: 'e' } as const
62
63export const register: Register = on => {
64 on('session.start', async ($, e, next) => {
65 const result = await next(e)
66 await refresh($)
67 await readEffort($)
68 // Keeps the reset countdowns and the git chip current between turns.
69 $.clock.every(30_000, () => void refresh($))
70 return result
71 })
72
73 on('turn.complete', async ($, e, next) => {
74 const result = await next(e)
75 if (e.agentId === undefined) await refresh($)
76 return result
77 })
78
79 // /model and /effort have no change event: read the model again once the command ends.
80 // The effort label waits for the next model request (turn.step).
81 on('command.run', { command: 'model' }, async ($, e, next) => {
82 const result = await next(e)
83 await refresh($)
84 return result
85 })
86
87 on('command.run', { command: 'effort' }, async ($, e, next) => {
88 const result = await next(e)
89 await refresh($)
90 return result
91 })
92
93 on('session.measure', async ($, e, next) => {
94 const now = await $.clock.now()
95 const { tokens, window, percent } = e.context
96 await update($, view, v => ({
97 ...v,
98 context: { tokens, window, percent },
99 rateLimits: e.rateLimits.map(({ kind, percentUsed, resetsAt }) => ({ kind, percentUsed, resetsAt })),
100 now,
101 }))
102 return next(e)
103 })
104
105 // The effort a model request used. It can differ from the setting (--effort, a model without effort).
106 on('turn.step', async function* ($, e, next) {
107 if (e.agentId === undefined) {
108 const effort = e.effort === undefined ? undefined : String(e.effort)
109 await update($, view, v => (v.effort === effort ? v : { ...v, effort }))
110 }
111 return yield* next(e)
112 })
113
114 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
115 if (e.props.hasSurvey) return next(e)
116 const v = await read($, view)
117 if (!v.dir) return next(e)
118
119 const { Box, Button, Text } = $.ui.resolve(e)
120 const seg = (s: Seg) => (
121 <Text color={s.color} backgroundColor={s.bg} dimColor={s.dim} bold={s.bold}>
122 {s.text}
123 </Text>
124 )
125 // Desktop draws each bar as an SVG. The terminal draws it as block characters.
126 const Svg = e.surface === 'desktop' ? $.ui.resolve(e).Svg : undefined
127 const group = (items: Item[]) =>
128 Svg ? (
129 <Box flexDirection="row" alignItems="center">
130 {items.map(item =>
131 'bar' in item ? (
132 <Svg
133 source={barSvg(item.bar, item.bar.width * 7, 8)}
134 alt={`${Math.floor(item.bar.pct)}%`}
135 width={item.bar.width * 7}
136 height={8}
137 />
138 ) : (
139 seg(item)
140 ),
141 )}
142 </Box>
143 ) : items.some(item => 'button' in item && item.button) ? (
144 // The model name opens /model, the effort opens /effort.
145 <Box flexDirection="row">
146 {items.map(item => {
147 if ('bar' in item) return null
148 const command = item.button
149 if (!command) return seg(item)
150 // A Button takes no color, so a dot in front of it carries the color.
151 return (
152 <Box flexDirection="row">
153 {item.color ? <Text color={item.color}>● </Text> : null}
154 <Button
155 key={command}
156 label={item.text}
157 hotkey={HOTKEYS[command]}
158 plain
159 dimColor={item.dim}
160 onPress={async () => {
161 // A plugin's own call skips its command.run hook, so read again here.
162 await $.command.run({ command })
163 await refresh($)
164 }}
165 />
166 </Box>
167 )
168 })}
169 </Box>
170 ) : (
171 <Text wrap="truncate">{items.flatMap(item => ('bar' in item ? barSegs(item.bar) : [item])).map(seg)}</Text>
172 )
173
174 // The desktop font has no fixed width, so its lines still wrap if the count is short.
175 return (
176 <Box flexDirection="column">
177 {lines(row(v), e.props.bodyColumns, !Svg).map(line => (
178 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
179 {line.map(group)}
180 </Box>
181 ))}
182 </Box>
183 )
184 })
185}
186hooks/format.ts 290 lines1// Pure layout logic: turns a View into colored segments, one array per line.
2// Same rules as statusline-command.sh (v1).
3import type { Git, View } from '../types'
4
5// button: the terminal draws this segment as a Button that runs that slash command.
6export type Seg = { text: string; color?: string; bg?: string; dim?: boolean; bold?: boolean; button?: 'model' | 'effort' }
7
8export const C = {
9 blue: '#0099ff',
10 orange: '#ffb055',
11 green: '#00a000',
12 cyan: '#2e9599',
13 red: '#ff5555',
14 yellow: '#e6c800',
15 white: '#dcdcdc',
16 dirBg: '#0087ff',
17 branchBg: '#ff8700',
18 worktreeBg: '#875fff',
19 chipText: '#000000',
20 dirText: '#ffffff',
21}
22
23const HOUR = 3_600_000
24const WINDOWS: Record<string, { label: string; ms: number }> = {
25 five_hour: { label: '5h', ms: 5 * HOUR },
26 seven_day: { label: '7d', ms: 7 * 24 * HOUR },
27}
28
29const byLevel = (pct: number) =>
30 pct >= 90 ? C.red : pct >= 70 ? C.yellow : pct >= 50 ? C.orange : undefined
31
32// Joins a segment into the previous one when the style is the same.
33function push(segs: Seg[], seg: Seg) {
34 const last = segs.at(-1)
35 if (last && last.color === seg.color && last.dim === seg.dim && !last.bg && !seg.bg) {
36 last.text += seg.text
37 } else {
38 segs.push({ ...seg })
39 }
40}
41
42// A progress bar. pace: a number colors the fill by pace delta, 'gradient' by
43// position, undefined by overall usage. The track is colored by overall usage.
44export type Bar = { pct: number; width: number; pace?: number | 'gradient' }
45export type Item = Seg | { bar: Bar }
46// sep: a "|" goes before the group when it shares a line with the group before.
47export type Group = { items: Item[]; sep?: boolean }
48
49const clamp = (pct: number) => Math.min(100, Math.max(0, pct))
50const BANDS: [number, number, string][] = [
51 [0, 50, C.green],
52 [50, 70, C.orange],
53 [70, 90, C.yellow],
54 [90, 100, C.red],
55]
56
57function fillColor({ pct, pace }: Bar, pos: number): string {
58 const p = Math.floor(clamp(pct))
59 if (pace === 'gradient') return byLevel(pos) ?? C.green
60 if (pace !== undefined) {
61 const above = p - pace
62 return above < 0 ? C.blue : above <= 20 ? C.green : above <= 50 ? C.yellow : C.red
63 }
64 return byLevel(p) ?? C.green
65}
66
67const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
68
69// Terminal: full cells, one partial cell in 1/8 steps, then the track.
70export function barSegs(bar: Bar): Seg[] {
71 const { width } = bar
72 const eighths = Math.round((clamp(bar.pct) * width * 8) / 100)
73 const full = Math.floor(eighths / 8)
74 const part = EIGHTHS[eighths % 8] ?? ''
75 const segs: Seg[] = []
76 for (let i = 0; i < full; i++) push(segs, { text: '█', color: fillColor(bar, Math.floor(((i + 1) * 100) / width)) })
77 if (part) push(segs, { text: part, color: fillColor(bar, Math.floor(((full + 1) * 100) / width)) })
78 const track = byLevel(Math.floor(clamp(bar.pct)))
79 const rest = '░'.repeat(width - full - (part ? 1 : 0))
80 if (rest) push(segs, track ? { text: rest, color: track } : { text: rest, dim: true })
81 return segs
82}
83
84// Desktop: an SVG bar with rounded ends, exact to the pixel.
85export function barSvg(bar: Bar, w: number, h: number): string {
86 const p = clamp(bar.pct)
87 const pieces =
88 bar.pace === 'gradient'
89 ? BANDS.filter(([from]) => from < p).map(([from, to, color]): [number, number, string] => [from, Math.min(to, p), color])
90 : [[0, p, fillColor(bar, p)] as [number, number, string]]
91 const x = (pct: number) => +((pct * w) / 100).toFixed(2)
92 const rects = pieces
93 .map(([from, to, color]) => `<rect x="${x(from)}" width="${x(to) - x(from)}" height="${h}" fill="${color}"/>`)
94 .join('')
95 return (
96 `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">` +
97 `<clipPath id="r"><rect width="${w}" height="${h}" rx="${h / 2}"/></clipPath>` +
98 `<g clip-path="url(#r)"><rect width="${w}" height="${h}" fill="${byLevel(Math.floor(p)) ?? '#808080'}" fill-opacity="0.3"/>${rects}</g></svg>`
99 )
100}
101
102// How far through the window we are, 0-100.
103export function paceOf(resetsAt: number, windowMs: number, now: number): number {
104 const elapsed = Math.min(windowMs, Math.max(0, now - (resetsAt - windowMs)))
105 return Math.round((elapsed / windowMs) * 100)
106}
107
108// "45min", "2h 30min", "3h", "3d 2h", "6d", "now"
109export function countdown(resetsAt: number, now: number): string {
110 const secs = Math.floor((resetsAt - now) / 1000)
111 if (secs <= 0) return 'now'
112 const mins = Math.ceil(secs / 60)
113 if (mins < 60) return `${mins}min`
114 if (mins < 1440) {
115 const h = Math.floor(mins / 60)
116 const m = mins % 60
117 return m === 0 ? `${h}h` : `${h}h ${m}min`
118 }
119 const d = Math.floor(mins / 1440)
120 const h = Math.floor((mins % 1440) / 60)
121 return h === 0 ? `${d}d` : `${d}d ${h}h`
122}
123
124const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
125
126// "1:10pm" under 24h, "Sun 10:45am" after. Local time.
127export function clockTime(resetsAt: number, now: number): string {
128 if (resetsAt <= now) return ''
129 const d = new Date(resetsAt)
130 const h = d.getHours()
131 const time = `${h % 12 || 12}:${String(d.getMinutes()).padStart(2, '0')}${h < 12 ? 'am' : 'pm'}`
132 return resetsAt - now < 24 * HOUR ? time : `${DAYS[d.getDay()]} ${time}`
133}
134
135// 950 -> "950", 118000 -> "118k", 1000000 -> "1m", 1500000 -> "1.5m"
136export function tokens(n: number): string {
137 if (n >= 1_000_000) {
138 const v = n / 1_000_000
139 return Number.isInteger(v) ? `${v}m` : `${v.toFixed(1)}m`
140 }
141 if (n >= 1000) return `${Math.round(n / 1000)}k`
142 return `${n}`
143}
144
145// Reads `git status --porcelain=v2 --branch --untracked-files=all`.
146export function parseStatus(out: string): Pick<Git, 'branch' | 'ahead' | 'behind' | 'untracked'> {
147 let head = ''
148 let oid = ''
149 let ahead = 0
150 let behind = 0
151 let untracked = 0
152 for (const line of out.split('\n')) {
153 if (line.startsWith('# branch.head ')) head = line.slice(14)
154 else if (line.startsWith('# branch.oid ')) oid = line.slice(13)
155 else if (line.startsWith('# branch.ab ')) {
156 const [a, b] = line.slice(12).split(' ')
157 ahead = Math.abs(Number(a)) || 0
158 behind = Math.abs(Number(b)) || 0
159 } else if (line.startsWith('? ')) untracked++
160 }
161 const branch = head === '(detached)' ? oid.slice(0, 7) : head
162 return { branch, ahead, behind, untracked }
163}
164
165// Sums `git diff --numstat` output. Binary files ("-\t-") count as zero.
166export function sumNumstat(out: string): { added: number; removed: number } {
167 let added = 0
168 let removed = 0
169 for (const line of out.split('\n')) {
170 const [a, r] = line.split('\t')
171 added += Number(a) || 0
172 removed += Number(r) || 0
173 }
174 return { added, removed }
175}
176
177// More effort, a warmer color. A level this list does not know stays cyan.
178const EFFORT_COLORS: Record<string, string> = {
179 low: C.blue,
180 medium: C.green,
181 high: C.yellow,
182 xhigh: C.orange,
183 max: C.red,
184}
185
186export const effortColor = (level: string) => EFFORT_COLORS[level] ?? C.cyan
187
188// "claude-opus-5-5" -> "Opus 5.5". Other names stay as they are.
189export function modelName(id: string): string {
190 const m = /^claude-([a-z]+)-(\d+)-(\d+)(?:\D|$)/.exec(id)
191 if (!m || !m[1]) return id
192 return `${m[1][0]?.toUpperCase()}${m[1].slice(1)} ${m[2]}.${m[3]}`
193}
194
195// The groups of the status line, in order.
196export function row(v: View): Group[] {
197 const groups: Group[] = []
198 if (v.dir) groups.push({ items: [{ text: ` ${v.dir} `, color: C.dirText, bg: C.dirBg, bold: true }] })
199
200 const g = v.git
201 if (g && g.branch) {
202 let label = g.isWorktree ? ` ⎇ ${g.branch}` : ` ${g.branch}`
203 if (g.ahead > 0) label += ` ↑${g.ahead}`
204 if (g.behind > 0) label += ` ↓${g.behind}`
205 if (g.added > 0 || g.removed > 0) label += ` +${g.added} -${g.removed}`
206 if (g.untracked > 0) label += ` ?${g.untracked}`
207 groups.push({ items: [{ text: `${label} `, color: C.chipText, bg: g.isWorktree ? C.worktreeBg : C.branchBg, bold: true }] })
208 }
209
210 if (v.model) {
211 const model: Item[] = [{ text: modelName(v.model), dim: true, button: 'model' }]
212 if (v.effort) model.push({ text: ' · ', dim: true }, { text: v.effort, color: effortColor(v.effort), button: 'effort' })
213 groups.push({ items: model })
214 }
215
216 const ctxPct = Math.floor(v.context.percent ?? 0)
217 const { tokens: used, window } = v.context
218 const hasTokens = used !== undefined && window > 0
219 // tokens / window is finer than the whole-number percent.
220 const ctxFill = hasTokens ? (used / window) * 100 : ctxPct
221 const context: Item[] = [
222 { text: 'ctx ', color: C.white },
223 { bar: { pct: ctxFill, width: 15, pace: 'gradient' } },
224 { text: ` ${ctxPct}%`, color: C.cyan },
225 ]
226 if (hasTokens) context.push({ text: ` ${tokens(used)}/${tokens(window)}`, dim: true })
227 groups.push({ items: context, sep: groups.length > 0 || undefined })
228
229 for (const limit of v.rateLimits) {
230 const win = WINDOWS[limit.kind]
231 if (!win) continue
232 const pct = Math.floor(limit.percentUsed)
233 const resetsAt = limit.resetsAt ? Date.parse(limit.resetsAt) : NaN
234 const hasReset = !Number.isNaN(resetsAt)
235 const pace = hasReset ? paceOf(resetsAt, win.ms, v.now) : undefined
236 const group: Item[] = [
237 { text: `${win.label} `, color: C.white },
238 { bar: { pct: limit.percentUsed, width: 10, pace } },
239 { text: ` ${pct}%`, color: C.cyan },
240 ]
241 if (hasReset) {
242 const parts = [countdown(resetsAt, v.now), clockTime(resetsAt, v.now)].filter(Boolean)
243 group.push({ text: ` ( ${parts.join(' - ')} )`, dim: true })
244 }
245 groups.push({ items: group, sep: true })
246 }
247
248 return groups
249}
250
251const SEP: Item[] = [{ text: '|', dim: true }]
252
253// Columns a group takes. In the terminal a Button adds its hotkey ("m: "), and a colored one a dot ("● ").
254function width(items: Item[], isTerminal: boolean): number {
255 let n = 0
256 for (const item of items) {
257 if ('bar' in item) {
258 n += item.bar.width
259 continue
260 }
261 n += [...item.text].length
262 if (isTerminal && item.button) n += item.color ? 5 : 3
263 }
264 return n
265}
266
267// Packs the groups into lines of at most `columns`. A "|" goes only between two groups on one line.
268export function lines(groups: Group[], columns: number, isTerminal: boolean): Item[][][] {
269 const out: Item[][][] = []
270 let line: Item[][] = []
271 let used = 0
272 for (const group of groups) {
273 const w = width(group.items, isTerminal)
274 const sep = group.sep ? 2 : 0 // the "|" and its gap
275 if (line.length && used + 1 + sep + w > columns) {
276 out.push(line)
277 line = []
278 used = 0
279 }
280 if (line.length) {
281 if (group.sep) line.push(SEP)
282 used += 1 + sep
283 }
284 line.push(group.items)
285 used += w
286 }
287 if (line.length) out.push(line)
288 return out
289}
290types/index.d.ts 28 lines1export type Git = {
2 branch: string
3 isWorktree: boolean
4 ahead: number
5 behind: number
6 added: number
7 removed: number
8 untracked: number
9}
10
11export type RateLimit = { kind: string; percentUsed: number; resetsAt?: string }
12
13export type View = {
14 dir: string
15 git: Git | null
16 model: string
17 effort?: string
18 context: { tokens?: number; window: number; percent?: number }
19 rateLimits: RateLimit[]
20 now: number
21}
22
23declare module 'claude-code' {
24 interface PluginState {
25 'rich-statusline': { view: View }
26 }
27}
28