SLOPSHOPPER

vim-statusline

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

newspinnerguardstatusprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · vim-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 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › /work/app │ Opus 5.5 │ ctx 49% │ $0.42 │ 5h 31% │ 08:53 │ ? for shortcuts

Draws

Prompt hint
/work/app │ Opus 5.5 │ ctx 49% │ $0.42 │ 5h 31% │ 08:53 │ ? for shortcuts
README

vim-statusline

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

vim-statusline in Claude Code

With every segment showing:

⎇ main ●3 ↑1 │ ~/proj │ Opus 5.5 │ ctx 42% │ $0.31 │ 5h 20% │ 7d 55% │ 14:05
SegmentShows
⎇ main ●3 ↑1 ↓2Git branch, changed files, commits ahead / behind
~/projCurrent folder
Opus 5.5Model
ctx 42%Context window used, green → yellow → peach → red as it fills
$0.31Session cost (when available)
5h 20% 7d 55%Usage limits, colored the same way
14:05Time, 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.

Install

/plugin marketplace add nasmarr/claude-mods
/plugin install vim-statusline@nasmarr-mods

Notes

  • Colors are Catppuccin Mocha and look best on a dark terminal background.
  • The glyphs (⎇ ● ↑ ↓ │) are plain Unicode, so no special font is needed.
Source 3 files
hooks/register.tsx 89 lines
1import { 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}
89
hooks/format.ts 130 lines
1import 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}
130
types/index.d.ts 20 lines
1export 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