A Neovim-style status line in Catppuccin colors: git branch, changes, folder, model, context, cost, time

A Neovim-style status line for Claude Code, in Catppuccin Mocha colors. It replaces the hint line under the prompt:

With every segment showing:
⎇ main ●3 ↑1 │ ~/proj │ Opus 5.5 │ ctx 42% │ $0.31 │ 5h 20% │ 7d 55% │ 14:05
| Segment | Shows |
|---|---|
⎇ main ●3 ↑1 ↓2 | Git branch, changed files, commits ahead / behind |
~/proj | Current folder |
Opus 5.5 | Model |
ctx 42% | Context window used, green → yellow → peach → red as it fills |
$0.31 | Session cost (when available) |
5h 20% 7d 55% | Usage limits, colored the same way |
14:05 | Time, refreshed every 30 seconds |
In a narrow window it gives up the least useful parts first: the hint text, then the time, then the folder's parent path (~/work/proj → proj). It refits as you resize.
It refreshes on session start, after each turn, after Bash/Edit/Write tool calls (so branch switches show up), and on the clock.
/plugin marketplace add nasmarr/claude-mods
/plugin install vim-statusline@nasmarr-mods
⎇ ● ↑ ↓ │) are plain Unicode, so no special font is needed.hooks/register.tsx 89 lines1import { atom, read, update } from 'claude-code'
2import type { Register, EngineInterface } from 'claude-code'
3import { fitLine, parseGit, SEP, MOCHA, type Git } from './format.ts'
4
5const lineAtom = atom({ plugin: 'vim-statusline', key: 'line' } as const, null)
6
7const readGit = async ($: EngineInterface): Promise<Git | undefined> => {
8 try {
9 const r = await $.process.run(['git', 'status', '--porcelain=v2', '--branch'], { timeoutMs: 3000 })
10 return r.exitCode === 0 ? parseGit(r.stdout) : undefined
11 } catch {
12 return undefined
13 }
14}
15
16const clockTime = (ms: number): string => {
17 const d = new Date(ms)
18 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
19}
20
21const refresh = async ($: EngineInterface): Promise<void> => {
22 try {
23 await draw($)
24 } catch {
25 // A failed read leaves the last line up rather than breaking anything.
26 }
27}
28
29const draw = async ($: EngineInterface): Promise<void> => {
30 const [git, cwd, model, usage, now] = await Promise.all([
31 readGit($),
32 $.session.cwd(),
33 $.session.model(),
34 $.session.usage(),
35 $.clock.now(),
36 ])
37 const line = {
38 git,
39 cwd,
40 model,
41 contextPercent: usage.context.percent,
42 costUsd: usage.cost?.usd,
43 rateLimits: [...usage.rateLimits],
44 time: clockTime(now),
45 }
46 await update($, lineAtom, () => line)
47}
48
49export const register: Register = on => {
50 on('session.start', async ($, e, next) => {
51 const started = await next(e)
52 // The line is drawn under the prompt now; drop the old plain-text status.
53 $.ui.status(undefined)
54 void refresh($)
55 // Keeps the clock current and catches branch switches made outside Claude.
56 $.clock.every(30_000, () => void refresh($))
57 return started
58 })
59
60 on('session.measure', ($, e, next) => {
61 void refresh($)
62 return next(e)
63 })
64
65 // A command or an edit may have switched branches or changed files.
66 on('tool.call', async ($, e, next) => {
67 const result = await next(e)
68 if (e.tool === 'Bash' || e.tool === 'Edit' || e.tool === 'Write') void refresh($)
69 return result
70 }).catch(($, e, next) => next(e))
71
72 // Draws the line in place of the hint under the prompt, keeping the hint dim at the end
73 // while there's room. Redrawn on every width change, so it refits as the window resizes.
74 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
75 const line = await read($, lineAtom)
76 if (!line) return next(e)
77 const { segments, hint } = fitLine(line, e.props.hint, e.viewport?.columns ?? Infinity)
78 const { Text } = $.ui.resolve(e)
79 return (
80 <Text wrap="truncate">
81 {segments.map(s => (
82 <Text color={s.color}>{s.text}</Text>
83 ))}
84 {hint ? <Text color={MOCHA.overlay0}>{SEP}{hint}</Text> : null}
85 </Text>
86 )
87 })
88}
89hooks/format.ts 130 lines1import type { Segment, Git, Line } from '../types'
2
3export type { Git, Line }
4
5// Catppuccin Mocha.
6export const MOCHA = {
7 red: '#f38ba8',
8 peach: '#fab387',
9 yellow: '#f9e2af',
10 green: '#a6e3a1',
11 teal: '#94e2d5',
12 sky: '#89dceb',
13 blue: '#89b4fa',
14 lavender: '#b4befe',
15 mauve: '#cba6f7',
16 overlay0: '#6c7086',
17} as const
18
19export const SEP = ' │ '
20
21// Parses `git status --porcelain=v2 --branch`.
22export const parseGit = (out: string): Git | undefined => {
23 let branch = ''
24 let oid = ''
25 let ahead = 0
26 let behind = 0
27 let changed = 0
28 for (const line of out.split('\n')) {
29 if (line.startsWith('# branch.head ')) branch = line.slice(14)
30 else if (line.startsWith('# branch.oid ')) oid = line.slice(13, 20)
31 else if (line.startsWith('# branch.ab ')) {
32 const [a, b] = line.slice(12).split(' ')
33 ahead = Number(a)
34 behind = -Number(b)
35 } else if (line && !line.startsWith('#')) changed++
36 }
37 if (!branch) return undefined
38 if (branch === '(detached)') branch = oid ? `@${oid}` : 'detached'
39 return { branch, changed, ahead, behind }
40}
41
42const FAMILIES = ['opus', 'sonnet', 'haiku', 'fable']
43const capitalize = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1)
44
45// `claude-opus-5-5` → `Opus 5.5`. Handles date suffixes, `[1m]`, Bedrock/Vertex
46// prefixes and the older `claude-3-5-sonnet` order; anything else passes through.
47export const prettyModel = (id: string): string => {
48 const oneM = /\[1m\]$/i.test(id) ? ' 1M' : ''
49 const name = id
50 .toLowerCase()
51 .replace(/\[1m\]$/, '')
52 .replace(/^.*?claude-/, '')
53 .replace(/-v\d+(:\d+)?$/, '') // Bedrock `-v1:0`
54 .replace(/[-@]\d{8}$/, '') // date suffix, Vertex `@20250929`
55 const parts = name.split('-')
56 const family = parts.find(p => FAMILIES.includes(p))
57 const version = parts.filter(p => /^\d+$/.test(p) && p.length <= 2)
58 if (!family || !version.length) return id
59 return `${capitalize(family)} ${version.join('.')}${oneM}`
60}
61
62export const shortenPath =(path: string): string =>
63 path.replace(/^\/home\/[^/]+(?=\/|$)/, '~')
64
65const RATE_LIMIT_LABELS: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' }
66const shortRateLimit = (kind: string): string => RATE_LIMIT_LABELS[kind] ?? kind
67// Shortest window first: 5h, then 7d, then anything else.
68const rateLimitOrder = (kind: string): number => {
69 const i = Object.keys(RATE_LIMIT_LABELS).indexOf(kind)
70 return i === -1 ? Infinity : i
71}
72
73// Green while there's room, warming toward red as it fills.
74const fillColor = (percent: number): string =>
75 percent >= 85 ? MOCHA.red : percent >= 60 ? MOCHA.peach : percent >= 40 ? MOCHA.yellow : MOCHA.green
76
77// Each group is one `│`-separated section; segments within it sit side by side.
78export const formatGroups = (l: Line): Segment[][] => {
79 const groups: Segment[][] = []
80 if (l.git) {
81 const g: Segment[] = [{ text: `⎇ ${l.git.branch}`, color: MOCHA.mauve }]
82 if (l.git.changed) g.push({ text: ` ●${l.git.changed}`, color: MOCHA.peach })
83 if (l.git.ahead) g.push({ text: ` ↑${l.git.ahead}`, color: MOCHA.green })
84 if (l.git.behind) g.push({ text: ` ↓${l.git.behind}`, color: MOCHA.red })
85 groups.push(g)
86 }
87 groups.push([{ text: shortenPath(l.cwd), color: MOCHA.blue }])
88 if (l.model) groups.push([{ text: prettyModel(l.model), color: MOCHA.lavender }])
89 if (l.contextPercent !== undefined) {
90 const pct = Math.round(l.contextPercent)
91 groups.push([{ text: `ctx ${pct}%`, color: fillColor(pct) }])
92 }
93 if (l.costUsd !== undefined) groups.push([{ text: `$${l.costUsd.toFixed(2)}`, color: MOCHA.yellow }])
94 // Each usage window its own section, colored by how full it is.
95 const limits = [...(l.rateLimits ?? [])].sort((a, b) => rateLimitOrder(a.kind) - rateLimitOrder(b.kind))
96 for (const r of limits) {
97 const pct = Math.round(r.percentUsed)
98 groups.push([{ text: `${shortRateLimit(r.kind)} ${pct}%`, color: fillColor(pct) }])
99 }
100 if (l.time) groups.push([{ text: l.time, color: MOCHA.sky }])
101 return groups
102}
103
104// Flattens the groups with colored separators between them.
105export const formatSegments = (l: Line): Segment[] =>
106 formatGroups(l).flatMap((g, i) => (i ? [{ text: SEP, color: MOCHA.overlay0 }, ...g] : g))
107
108export const formatLine = (l: Line): string => formatSegments(l).map(s => s.text).join('')
109
110// Room the engine takes before the line: the indent and a mode label like `⏵⏵ auto mode on · `.
111export const LEAD = 22
112
113const cells = (s: string): number => [...s].length
114const segmentsWidth = (segs: Segment[]): number => segs.reduce((n, s) => n + cells(s.text), 0)
115
116// `~/work/claude-mods` → `claude-mods`.
117export const folderName = (path: string): string => shortenPath(path).split('/').filter(Boolean).pop() ?? '/'
118
119// Fits the line to the terminal, giving up the least useful parts first: the
120// hint, then the time, then the folder's parents. Past that the row truncates.
121export const fitLine = (l: Line, hint: string | undefined, columns: number): { segments: Segment[]; hint?: string } => {
122 const room = columns - LEAD
123 const full = formatSegments(l)
124 if (!hint || segmentsWidth(full) + cells(SEP + hint) <= room) return { segments: full, hint }
125 if (segmentsWidth(full) <= room) return { segments: full }
126 const noTime = formatSegments({ ...l, time: undefined })
127 if (segmentsWidth(noTime) <= room) return { segments: noTime }
128 return { segments: formatSegments({ ...l, time: undefined, cwd: folderName(l.cwd) }) }
129}
130types/index.d.ts 20 lines1export type Segment = { text: string; color: string }
2
3export type Git = { branch: string; changed: number; ahead: number; behind: number }
4
5export type Line = {
6 git?: Git
7 cwd: string
8 model?: string
9 contextPercent?: number
10 costUsd?: number
11 rateLimits?: { kind: string; percentUsed: number }[]
12 time?: string
13}
14
15declare module 'claude-code' {
16 interface PluginState {
17 'vim-statusline': { line: Line | null }
18 }
19}
20