SLOPSHOPPER

powerline-bar

A one-row powerline-style band above the prompt: directory, git branch and state, model, context fill.

newbandprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · powerline-bar
› 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 claude-opus-5-5 ctx 49% ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
app claude-opus-5-5 ctx 49%
README

Powerline Bar

A one-row coloured band above the prompt: working directory, git branch with state glyphs, model, and context fill.

 app   ⎇ main   +2 ~2 ?1 ↑2 ↓1   sonnet   ctx 42%

Each segment is a block with its own background. The git blocks are green when clean, yellow when dirty (staged, unstaged or untracked files) and red on merge conflicts. The context block turns yellow at 60% and red at 85%.

What this shows

ui.render for the AbovePrompt site, a cached refresh driven by session.start, turn.complete and $.clock.every, one git status run through $.process.run, and a band that composes with other mods' bands.

Demo

Captured in a terminal on Claude Code 2.1.289: directory, git branch with a clean tick, model, and (once a turn has run) context fill.

powerline-bar above the prompt

Segment choice, colours and narrow-width behaviour are also covered by $.ui.mount tests.

How it works

refresh() reads the directory, model and context, and runs git status --porcelain=v2 --branch once. It caches the result and redraws only when it changed. It runs on session.start, after every turn.complete, and every 5 seconds. Drawing never spawns git.

  • Branch: the branch name, or the short SHA when HEAD is detached.
  • Git glyphs: +n staged, ~n unstaged, ?n untracked, !n conflicts, ↑n ahead, ↓n behind, ✓ clean.
  • Narrow terminals: segments are dropped from the right until the row fits bodyColumns. It never wraps.
  • Other bands: the bar returns a column of [bar, theirs] when another mod draws in the band, and just the bar otherwise.
  • Errors: a missing git, a non-repo directory or a 3 s timeout hides the git blocks. The draw hook catches everything and falls back to next(e).

Config: segments, a comma-separated list in order, default cwd,branch,git,model,context. Set it with /config or pluginConfigs.

What claude plugin validate reports:

hooks: session.start, turn.complete, ui.render{component=AbovePrompt}
calls: $.clock.every, $.env.get (via refresh), $.process.run (via refresh), $.session.cwd (via refresh), $.session.model (via refresh), $.session.usage (via refresh), $.ui.invalidate (via refresh), $.ui.resolve

Run it

Requires Claude Code 2.1.287 or later and git on PATH.

claude --plugin-dir ./mods/powerline-bar        # one session
claude plugin marketplace add justmalhar/awesome-claude-mods
claude plugin install powerline-bar@awesome-claude-mods --scope user

Test it: claude plugin test mods/powerline-bar.

Notes / limitations

  • Draws in the terminal and the Desktop Code tab only. VS Code and claude -p draw nothing.
  • Uses the AbovePrompt band, which every mod shares. This one composes: it stacks above whatever mods after it draw. A mod that returns a tree without calling next replaces this bar if it runs first.
  • Plain Unicode, no Nerd Font arrows. The colour change is the separator.
  • Data is up to 5 s stale between turns. A refresh already running when a turn ends skips that turn's refresh.
  • The model name is shown as the session reports it. Context shows nothing until the first response.
  • Needs a truecolor or 256-colour terminal for the block colours.

Dependencies

None.

