Port of statuslineV4.sh to a mod: draws the same figures in two colored rows (dir, git, model, effort | duration, version, session id / rate limits, context…

A mod that ports claude-code/statuslines/statuslineV4.sh into the band above the prompt, so the Claude Desktop Code tab (which doesn't run statusLine scripts) gets the same figures in two rows:
statusline-band ⟡ main ⟡ Opus 5.5 ⟡ high 2m ⟡ v2.1.286 ⟡ f00a9b5b…afddad
5h 13% · 7d 26% ⟡ ctx 8% ⟡ $0.77 ⟡ Caveman RAM 2.03GB (6 · 2.9%)
Each row puts its main figures on the left and pushes the rest to the right edge. Each figure is drawn in its own color, labels included. The 5h/7d labels and the RAM process count are a shade darker, and the ⟡ separators are dimmed. Colors are the script's xterm-256 palette converted to hex. A non-empty NO_COLOR turns them off.
The session id is a button. In the terminal, pressing it copies the full id. The Desktop app doesn't let mods write to the clipboard yet, so there it shows the full id in a toast instead.
/plugin marketplace add zakattack9/agentic-coding
/plugin install statusline-band@zaksak
Requires a Claude Code build with mods. Tested on v2.1.286 in the Desktop Code tab.
Set via /plugin configure statusline-band@zaksak or /config:
| Option | Default | What it does |
|---|---|---|
show_in_terminal | false | Also draw the band in terminal sessions. Off so the CLI's real status line isn't doubled. |
dir_levels | 3 | Trailing path components shown; 0 shows the full path. |
refresh_seconds | 5 | How often git, RAM and duration refresh between turns. |
first8…last6. Duration and output style keep each other's old colors.turn.step), and is blank for a model without effort. Before the first request, or after /effort or /model changes between turns, it comes from the /config row until the next request./config row, matched by key name. It shows default when not found.— until the first response of a fresh or just-compacted session, where the script showed 0%.0% once a window's reset time has passed, since the engine's reading is from the last response. A gateway's spend_limit shows as spend. With no reading at all, the segment is left out instead of saying No ongoing session.$0.00./clear still resets it.claude for this user, as the script does: all Desktop Code tabs and terminal sessions, not just this one. This session's engine is always included, and the Desktop app's disclaimer wrapper is skipped.claude plugin validate claude-code/plugins/statusline-band
claude plugin test claude-code/plugins/statusline-band
claude --plugin-dir claude-code/plugins/statusline-bandhooks/register.tsx 288 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import {
4 formatEffort,
5 formatRam,
6 joinSide,
7 type Limit,
8 livePercent,
9 mergePids,
10 PALETTE,
11 parseEtime,
12 type Piece,
13 rows,
14 shade,
15 shortenDir,
16 shortenModel,
17 type Snapshot,
18 xterm256,
19} from './format'
20
21// Options, set by register() from the manifest's userConfig.
22let showInTerminal = false
23let dirLevels = 3
24let refreshMs = 5000
25
26// What the band draws, rebuilt by refresh() and read by the render hook.
27let snap: Snapshot | null = null
28let useColor = true
29// Effort from the main loop's last request: undefined until the first one
30// (the /config row stands in), '' for a model that takes no effort.
31let liveEffort: string | undefined
32// The /config effort row and the model as last read, so a change to either
33// between turns drops the last request's effort.
34let lastCfgEffort: string | undefined
35let lastModel: string | undefined
36let refreshing = false
37let pending = false
38
39const LIMIT_LABELS: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' }
40
41async function run($: EngineInterface, argv: string[], cwd?: string) {
42 try {
43 return await $.process.run(argv, { cwd, timeoutMs: 5000, env: { LC_ALL: 'C' } })
44 } catch {
45 return null
46 }
47}
48
49async function gitBranch($: EngineInterface, cwd: string) {
50 const inRepo = await run($, ['git', 'rev-parse', '--git-dir'], cwd)
51 if (!inRepo || inRepo.exitCode !== 0) return 'no git'
52 const branch = await run($, ['git', 'branch', '--show-current'], cwd)
53 const name = branch?.exitCode === 0 ? branch.stdout.trim() : ''
54 if (name) return name
55 const head = await run($, ['git', 'rev-parse', '--short', 'HEAD'], cwd)
56 return head?.exitCode === 0 ? head.stdout.trim() : ''
57}
58
59// The engine process this session runs in: the parent of a shell it spawns,
60// kept only when that program is named "claude" (the script's ppid match).
61// Its elapsed time is how long this run has been up.
62async function engineProcess($: EngineInterface) {
63 const sh = await run($, ['sh', '-c', 'echo $PPID'])
64 const pid = sh?.exitCode === 0 ? sh.stdout.trim() : ''
65 if (!/^\d+$/.test(pid)) return null
66 const ps = await run($, ['ps', '-o', 'etime=,comm=', '-p', pid])
67 const m = ps?.exitCode === 0 ? /^\s*(\S+)\s+(.+?)\s*$/.exec(ps.stdout) : null
68 if (!m || !/(^|\/)claude$/.test(m[2] ?? '')) return null
69 return { pid, uptimeMs: parseEtime(m[1] ?? '') }
70}
71
72// Same process match as the script: name "claude", a command line whose
73// program path ends in /claude, or this session's own engine.
74async function ramUsage($: EngineInterface, enginePid: string | undefined) {
75 const byName = await run($, ['pgrep', '-x', 'claude'])
76 const byPath = await run($, ['pgrep', '-fx', '.*/claude([[:space:]].*)?$'])
77 const pids = mergePids(byName?.stdout ?? '', byPath?.stdout ?? '', enginePid ?? '')
78 if (pids.length === 0) return formatRam('')
79 const ps = await run($, ['ps', '-o', '%mem=,rss=,comm=', '-p', pids.join(',')])
80 return formatRam(ps?.stdout ?? '')
81}
82
83// Effort and output style from the engine's own /config rows (a plugin's
84// userConfig field named like them is skipped), matched loosely by key.
85async function configValues($: EngineInterface) {
86 let effort = ''
87 let outputStyle = ''
88 try {
89 for (const row of await $.config.list()) {
90 if (row.provider.plugin !== 'engine' || typeof row.value !== 'string') continue
91 const key = row.key.toLowerCase()
92 if (!effort && key.includes('effort')) effort = row.value
93 if (!outputStyle && key.replace(/[^a-z]/g, '').includes('outputstyle')) outputStyle = row.value
94 }
95 } catch {
96 // No config rows here: leave both empty.
97 }
98 return { effort, outputStyle }
99}
100
101async function collect($: EngineInterface): Promise<Snapshot> {
102 const [cwd, model, id, version, usage, home, cfg, now, proc] = await Promise.all([
103 $.session.cwd(),
104 $.session.model(),
105 $.session.id(),
106 $.session.version(),
107 $.session.usage(),
108 $.env.get('HOME'),
109 configValues($),
110 $.clock.now(),
111 engineProcess($),
112 ])
113 const [git, ram] = await Promise.all([gitBranch($, cwd), ramUsage($, proc?.pid)])
114
115 // A /config effort or /model change since the last read beats the last
116 // request's effort until the next request reports its own.
117 if (lastCfgEffort !== undefined && cfg.effort !== lastCfgEffort) liveEffort = undefined
118 if (lastModel !== undefined && model !== lastModel) liveEffort = undefined
119 lastCfgEffort = cfg.effort
120 lastModel = model
121 const effort = liveEffort ?? formatEffort(cfg.effort)
122
123 const limits: Limit[] = []
124 for (const kind of ['five_hour', 'seven_day', 'spend_limit']) {
125 const r = usage.rateLimits.find(x => x.kind === kind)
126 if (r) limits.push({ label: LIMIT_LABELS[kind] ?? kind, pct: livePercent(r.percentUsed, r.resetsAt, now) })
127 }
128
129 // usage.startedAt is a resumed session's first launch, so count from this
130 // run's start instead; /clear moves startedAt past it and wins.
131 const runStart = proc?.uptimeMs !== undefined ? now - proc.uptimeMs : usage.startedAt
132 return {
133 dir: shortenDir(cwd, home, dirLevels),
134 git,
135 model: shortenModel(model),
136 effort,
137 version: version.version,
138 sessionId: id,
139 outputStyle: cfg.outputStyle || 'default',
140 ctxPct: usage.context.percent,
141 costUsd: usage.cost?.usd,
142 durationMs: now - Math.max(runStart, usage.startedAt),
143 limits,
144 ram,
145 }
146}
147
148// One refresh at a time; a trigger that lands mid-refresh runs one more after.
149// A failed collect keeps the last snapshot.
150async function refresh($: EngineInterface) {
151 if (refreshing) {
152 pending = true
153 return
154 }
155 refreshing = true
156 try {
157 do {
158 pending = false
159 try {
160 snap = await collect($)
161 $.ui.invalidate('ui.render')
162 } catch {
163 // Keep drawing the last snapshot.
164 }
165 } while (pending)
166 } finally {
167 refreshing = false
168 }
169}
170
171export const register: Register = (on, options) => {
172 showInTerminal = options.show_in_terminal === true
173 if (typeof options.dir_levels === 'number') dirLevels = options.dir_levels
174 if (typeof options.refresh_seconds === 'number') refreshMs = Math.max(1, options.refresh_seconds) * 1000
175
176 on('session.start', async ($, e, next) => {
177 // Like the script's `[ -n "$NO_COLOR" ]`: an empty value keeps color.
178 useColor = !(await $.env.get('NO_COLOR').catch(() => undefined))
179 $.clock.every(refreshMs, () => {
180 void refresh($)
181 })
182 // Awaited so the band is up by the first prompt; a failed collect is
183 // caught inside refresh, and the timer above runs regardless.
184 await refresh($)
185 return next(e)
186 })
187
188 // The status line's own triggers: a finished main turn, a usage change, /clear.
189 on('turn.complete', async ($, e, next) => {
190 const result = await next(e)
191 if (e.agentId === undefined) void refresh($)
192 return result
193 })
194
195 on('session.measure', async ($, e, next) => {
196 void refresh($)
197 return next(e)
198 })
199
200 on('classic.SessionStart', async ($, e, next) => {
201 void refresh($)
202 return next(e)
203 })
204
205 // The main loop's effort as each request goes out (subagents skipped).
206 on('turn.step', async function* ($, e, next) {
207 if (e.agentId === undefined) {
208 const effort = formatEffort(e.effort)
209 if (effort !== liveEffort) {
210 liveEffort = effort
211 void refresh($)
212 }
213 }
214 return yield* next(e)
215 })
216
217 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
218 if (e.props.hasSurvey || snap === null) return next(e)
219 if (e.surface === 'terminal' && !showInTerminal) return next(e)
220
221 const { Box, Button, Text } = $.ui.resolve(e)
222 const color = (p: Piece) => {
223 if (!useColor || !p.seg) return undefined
224 const hex = xterm256(PALETTE[p.seg])
225 return p.isShaded ? shade(hex) : hex
226 }
227 const draw = (pieces: Piece[]) =>
228 pieces.map(p => (
229 <Text color={color(p)} dimColor={p.isDim === true}>
230 {p.text}
231 </Text>
232 ))
233
234 // Copies where the surface can; Desktop has no clipboard path for mods
235 // yet, so it gets the full id in a toast instead.
236 const copyButton = (p: Piece, text: string) => (
237 <Button
238 key="copy-session-id"
239 label={p.text}
240 plain
241 dimColor
242 onPress={async press => {
243 const result = await $.ui.copy({ text, surface: press.surface })
244 $.ui.toast(result.isCopied ? 'Session ID copied' : `Session ID: ${text}`)
245 }}
246 />
247 )
248
249 // A side as runs of Text, broken where a piece is a copy button.
250 const side = (pieces: Piece[], wrap: 'truncate-end' | 'truncate-start') => {
251 const out: unknown[] = []
252 let chunk: Piece[] = []
253 const flush = () => {
254 if (chunk.length > 0) out.push(<Text wrap={wrap}>{draw(chunk)}</Text>)
255 chunk = []
256 }
257 for (const p of pieces) {
258 if (p.copy === undefined) {
259 chunk.push(p)
260 } else {
261 flush()
262 out.push(copyButton(p, p.copy))
263 }
264 }
265 flush()
266 return out
267 }
268
269 // A plain Button is the bare label on the terminal; other surfaces pad it.
270 const isButtonPadded = e.surface !== 'terminal'
271
272 // Each row: the left side from the start, the right side pushed to the
273 // far edge. When the band is too narrow, the right side gives way first.
274 return (
275 <Box flexDirection="column" width={e.props.bodyColumns}>
276 {rows(snap).map(row => (
277 <Box flexDirection="row" justifyContent="space-between" columnGap={3}>
278 <Text wrap="truncate-end">{draw(joinSide(row.left, isButtonPadded))}</Text>
279 <Box flexDirection="row" flexShrink={1000}>
280 {side(joinSide(row.right, isButtonPadded), 'truncate-start')}
281 </Box>
282 </Box>
283 ))}
284 </Box>
285 )
286 })
287}
288hooks/format.ts 233 lines1// Pure formatting helpers, ported from statuslineV4.sh. No engine calls here,
2// so the tests can exercise them directly.
3
4// The script's 256-color palette, by segment.
5export const PALETTE = {
6 dir: 117, // sky blue
7 model: 147, // light purple
8 effort: 186, // soft yellow (the script's version_color)
9 sep: 249, // light gray
10 style: 245, // gray (cc version)
11 ram: 218, // pastel pink
12 ctx: 116, // soft teal
13 dur: 139, // muted mauve-gray (the script's ostyle_color: duration took the style's old slot)
14 git: 150, // soft green
15 cost: 222, // light gold
16 session: 194, // light green
17 sessid: 103, // muted periwinkle-gray
18 ostyle: 173, // muted salmon (the script's dur_color: style took the duration's old slot)
19} as const
20
21export type Segment = keyof typeof PALETTE
22
23const BASE16 = [
24 '#000000', '#800000', '#008000', '#808000', '#000080', '#800080', '#008080', '#c0c0c0',
25 '#808080', '#ff0000', '#00ff00', '#ffff00', '#0000ff', '#ff00ff', '#00ffff', '#ffffff',
26]
27const CUBE = [0, 95, 135, 175, 215, 255]
28const hex2 = (n: number) => n.toString(16).padStart(2, '0')
29
30// xterm-256 index to #rrggbb, so the desktop surface draws the same colors
31// the terminal does for `\033[38;5;Nm`.
32export function xterm256(n: number): string {
33 if (n < 16) return BASE16[n] ?? '#ffffff'
34 if (n < 232) {
35 const i = n - 16
36 const r = CUBE[Math.floor(i / 36)] ?? 0
37 const g = CUBE[Math.floor((i % 36) / 6)] ?? 0
38 const b = CUBE[i % 6] ?? 0
39 return '#' + hex2(r) + hex2(g) + hex2(b)
40 }
41 const v = 8 + 10 * (n - 232)
42 return '#' + hex2(v) + hex2(v) + hex2(v)
43}
44
45// A color a shade darker: each channel scaled toward black.
46export function shade(hex: string, factor = 0.75): string {
47 const n = parseInt(hex.slice(1), 16)
48 const ch = (shift: number) => hex2(Math.round(((n >> shift) & 0xff) * factor))
49 return '#' + ch(16) + ch(8) + ch(0)
50}
51
52// `sed "s|^$HOME|~|"` then keep the last `levels` components (0 = full path).
53export function shortenDir(dir: string, home: string | undefined, levels: number): string {
54 let d = dir
55 if (home && (d === home || d.startsWith(home + '/'))) d = '~' + d.slice(home.length)
56 if (levels <= 0) return d
57 const parts = d.split('/')
58 return parts.length > levels ? parts.slice(parts.length - levels).join('/') : d
59}
60
61// The script drops " context": "Opus 4.8 (1M context)" -> "Opus 4.8 (1M)".
62// The engine may hand back a model id instead of a display name, so ids like
63// "claude-opus-5-5[1m]" are spelled out the same way: "Opus 5.5 (1M)".
64export function shortenModel(model: string): string {
65 const id = /^claude-([a-z]+)-(\d+)(?:-(\d+))?(?:-\d{8})?(\[1m\])?$/i.exec(model.trim())
66 if (id) {
67 const family = id[1] ?? ''
68 const name = family.charAt(0).toUpperCase() + family.slice(1)
69 const ver = id[3] ? `${id[2]}.${id[3]}` : `${id[2]}`
70 return `${name} ${ver}${id[4] ? ' (1M)' : ''}`
71 }
72 return model.replace(' context', '')
73}
74
75// turn.step's effort: a level name, or a thinking budget in tokens.
76export function formatEffort(effort: string | number | undefined): string {
77 if (effort === undefined) return ''
78 if (typeof effort === 'number') return formatTokens(effort)
79 return effort === 'null' ? '' : effort
80}
81
82// 1_000_000 -> "1M", 200_000 -> "200k", 32_000 -> "32k".
83export function formatTokens(n: number): string {
84 if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`
85 if (n >= 1000) return `${Math.round(n / 1000)}k`
86 return `${n}`
87}
88
89export function formatDuration(ms: number): string {
90 const s = Math.max(0, Math.floor(ms / 1000))
91 const days = Math.floor(s / 86400)
92 const hours = Math.floor((s % 86400) / 3600)
93 const mins = Math.floor((s % 3600) / 60)
94 if (s < 60) return `${s}s`
95 if (days > 0) return `${days}d ${hours}h`
96 if (hours > 0) return `${hours}h ${mins}m`
97 return `${mins}m`
98}
99
100// `ps -o etime=` ("[[dd-]hh:]mm:ss") to milliseconds; undefined when unparsable.
101export function parseEtime(etime: string): number | undefined {
102 const m = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/.exec(etime.trim())
103 if (!m) return undefined
104 const [d, h, min, s] = [m[1], m[2], m[3], m[4]].map(x => Number(x ?? 0))
105 return (((d ?? 0) * 24 + (h ?? 0)) * 60 + (min ?? 0)) * 60_000 + (s ?? 0) * 1000
106}
107
108// A window's percent, or 0 once its reset time has passed: the reading is from
109// the last API response, which may predate the reset by hours.
110export function livePercent(percentUsed: number, resetsAt: string | undefined, now: number): number {
111 const t = resetsAt ? Date.parse(resetsAt) : NaN
112 return Number.isFinite(t) && t <= now ? 0 : percentUsed
113}
114
115// "f00a9b5b-d316-4fca-9031-1b5fa9afddad" -> "f00a9b5b…afddad".
116export function shortenId(id: string): string {
117 return id.length > 16 ? `${id.slice(0, 8)}…${id.slice(-6)}` : id
118}
119
120export type Ram = { amount: string; procs: number; pct: string }
121
122// Sums `ps -o %mem=,rss=,comm=` output the way the script's awk does, keeping
123// only programs named exactly "claude": a wrapper whose arguments end in a
124// claude path (the Desktop app's `disclaimer`) matches `pgrep -f` but isn't one.
125export function formatRam(psOutput: string): Ram {
126 let mem = 0
127 let rss = 0
128 let found = 0
129 for (const line of psOutput.split('\n')) {
130 const m = /^\s*([\d.]+)\s+(\d+)\s+(.+?)\s*$/.exec(line)
131 if (!m || !/(^|\/)claude$/.test(m[3] ?? '')) continue
132 mem += Number(m[1]) || 0
133 rss += Number(m[2]) || 0
134 found++
135 }
136 const mb = rss / 1024
137 const amount = mb >= 1000 ? `${(mb / 1024).toFixed(2)}GB` : `${mb.toFixed(1)}MB`
138 return { amount, procs: found, pct: `${mem.toFixed(1)}%` }
139}
140
141// Unique pids from any number of `pgrep` outputs.
142export function mergePids(...outputs: string[]): string[] {
143 const seen = new Set<string>()
144 for (const out of outputs) {
145 for (const p of out.split('\n')) {
146 const pid = p.trim()
147 if (/^\d+$/.test(pid)) seen.add(pid)
148 }
149 }
150 return [...seen]
151}
152
153export type Limit = { label: string; pct: number }
154
155export type Snapshot = {
156 dir: string
157 git: string
158 model: string
159 effort: string
160 version: string
161 sessionId: string
162 outputStyle: string
163 // Absent until the live window's first response (a fresh or just-compacted session).
164 ctxPct?: number
165 // Absent where the host keeps no cost ledger.
166 costUsd?: number
167 durationMs: number
168 limits: Limit[]
169 ram: Ram
170}
171
172// One run of text; `seg` colors it, `isShaded` draws that color a shade
173// darker, `isDim` draws it quiet, and `copy` makes it a button that copies
174// that text.
175export type Piece = { text: string; seg?: Segment; isShaded?: boolean; isDim?: boolean; copy?: string }
176// Segments are joined by the separator; each is one or more pieces.
177export type Side = Piece[][]
178export type Row = { left: Side; right: Side }
179
180// Two rows, each split into a left side (what changes the reading of the
181// session) and a right side pushed to the band's far edge:
182// dir ⟡ branch ⟡ model ⟡ effort duration ⟡ version ⟡ session id
183// 5h 13% · 7d 26% ⟡ ctx 8% ⟡ $0.77 ⟡ style RAM 2.03GB (7 · 3.1%)
184export function rows(s: Snapshot): Row[] {
185 const row1: Row = {
186 left: [[{ text: s.dir, seg: 'dir' }], [{ text: s.git, seg: 'git' }], [{ text: s.model, seg: 'model' }]],
187 right: [],
188 }
189 if (s.effort) row1.left.push([{ text: s.effort, seg: 'effort' }])
190 row1.right.push([{ text: formatDuration(s.durationMs), seg: 'dur' }])
191 if (s.version) row1.right.push([{ text: `v${s.version}`, seg: 'style' }])
192 if (s.sessionId) row1.right.push([{ text: shortenId(s.sessionId), seg: 'sessid', copy: s.sessionId }])
193
194 const row2: Row = { left: [], right: [] }
195 if (s.limits.length > 0) {
196 const pieces: Piece[] = []
197 s.limits.forEach((l, i) => {
198 if (i > 0) pieces.push({ text: ' · ', seg: 'session' })
199 pieces.push({ text: `${l.label} `, seg: 'session', isShaded: true }, { text: `${Math.trunc(l.pct)}%`, seg: 'session' })
200 })
201 row2.left.push(pieces)
202 }
203 const ctx = s.ctxPct === undefined ? '—' : `${Math.trunc(s.ctxPct)}%`
204 row2.left.push([{ text: `ctx ${ctx}`, seg: 'ctx' }])
205 if (s.costUsd !== undefined) row2.left.push([{ text: `$${s.costUsd.toFixed(2)}`, seg: 'cost' }])
206 if (s.outputStyle) row2.left.push([{ text: s.outputStyle, seg: 'ostyle' }])
207 row2.right.push([
208 { text: `RAM ${s.ram.amount}`, seg: 'ram' },
209 { text: ` (${s.ram.procs} · ${s.ram.pct})`, seg: 'ram', isShaded: true },
210 ])
211
212 return [row1, row2]
213}
214
215// Two no-break spaces each side: plain spaces may collapse on a surface that
216// lays the band out as HTML.
217const GAP = '\u00a0\u00a0'
218
219// A side as one flat run of pieces, separators included. `isButtonPadded`:
220// the surface pads a button itself (Desktop draws a native one), so the
221// separator before a copy button drops its trailing gap.
222export function joinSide(side: Side, isButtonPadded = false): Piece[] {
223 const out: Piece[] = []
224 side.forEach((seg, i) => {
225 if (i > 0) {
226 const beforeButton = isButtonPadded && seg[0]?.copy !== undefined
227 out.push({ text: GAP + '⟡' + (beforeButton ? '' : GAP), seg: 'sep', isDim: true })
228 }
229 out.push(...seg)
230 })
231 return out
232}
233