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.

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.
Needs cc-statusline on PATH.
/plugin install cc-statusline@northisup-skillz
Then remove statusLine from ~/.claude/settings.json, or both will draw.
/statusline-config opens a checklist of every section:
| Key | Does |
|---|---|
| ↑ ↓ | move the cursor |
| space / enter | show or hide the section |
| ← → | move the section earlier or later |
| esc | close |
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.
fast_mode and output_style are not sent, so those parts of the line stay empty.claude plugin validate plugins/cc-statusline
claude plugin test plugins/cc-statuslinehooks/register.tsx 163 lines1import { 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}
163hooks/ansi.ts 84 lines1import 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}
84hooks/layout.ts 132 lines1// 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}
132hooks/menu.tsx 45 lines1import 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
45types/index.d.ts 18 lines1export 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