SLOPSHOPPER

cc-statusline

Draws cc-statusline as a band above the prompt: runs the binary on a 2s tick and turns its ANSI colors and OSC 8 links into styled text.

panebandcommandtoastprocesstimer
v0.2.0MITupdated 2026-10-17NorthIsUp/skillz/plugins/cc-statusline
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cc-statusline
│ ┃ Status line sections ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ client module ./menu.tsx │ ⏺ 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 │ │ › /statusline-config │ ⎿ cc-statusline: Status line sections opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Pane · Status line sections
▣ client module ./menu.tsx
README

cc-statusline

A mod that draws cc-statusline as a band above the prompt. A statusLine command gets its input from Claude Code on stdin. This mod builds that same JSON from the session instead ($.session, plus the transcript path and effort that classic hook events carry), runs the binary every 2s, and turns its ANSI colors and OSC 8 links into Text and Link elements, because a mod's Text does not accept escape codes.

Install

Needs cc-statusline on PATH.

/plugin install cc-statusline@northisup-skillz

Then remove statusLine from ~/.claude/settings.json, or both will draw.

Choosing sections

/statusline-config opens a checklist of every section:

KeyDoes
↑ ↓move the cursor
space / entershow or hide the section
← →move the section earlier or later
escclose

Sections above the right side row go on the left of the line, the rest on the right; move that row to rebalance. Each change is written straight to [layout] left/right in ~/.config/cc-statusbar/config.toml (other lines are kept), so the line updates within 2s. Click the list first if the arrows don't reach it.

Gaps

  • fast_mode and output_style are not sent, so those parts of the line stay empty.
  • PR chips need the transcript path. They appear after the first classic hook event, a moment after the session starts.

Develop

