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

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%.
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.
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.

Segment choice, colours and narrow-width behaviour are also covered by $.ui.mount tests.
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.
+n staged, ~n unstaged, ?n untracked, !n conflicts, ↑n ahead, ↓n behind, ✓ clean.bodyColumns. It never wraps.[bar, theirs] when another mod draws in the band, and just the bar otherwise.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
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.
claude -p draw nothing.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.None.
hooks/powerline-bar.mjs 131 lines1// 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