Source 1 files
hooks/powerline-bar.mjs 131 lines
1// Powerline Bar: one coloured row in the AbovePrompt band.
2// Everything the band shows is gathered by refresh() and cached; drawing only reads the cache.
3
4const DEFAULT_SEGMENTS = 'cwd,branch,git,model,context'
5const GREEN = '#2e7d32'
6const YELLOW = '#f9a825'
7const RED = '#c62828'
8const BLUE = '#1565c0'
9const GREY = '#455a64'
10const PURPLE = '#6a1b9a'
11
12let cache = { cwd: '', home: '', model: '', percent: undefined, git: null }
13let busy = false
14
15// One `git status --porcelain=v2 --branch` -> counts. null when not a repo.
16function parseGit(out) {
17  const g = { branch: '', staged: 0, unstaged: 0, untracked: 0, conflicts: 0, ahead: 0, behind: 0 }
18  let sha = ''
19  for (const line of out.split('\n')) {
20    if (line.startsWith('# branch.oid ')) sha = line.slice(13, 20)
21    else if (line.startsWith('# branch.head ')) g.branch = line.slice(14)
22    else if (line.startsWith('# branch.ab ')) {
23      const m = /\+(\d+) -(\d+)/.exec(line)
24      if (m) [g.ahead, g.behind] = [Number(m[1]), Number(m[2])]
25    } else if (line[0] === '1' || line[0] === '2') {
26      if (line[2] !== '.') g.staged++
27      if (line[3] !== '.') g.unstaged++
28    } else if (line[0] === 'u') g.conflicts++
29    else if (line[0] === '?') g.untracked++
30  }
31  if (g.branch === '(detached)') g.branch = sha
32  return g.branch ? g : null
33}
34
35// Gather cwd, model, context and git. Never throws; a failed piece is just left out.
36async function refresh($) {
37  if (busy) return
38  busy = true
39  try {
40    const snap = { cwd: '', home: '', model: '', percent: undefined, git: null }
41    try { snap.cwd = String(await $.session.cwd()) } catch {}
42    try { snap.home = String((await $.env.get('HOME')) ?? '') } catch {}
43    try { snap.model = String((await $.session.model()) ?? '') } catch {}
44    try { snap.percent = (await $.session.usage())?.context?.percent } catch {}
45    try {
46      const r = await $.process.run(['git', 'status', '--porcelain=v2', '--branch'], { cwd: snap.cwd || undefined, timeoutMs: 3000 })
47      if (r.exitCode === 0) snap.git = parseGit(r.stdout)
48    } catch {} // no git, not a repo, or timed out: hide the git segments
49    const changed = JSON.stringify(snap) !== JSON.stringify(cache)
50    cache = snap
51    if (changed) await $.ui.invalidate('ui.render')
52  } catch {} finally {
53    busy = false
54  }
55}
56
57function cwdLabel() {
58  const { cwd, home } = cache
59  if (!cwd) return ''
60  if (home && cwd === home) return '~'
61  return cwd.split('/').filter(Boolean).pop() || '/'
62}
63
64// [text, background] for each requested segment that has something to show.
65function segmentsFor(names) {
66  const g = cache.git
67  const dirty = g && (g.staged || g.unstaged || g.untracked)
68  const gitBg = !g ? GREY : g.conflicts ? RED : dirty ? YELLOW : GREEN
69  const out = []
70  for (const name of names) {
71    if (name === 'cwd' && cwdLabel()) out.push([cwdLabel(), BLUE])
72    else if (name === 'branch' && g) out.push(['⎇ ' + g.branch, gitBg])
73    else if (name === 'git' && g) {
74      const bits = []
75      if (g.conflicts) bits.push('!' + g.conflicts)
76      if (g.staged) bits.push('+' + g.staged)
77      if (g.unstaged) bits.push('~' + g.unstaged)
78      if (g.untracked) bits.push('?' + g.untracked)
79      if (g.ahead) bits.push('↑' + g.ahead)
80      if (g.behind) bits.push('↓' + g.behind)
81      out.push([bits.join(' ') || '✓', gitBg])
82    } else if (name === 'model' && cache.model) out.push([cache.model, PURPLE])
83    else if (name === 'context' && typeof cache.percent === 'number') {
84      const p = cache.percent
85      out.push(['ctx ' + p + '%', p >= 85 ? RED : p >= 60 ? YELLOW : GREY])
86    }
87  }
88  return out
89}
90
91export function register(on, options) {
92  const names = String(options?.segments || DEFAULT_SEGMENTS).split(',').map((s) => s.trim())
93
94  on('session.start', async ($, e, next) => {
95    refresh($)
96    $.clock.every(5000, () => refresh($))
97    return next(e)
98  })
99
100  // Branch and counts move when Claude edits files.
101  on('turn.complete', async ($, e, next) => {
102    await refresh($)
103    return next(e)
104  })
105
106  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
107    try {
108      const theirs = await next(e)
109      const segs = segmentsFor(names)
110      if (!segs.length) return theirs
111      // Narrow terminal: drop segments from the right instead of wrapping.
112      const room = e.props?.bodyColumns ?? Infinity
113      let used = 0
114      const fit = segs.filter(([text], i) => (used += text.length + 2) <= room || i === 0)
115      const { Box, Text } = $.ui.resolve(e)
116      // ponytail: coloured blocks, no Nerd Font arrows; the colour change is the separator.
117      const mine = Box({
118        flexDirection: 'row',
119        children: fit.map(([text, bg], i) =>
120          Text({ key: 'seg-' + i, color: '#ffffff', backgroundColor: bg, bold: i === 0, wrap: 'truncate', children: [' ' + text + ' '] }),
121        ),
122      })
123      // ponytail: an engine ref is the engine's own (empty) AbovePrompt drawing, so skip it; only a mod's tree stacks under the bar.
124      if (!theirs || theirs.type === 'engine') return mine
125      return Box({ flexDirection: 'column', children: [mine, theirs] })
126    } catch {
127      return next(e) // a draw hook must never throw
128    }
129  })
130}
131