claude plugin validate plugins/cc-statusline
claude plugin test plugins/cc-statusline
Source 5 files
hooks/register.tsx 163 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Span } from '../types'
5import { parseAnsi } from './ansi'
6import { menuFrom, readLayout, writeLayout } from './layout'
7import type { Layout } from './layout'
8
9const lines = atom({ plugin: 'cc-statusline', key: 'lines' } as const, [] as Span[][])
10
11const TICK_MS = 2000
12const PANE = 'cc-statusline-config'
13const COMMAND = 'statusline-config'
14
15// Same lookup as cc-statusline's config_path().
16async function configPath($: EngineInterface): Promise<string> {
17  const xdg = await $.env.get('XDG_CONFIG_HOME')
18  const base = xdg || `${(await $.env.get('HOME')) ?? ''}/.config`
19  return `${base}/cc-statusbar/config.toml`
20}
21
22// A missing file reads as empty: writeLayout then starts one.
23async function readConfig($: EngineInterface): Promise<string> {
24  try {
25    return await $.fs.read(await configPath($))
26  } catch {
27    return ''
28  }
29}
30
31const isLayout = (v: unknown): v is Layout => {
32  const names = (x: unknown) => Array.isArray(x) && x.every(n => typeof n === 'string')
33  return typeof v === 'object' && v !== null && names((v as Layout).left) && names((v as Layout).right)
34}
35
36// $.session.model() is the id; the status line shows the display name.
37export const displayName = (id: string) =>
38  id
39    .replace(/^claude-/, '')
40    .replace(/-\d{8}$/, '')
41    .replace(/\[.*\]$/, '')
42    .replace(/^(\w)(\w*)-(.*)$/, (_, a: string, b: string, v: string) => `${a.toUpperCase()}${b} ${v.replace(/-/g, '.')}`)
43
44export const register: Register = on => {
45  // Only classic hook inputs carry these; the binary needs the transcript for chips.
46  let transcript = ''
47  let effort = ''
48  let cols = 0
49  let isRunning = false
50
51  on('classic.*', ($, e, next) => {
52    const base = e as { transcript_path?: unknown; effort?: { level?: unknown } }
53    if (typeof base.transcript_path === 'string' && base.transcript_path) transcript = base.transcript_path
54    if (typeof base.effort?.level === 'string') effort = base.effort.level
55    return next(e)
56  }).catch(($, e, next) => next(e))
57
58  on('session.start', async ($, e, next) => {
59    const refresh = async () => {
60      if (isRunning) return
61      isRunning = true
62      try {
63        const [id, cwd, model, usage] = await Promise.all([
64          $.session.id(),
65          $.session.cwd(),
66          $.session.model(),
67          $.session.usage(),
68        ])
69        const limit = (kind: string) => {
70          const r = usage.rateLimits.find(l => l.kind === kind)
71          return r && { used_percentage: r.percentUsed, resets_at: r.resetsAt }
72        }
73        const input = {
74          session_id: id,
75          transcript_path: transcript,
76          workspace: { current_dir: cwd },
77          model: { display_name: displayName(model) },
78          context_window: { used_percentage: usage.context.percent },
79          cost: {
80            total_cost_usd: usage.cost?.usd ?? 0,
81            total_duration_ms: Math.max(0, (await $.clock.now()) - usage.startedAt),
82          },
83          rate_limits: { five_hour: limit('five_hour'), seven_day: limit('seven_day') },
84          effort_level: effort,
85          cols,
86        }
87        const ran = await $.process.run(['cc-statusline'], {
88          cwd,
89          stdin: JSON.stringify(input),
90          timeoutMs: 5000,
91        })
92        if (ran.exitCode === 0) {
93          const parsed = ran.stdout.replace(/\n+$/, '').split('\n').map(parseAnsi)
94          await update($, lines, () => parsed)
95        }
96      } finally {
97        isRunning = false
98      }
99    }
100    await $.command.register({
101      name: COMMAND,
102      description: 'Choose and order the status line sections',
103    })
104    $.clock.every(TICK_MS, () => void refresh())
105    void refresh()
106    return next(e)
107  })
108
109  on('command.run', { command: COMMAND }, async $ => {
110    await $.ui.open({ id: PANE, title: 'Status line sections', focus: true, closeOnEscape: true })
111    await $.ui.focus({ requestId: PANE, key: 'menu' })
112    return { text: 'Status line sections opened.' }
113  })
114
115  // Client, the one element that takes raw arrow keys, is terminal-only.
116  on('ui.render', { component: 'Pane', requestId: PANE, surface: 'terminal' }, async ($, e) => {
117    const { Box, Client } = $.ui.resolve(e)
118    const { rows } = menuFrom(readLayout(await readConfig($)))
119    return (
120      <Box flexDirection="column">
121        <Client key="menu" module="./menu.tsx" props={{ rows }} />
122      </Box>
123    )
124  })
125
126  // Every change saves at once, so the band shows it on the next tick.
127  on('ui.message', async ($, e, next) => {
128    if (e.requestId !== PANE || !isLayout(e.data)) return next(e)
129    try {
130      await $.fs.write(await configPath($), writeLayout(await readConfig($), e.data))
131    } catch (err) {
132      $.ui.toast(`cc-statusline: could not save the layout (${String(err)})`)
133    }
134    return {}
135  })
136
137  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
138    cols = e.props.bodyColumns
139    const shown = await read($, lines)
140    if (e.props.hasSurvey || shown.length === 0) return next(e)
141
142    const { Box, Text, Link } = $.ui.resolve(e)
143    const span = (s: Span, i: number) => {
144      const { text, href, ...style } = s
145      const t = (
146        <Text key={String(i)} {...style}>
147          {text}
148        </Text>
149      )
150      return href ? <Link key={String(i)} href={href}>{t}</Link> : t
151    }
152    return (
153      <Box flexDirection="column">
154        {shown.map((line, i) => (
155          <Text key={String(i)} wrap="truncate-end">
156            {line.map(span)}
157          </Text>
158        ))}
159      </Box>
160    )
161  })
162}
163
hooks/ansi.ts 84 lines
1import type { Span } from '../types'
2
3const NAMES: readonly string[] = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white']
4const CUBE = [0, 95, 135, 175, 215, 255]
5
6const hex = (r: number, g: number, b: number) =>
7  '#' + [r, g, b].map(v => v.toString(16).padStart(2, '0')).join('')
8
9// 0-15 stay names so they follow the terminal's palette.
10function color256(n: number): string {
11  if (n < 8) return NAMES[n]!
12  if (n < 16) return NAMES[n - 8]! + 'Bright'
13  if (n < 232) {
14    const i = n - 16
15    return hex(CUBE[Math.floor(i / 36)]!, CUBE[Math.floor(i / 6) % 6]!, CUBE[i % 6]!)
16  }
17  const g = 8 + 10 * (n - 232)
18  return hex(g, g, g)
19}
20
21type Style = Omit<Span, 'text'>
22
23function applySgr(style: Style, params: number[]): Style {
24  const s = { ...style }
25  for (let i = 0; i < params.length; i++) {
26    const p = params[i]!
27    if (p === 0) {
28      for (const k of Object.keys(s) as (keyof Style)[]) if (k !== 'href') delete s[k]
29    } else if (p === 1) s.bold = true
30    else if (p === 2) s.dimColor = true
31    else if (p === 22) (delete s.bold, delete s.dimColor)
32    else if (p === 3) s.italic = true
33    else if (p === 23) delete s.italic
34    else if (p === 4) s.underline = true
35    else if (p === 24) delete s.underline
36    else if (p === 7) s.inverse = true
37    else if (p === 27) delete s.inverse
38    else if (p >= 30 && p <= 37) s.color = NAMES[p - 30]!
39    else if (p >= 90 && p <= 97) s.color = NAMES[p - 90] + 'Bright'
40    else if (p === 39) delete s.color
41    else if (p >= 40 && p <= 47) s.backgroundColor = NAMES[p - 40]!
42    else if (p >= 100 && p <= 107) s.backgroundColor = NAMES[p - 100] + 'Bright'
43    else if (p === 49) delete s.backgroundColor
44    else if (p === 38 || p === 48) {
45      const key = p === 38 ? 'color' : 'backgroundColor'
46      if (params[i + 1] === 5) {
47        s[key] = color256(params[i + 2] ?? 0)
48        i += 2
49      } else if (params[i + 1] === 2) {
50        s[key] = hex(params[i + 2] ?? 0, params[i + 3] ?? 0, params[i + 4] ?? 0)
51        i += 4
52      }
53    }
54  }
55  return s
56}
57
58// SGR and OSC 8 become styled spans; every other escape is dropped, since a
59// Text refuses control characters.
60// eslint-disable-next-line no-control-regex
61const TOKEN = /\x1b\[([0-9;]*)m|\x1b\]8;[^;]*;([^\x07\x1b]*)(?:\x07|\x1b\\)|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b\[[0-9;?]*[A-Za-z]|[\x00-\x1f\x7f]/g
62
63export function parseAnsi(line: string): Span[] {
64  const out: Span[] = []
65  let style: Style = {}
66  let last = 0
67  const push = (text: string) => {
68    if (text) out.push({ text, ...style })
69  }
70  for (const m of line.matchAll(TOKEN)) {
71    push(line.slice(last, m.index))
72    last = m.index + m[0].length
73    if (m[1] !== undefined) {
74      style = applySgr(style, m[1] === '' ? [0] : m[1].split(';').map(Number))
75    } else if (m[2] !== undefined) {
76      style = { ...style }
77      if (m[2]) style.href = m[2]
78      else delete style.href
79    }
80  }
81  push(line.slice(last))
82  return out
83}
84
hooks/layout.ts 132 lines
1// The binary's `[layout] left/right` lists, and the menu that edits them.
2
3export type Layout = { left: string[]; right: string[] }
4export type Row = { name: string; on: boolean }
5export type Menu = { rows: Row[]; cursor: number }
6
7/** The row splitting left from right; it moves like any section. */
8export const SPLIT = '|'
9
10// Mirrors cc-statusline's config.rs defaults, plus the dotted quota buckets.
11export const DEFAULT_LAYOUT: Layout = {
12  left: ['repo', 'pr_icon', 'branch', 'pr_num', 'ci', 'review', 'comments', 'dirty', 'ahead', 'behind', 'ticket', 'chips'],
13  right: ['burn', 'agents', 'quotas', 'ctx_bar', 'loc', 'model', 'effort', 'spinner'],
14}
15export const KNOWN = [
16  ...DEFAULT_LAYOUT.left,
17  ...DEFAULT_LAYOUT.right,
18  'quotas.hourly',
19  'quotas.weekly',
20  'quotas.design',
21  'quotas.sonnet',
22]
23
24const HEADER = /^\s*\[\s*layout\s*\]\s*(#.*)?$/
25const TABLE = /^\s*\[/
26const ASSIGN = /^\s*(left|right)\s*=/
27
28/** Line span of the `[layout]` table, header included; null when absent. */
29function section(lines: string[]): [number, number] | null {
30  const start = lines.findIndex(l => HEADER.test(l))
31  if (start < 0) return null
32  let end = start + 1
33  while (end < lines.length && !TABLE.test(lines[end]!)) end++
34  return [start, end]
35}
36
37/** Each `left`/`right` assignment in the span: its key and [first, last] line. */
38function assignments(lines: string[], from: number, to: number) {
39  const out: { key: 'left' | 'right'; first: number; last: number }[] = []
40  for (let i = from; i < to; i++) {
41    const m = ASSIGN.exec(lines[i]!)
42    if (!m) continue
43    let depth = 0
44    let last = i
45    for (; last < to; last++) {
46      const code = lines[last]!.replace(/"[^"]*"|#.*$/g, '')
47      depth += (code.match(/\[/g)?.length ?? 0) - (code.match(/\]/g)?.length ?? 0)
48      if (depth <= 0) break
49    }
50    out.push({ key: m[1] as 'left' | 'right', first: i, last: Math.min(last, to - 1) })
51    i = last
52  }
53  return out
54}
55
56export function readLayout(toml: string): Layout {
57  const lines = toml.split('\n')
58  const span = section(lines)
59  const got: Partial<Layout> = {}
60  if (span) {
61    for (const a of assignments(lines, span[0] + 1, span[1])) {
62      const body = lines.slice(a.first, a.last + 1).join('\n').replace(/#.*$/gm, '')
63      got[a.key] = [...body.matchAll(/"([^"]*)"/g)].map(m => m[1]!)
64    }
65  }
66  return { left: got.left ?? DEFAULT_LAYOUT.left, right: got.right ?? DEFAULT_LAYOUT.right }
67}
68
69const list = (key: string, names: string[]) =>
70  `${key} = [${names.map(n => JSON.stringify(n)).join(', ')}]`
71
72/** `toml` with `[layout]`'s left/right replaced; every other line kept as is. */
73export function writeLayout(toml: string, layout: Layout): string {
74  const lines = toml.split('\n')
75  const fresh = [list('left', layout.left), list('right', layout.right)]
76  const span = section(lines)
77  if (!span) {
78    const sep = toml === '' || toml.endsWith('\n') ? '' : '\n'
79    return `${toml}${sep}\n[layout]\n${fresh.join('\n')}\n`
80  }
81  const drop = new Set<number>()
82  for (const a of assignments(lines, span[0] + 1, span[1])) {
83    for (let i = a.first; i <= a.last; i++) drop.add(i)
84  }
85  const kept = lines.filter((_, i) => !drop.has(i))
86  kept.splice(span[0] + 1, 0, ...fresh)
87  return kept.join('\n')
88}
89
90export function menuFrom(layout: Layout): Menu {
91  const listed = new Set([...layout.left, ...layout.right])
92  const rows: Row[] = [
93    ...layout.left.map(name => ({ name, on: true })),
94    { name: SPLIT, on: true },
95    ...layout.right.map(name => ({ name, on: true })),
96    ...KNOWN.filter(n => !listed.has(n)).map(name => ({ name, on: false })),
97  ]
98  return { rows, cursor: 0 }
99}
100
101export function layoutOf(menu: Menu): Layout {
102  const split = menu.rows.findIndex(r => r.name === SPLIT)
103  const names = (rows: Row[]) => rows.filter(r => r.on).map(r => r.name)
104  return { left: names(menu.rows.slice(0, split)), right: names(menu.rows.slice(split + 1)) }
105}
106
107/** The menu after one key; the same object when the key does nothing. */
108export function press(menu: Menu, key: string): Menu {
109  const { rows, cursor } = menu
110  const row = rows[cursor]
111  switch (key) {
112    case 'up':
113      return cursor > 0 ? { rows, cursor: cursor - 1 } : menu
114    case 'down':
115      return cursor < rows.length - 1 ? { rows, cursor: cursor + 1 } : menu
116    case ' ':
117    case 'return':
118      if (!row || row.name === SPLIT) return menu
119      return { rows: rows.map((r, i) => (i === cursor ? { ...r, on: !r.on } : r)), cursor }
120    case 'left':
121    case 'right': {
122      const to = key === 'left' ? cursor - 1 : cursor + 1
123      if (to < 0 || to >= rows.length) return menu
124      const next = [...rows]
125      ;[next[cursor], next[to]] = [next[to]!, next[cursor]!]
126      return { rows: next, cursor: to }
127    }
128    default:
129      return menu
130  }
131}
132
hooks/menu.tsx 45 lines
1import type { ClientModule } from 'claude-code'
2
3import { layoutOf, press, SPLIT } from './layout'
4import type { Menu, Row } from './layout'
5
6type Props = { rows: Row[] }
7
8const MenuView: ClientModule<Props, Menu> = (props, surface) => {
9  const { Box, Text } = surface.elements
10  if (surface.state === undefined) {
11    surface.onKey(({ key }) => {
12      const before = surface.state ?? { rows: props.rows, cursor: 0 }
13      const after = press(before, key)
14      if (after === before) return
15      surface.setState(after)
16      if (after.rows !== before.rows) surface.post(layoutOf(after))
17    })
18    surface.setState({ rows: props.rows, cursor: 0 })
19  }
20  const { rows, cursor } = surface.state ?? { rows: props.rows, cursor: 0 }
21
22  return (
23    <Box flexDirection="column">
24      {rows.map((row, i) => {
25        const mark = i === cursor ? '❯ ' : '  '
26        return row.name === SPLIT ? (
27          <Text key={`r${i}`} dimColor={i !== cursor}>
28            {mark}──── right side ────
29          </Text>
30        ) : (
31          <Text key={`r${i}`} color={i === cursor ? 'suggestion' : undefined} dimColor={!row.on && i !== cursor}>
32            {mark}
33            {row.on ? '[x] ' : '[ ] '}
34            {row.name}
35          </Text>
36        )
37      })}
38      <Text dimColor> </Text>
39      <Text dimColor>↑↓ move · space/enter toggle · ←→ reorder · esc close</Text>
40    </Box>
41  )
42}
43
44export default MenuView
45
types/index.d.ts 18 lines
1export type Span = {
2  text: string
3  color?: string
4  backgroundColor?: string
5  bold?: boolean
6  dimColor?: boolean
7  italic?: boolean
8  underline?: boolean
9  inverse?: boolean
10  href?: string
11}
12
13declare module 'claude-code' {
14  interface PluginState {
15    'cc-statusline': { lines: Span[][] }
16  }
17}
18