SLOPSHOPPER

statusline-band

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…

newbandtoastprocesstimer
A shopper browsing a rack in a slop shop
README

statusline-band

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.

Install

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

Options

Set via /plugin configure statusline-band@zaksak or /config:

OptionDefaultWhat it does
show_in_terminalfalseAlso draw the band in terminal sessions. Off so the CLI's real status line isn't doubled.
dir_levels3Trailing path components shown; 0 shows the full path.
refresh_seconds5How often git, RAM and duration refresh between turns.

Differences from the script

  • Draws above the prompt: mods can't draw in the status line slot below it.
  • Two rows instead of three. Duration, version and session id sit on the right of row 1, output style ends row 2, and the session id is shortened to first8…last6. Duration and output style keep each other's old colors.
  • Effort comes from the main loop's last request (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.
  • Output style comes from the engine's own /config row, matched by key name. It shows default when not found.
  • Context shows — until the first response of a fresh or just-compacted session, where the script showed 0%.
  • Rate limits read 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.
  • Cost is left out when the host keeps no cost ledger, instead of showing $0.00.
  • Duration counts from this run's start, read from the engine process's uptime. The engine's own session start counts from a resumed session's first launch. /clear still resets it.
  • RAM sums every process named 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.

Develop

claude plugin validate claude-code/plugins/statusline-band
claude plugin test claude-code/plugins/statusline-band
claude --plugin-dir claude-code/plugins/statusline-band
Source 2 files
hooks/register.tsx 288 lines
1import 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}
288
hooks/format.ts 233 lines
1// 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