SLOPSHOPPER

rich-statusline

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

newbandprocesstimer
★ 4v2.1.0no licenseupdated 2026-10-07TheBabaYaga/claude-statusline/mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · rich-statusline
› 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 app m: Opus 5.5 | ctx ███████▎░░░░░░░ 49% 97k/200k | 5h ███▏░░░░░░ 31% ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
app m: Opus 5.5 | ctx ███████▎░░░░░░░ 49% 97k/200k | 5h ███▏░░░░░░ 31%
README

claude-statusline

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

preview

There are two versions:

VersionWhat it isWhere it shows
v1A shell script that Claude Code runs as its statusLine commandThe terminal, below the prompt
v2A 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.

Features

LineSegments
Line 1Directory, git branch with ahead/behind + +N -M lines + untracked count, model name + reasoning effort level
Line 2Context 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.

Worktree indicator

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:

worktree indicator

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.

Effort level

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.

Dot colors

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 positionColor
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 deltaColorMeaning
Below paceBlueUnder pace (healthy buffer)
0-20% aboveGreenWithin 20% of pace (on track)
20-50% aboveYellowOutpacing
50%+ aboveRedSignificantly outpacing

Empty dots (○) — all bars. Colored by overall usage:

UsageColor
<50%Dim
50-69%Orange
70-89%Yellow
90%+Red

Prerequisites

  • Claude Code CLI
  • jq — JSON processor
  • git — for branch/diff info (pre-installed on most systems)

Install

Quick install

git clone git@github.com:TheBabaYaga/claude-statusline.git
cd claude-statusline
./install.sh

The install script will:

  1. Install jq if missing (via Homebrew, apt, dnf, or pacman)
  2. Copy statusline-command.sh to ~/.claude/
  3. Configure ~/.claude/settings.json with the statusline command

Manual install

  1. Install jq:
   # macOS
   brew install jq

   # Debian/Ubuntu
   sudo apt-get install jq

   # Fedora
   sudo dnf install jq

   # Arch
   sudo pacman -S jq
  1. Copy the script:
   cp statusline-command.sh ~/.claude/statusline-command.sh
   chmod +x ~/.claude/statusline-command.sh
  1. Add to ~/.claude/settings.json:
   {
     "statusLine": {
       "type": "command",
       "command": "bash ~/.claude/statusline-command.sh"
     }
   }
  1. Restart Claude Code.

Updating

git pull && ./install.sh

v2: mod for the terminal and Claude Desktop

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:

SegmentSource
Directory, model$.session.cwd(), $.session.model()
Effort levelCLAUDE_EFFORT at session start, then the turn.step event
Git chipgit 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.

Install v2

  1. Add the marketplace and install the plugin:
   claude plugin marketplace add TheBabaYaga/claude-statusline
   claude plugin install rich-statusline@thebabayaga
  1. Start a new session.

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.

Update v2

claude plugin marketplace update thebabayaga
claude plugin update rich-statusline@thebabayaga

Then start a new session.

Develop v2

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.

How it works

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.

  • No network access. The script never contacts Anthropic (or anything else).
  • No credentials. No OAuth token, keychain lookup, or API key is read.
  • Input is validated. The working directory is rejected unless it's an absolute path, and rate-limit numbers are rejected unless they're plain non-negative integers — so nothing untrusted reaches shell arithmetic.

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.

License

MIT

Source 3 files
hooks/register.tsx 186 lines
1import { 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}
186
hooks/format.ts 290 lines
1// 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}
290
types/index.d.ts 28 lines
1export 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