Colour-coded status line under the prompt: repo/branch, model, context %, 5-hour and weekly usage with reset countdowns and pace, cost and session time

A two-row powerline status line above Claude Code's footer, in the spirit of ccstatusline.
ctx ████░░░░░░░░ 16% 156k/1.0M 5h █░░░░░░░░░░░ 11% 4h29m wk ████░░░░░░░░ 38% 19h40m
⏵⏵ BYPASS Signal Lance · main ±4 Opus 5.5 · medium 12m $3.10 Q:\Signal Lance
Row 1, the meters
| Block | Shows | Colour |
|---|---|---|
ctx | context window used, tokens / window | green <50%, amber <80%, red ≥80% |
5h / wk | 5-hour and weekly usage, time to reset | same; amber ↑N ahead when you're N points ahead of an even burn, red ⚠ cap in … when you'd hit 100% before the reset at this pace (only from 30% used, so one busy start doesn't cry wolf) |
Row 2, where you are
| Block | Shows |
|---|---|
| mode | ⏵⏵ BYPASS red, ⏵⏵ auto purple, ⏵⏵ accept edits amber, ⏸ plan green, ◇ ask slate |
| git | repo · branch, ✓ clean or ±N changed files, ↑ahead ↓behind |
| model | model and reasoning effort |
| session | time and cost |
| folder | last two parts of the working directory |
Bars widen on wide terminals; on a narrow one the lowest-priority blocks drop first (folder, session, model, weekly) and the mode block stays. Colours live in PAL / MODES at the top of hooks/logic.ts.
Two halves sharing hooks/logic.ts:
statusline/statusline.ts, the line itself. Claude Code's statusLine setting runs it with the session's JSON on stdin; it prints two ANSI rows. That slot sits above the footer, where a mod can't draw.hooks/register.tsx) only notes the permission mode, which the statusLine JSON leaves out: it reads it from classic hook inputs and live from the footer's hint as shift+tab cycles, and writes it to ~/.claude/flu-statusline/mode-<session id> for the script..ts directly).terminal.integrated.fontFamily./plugin install flu-statusline --marketplace FluKanuck/Signal-Lance
then in ~/.claude/settings.json (replacing any ccstatusline entry), pointing at this folder in your clone:
"statusLine": { "type": "command", "command": "node \"<clone>/mods/flu-statusline/statusline/statusline.ts\"", "refreshInterval": 5 }
Without the mod the line still draws, just with no mode block.
hooks/register.tsx 38 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { asMode, modeFile, modeFromHint } from './logic.ts'
5
6// The status line itself is statusline.ts, run by Claude Code's `statusLine` setting (it draws above the footer,
7// where a mod can't). Its input carries everything but the permission mode, so this mod's one job is to note the
8// mode where the script can read it: <claude dir>/flu-statusline/mode-<session id>.
9
10const file = atom({ plugin: 'flu-statusline', key: 'file' } as const, '')
11const mode = atom({ plugin: 'flu-statusline', key: 'mode' } as const, '')
12
13async function noteMode($: any, m: string | null) {
14 const path = await read($, file)
15 if (!m || !path || m === (await read($, mode))) return
16 await update($, mode, () => m)
17 try { await $.fs.write(path, m) } catch { /* the script just shows no mode block */ }
18}
19
20// every classic hook input names the session, its transcript (so the claude dir) and the mode
21async function fromHookInput($: any, e: any, next: any) {
22 if (e?.transcript_path && e?.session_id) await update($, file, () => modeFile(e.transcript_path, e.session_id))
23 await noteMode($, asMode(e?.permission_mode))
24 return next(e)
25}
26
27export const register: Register = on => {
28 on('classic.SessionStart', fromHookInput)
29 on('classic.UserPromptSubmit', fromHookInput)
30 on('classic.PostToolUse', fromHookInput)
31
32 // shift+tab changes the hint line at once; the status line re-runs on a mode change, 300 ms later
33 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
34 await noteMode($, modeFromHint(e.props.hint))
35 return next(e)
36 })
37}
38hooks/logic.ts 297 lines1// Pure helpers for the status line: no `$`, so the tests call them directly.
2import type { GitInfo, Usage } from '../types'
3
4export type Tone = 'success' | 'warning' | 'error' | 'inactive'
5export type Cell = { text: string; fg?: string; bold?: boolean }
6/** One powerline block: a background and the coloured runs on it. `priority` decides what drops first (lowest first). */
7export type Block = { key: string; bg: string; cells: Cell[]; priority: number }
8
9/** The colours: blocks in the teal/blue family ccstatusline had, fills that still read on them. Tune here. */
10export const PAL = {
11 bg: ['#0f4c5c', '#16687c', '#1f3f6e', '#2a5a96'],
12 fg: '#eaf7fb', mute: '#a9cfd9', track: '#5f8f9c',
13 success: '#7fe08e', warning: '#ffc857', error: '#ff6b6b', inactive: '#a9cfd9',
14}
15/** Glyphs: Nerd Font (JetBrainsMono Nerd Font has them all). */
16export const G = { sep: '', branch: '', chip: '', clock: '', reset: '', folder: '', full: '█', empty: '░' }
17
18const HOUR = 3_600_000
19export const WINDOW_MS: Record<string, number> = { five_hour: 5 * HOUR, seven_day: 7 * 24 * HOUR }
20const LABEL: Record<string, string> = { five_hour: '5h', seven_day: 'wk', spend_limit: '$lim' }
21
22/** `git status --porcelain=v2 --branch` → branch, dirty files, ahead/behind. */
23export function parseGit(porcelain: string, toplevel: string): GitInfo {
24 let branch = '?', ahead = 0, behind = 0, dirty = 0
25 for (const line of porcelain.split('\n')) {
26 if (line.startsWith('# branch.head ')) branch = line.slice(14).trim()
27 else if (line.startsWith('# branch.ab ')) {
28 const m = /\+(\d+) -(\d+)/.exec(line)
29 if (m) { ahead = Number(m[1] ?? 0); behind = Number(m[2] ?? 0) }
30 } else if (line.trim() && !line.startsWith('#')) dirty++
31 }
32 const repo = toplevel.replace(/\\/g, '/').replace(/\/+$/, '').split('/').pop() || '?'
33 return { repo, branch: branch === '(detached)' ? 'detached' : branch, dirty, ahead, behind }
34}
35
36/** 0–100 used → green / amber / red. */
37export function toneFor(pct: number, warn = 50, bad = 80): Tone {
38 return pct >= bad ? 'error' : pct >= warn ? 'warning' : 'success'
39}
40
41export function bar(pct: number, width = 5) {
42 const n = Math.max(0, Math.min(width, Math.round((pct / 100) * width)))
43 return G.full.repeat(n) + G.empty.repeat(width - n)
44}
45
46/** Bar width for the terminal: wider meters on wider screens. */
47export function barWidth(columns: number) {
48 return columns >= 150 ? 16 : columns >= 120 ? 12 : columns >= 95 ? 8 : 5
49}
50
51/** 2h14m, 3d4h, 9m, <1m. */
52export function fmtLeft(ms: number) {
53 if (ms <= 60_000) return '<1m'
54 const m = Math.floor(ms / 60_000), h = Math.floor(m / 60), d = Math.floor(h / 24)
55 if (d) return `${d}d${h % 24}h`
56 if (h) return `${h}h${String(m % 60).padStart(2, '0')}m`
57 return `${m}m`
58}
59
60/**
61 * Pace: how far ahead of an even burn this window is, in points.
62 * Positive = using faster than the window refills (you'll hit the cap before the reset).
63 */
64export function pace(kind: string, pct: number, resetsAt: number | null, now: number): number | null {
65 const win = WINDOW_MS[kind]
66 if (!win || resetsAt == null) return null
67 const left = Math.max(0, Math.min(win, resetsAt - now))
68 const elapsedPct = 100 * (1 - left / win)
69 return Math.round(pct - elapsedPct)
70}
71
72/** When the window would hit 100% at the current pace, if before its reset. */
73export function hitsCapIn(kind: string, pct: number, resetsAt: number | null, now: number): number | null {
74 const win = WINDOW_MS[kind]
75 if (!win || resetsAt == null || pct <= 0) return null
76 const left = Math.max(0, resetsAt - now), elapsed = win - left
77 if (elapsed <= 0) return null
78 const rate = pct / elapsed // points per ms
79 const toCap = (100 - pct) / rate
80 return toCap < left ? toCap : null
81}
82
83export function fmtTokens(n: number) {
84 return n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
85}
86
87/** Short model name: "claude-opus-5-5" / "Opus 5.5 (1M context)" → "Opus 5.5". */
88export function shortModel(m: string) {
89 const s = m.replace(/^claude-/i, '').replace(/\s*\(.*\)\s*$/, '').replace(/-\d{8}$/, '')
90 const x = /^(opus|sonnet|haiku|fable|mythos)[- ](\d+)(?:[-.](\d+))?/i.exec(s)
91 if (x) return `${x[1]![0]!.toUpperCase()}${x[1]!.slice(1).toLowerCase()} ${x[2]}${x[3] ? '.' + x[3] : ''}`
92 return s
93}
94
95/** The last `n` parts of a path, Windows or POSIX, as written. */
96export function shortPath(p: string, n = 2) {
97 const sep = p.includes('\\') ? '\\' : '/'
98 const parts = p.replace(/[\\/]+$/, '').split(/[\\/]/)
99 return parts.length <= n ? parts.join(sep) : '…' + sep + parts.slice(-n).join(sep)
100}
101
102export type Mode = 'default' | 'acceptEdits' | 'plan' | 'auto' | 'dontAsk' | 'bypassPermissions'
103
104/** The permission-mode block: label and background, louder the less Claude asks. */
105export const MODES: Record<Mode, { label: string; bg: string }> = {
106 bypassPermissions: { label: '⏵⏵ BYPASS', bg: '#a3243b' },
107 dontAsk: { label: "⏵⏵ don't ask", bg: '#a3243b' },
108 auto: { label: '⏵⏵ auto', bg: '#6a4a9c' },
109 acceptEdits: { label: '⏵⏵ accept edits', bg: '#94640a' },
110 plan: { label: '⏸ plan', bg: '#2f7a46' },
111 default: { label: '◇ ask', bg: '#3d4f5c' },
112}
113
114/**
115 * The mode as the engine's hint line names it, live as shift+tab cycles it.
116 * `null` when the line doesn't say (Claude working, a draft's hint), so the caller keeps its last reading.
117 */
118export function modeFromHint(hint: string): Mode | null {
119 if (/bypass permissions/i.test(hint)) return 'bypassPermissions'
120 if (/accept edits|auto-accept/i.test(hint)) return 'acceptEdits'
121 if (/plan mode/i.test(hint)) return 'plan'
122 if (/don'?t ask/i.test(hint)) return 'dontAsk'
123 if (/auto mode/i.test(hint)) return 'auto'
124 if (/\? for shortcuts/i.test(hint)) return 'default'
125 return null
126}
127
128export const asMode = (m: unknown): Mode | null => (typeof m === 'string' && m in MODES ? m as Mode : null)
129
130/** Where the mod notes a session's mode for the script: beside the claude dir's `projects`, one file per session. */
131export function modeFile(transcriptPath: string, sessionId: string) {
132 const claudeDir = transcriptPath.replace(/\\/g, '/').split('/projects/')[0]!
133 return `${claudeDir}/flu-statusline/mode-${sessionId.replace(/[^\w-]/g, '')}`
134}
135
136export type Input = {
137 git: GitInfo | null; usage: Usage | null; model: string; cwd: string; now: number; columns: number
138 mode?: Mode | null; effort?: string
139}
140
141/**
142 * Two powerline rows.
143 * Row 1, the meters: context, 5-hour, weekly, each with its bar, % and reset.
144 * Row 2, where you are: branch and changes, model, session time and cost, folder.
145 */
146export function rows({ git, usage, model, cwd, now, columns, mode, effort }: Input): Block[][] {
147 const w = barWidth(columns)
148 const meters: Block[] = []
149 const place: Block[] = []
150
151 // the mode leads row 2 and is the last block to drop
152 if (mode) place.push({ key: 'mode', bg: MODES[mode].bg, priority: 10, cells: [{ text: MODES[mode].label, fg: PAL.fg, bold: true }] })
153
154 if (usage && usage.ctxPct != null) {
155 const c = PAL[toneFor(usage.ctxPct, 50, 80)]
156 meters.push({ key: 'ctx', bg: PAL.bg[0]!, priority: 9, cells: [
157 { text: 'ctx ', fg: PAL.mute }, { text: bar(usage.ctxPct, w), fg: c },
158 { text: ` ${Math.round(usage.ctxPct)}%`, fg: c, bold: true },
159 ...(usage.ctxTokens != null ? [{ text: ` ${fmtTokens(usage.ctxTokens)}/${fmtTokens(usage.ctxWindow)}`, fg: PAL.mute }] : []),
160 ] })
161 }
162 for (const l of usage?.limits ?? []) {
163 const p = pace(l.kind, l.pct, l.resetsAt, now)
164 // early in a window one busy half-hour extrapolates to "cap"; only warn once there's real usage behind it
165 const cap = l.pct >= 30 ? hitsCapIn(l.kind, l.pct, l.resetsAt, now) : null
166 // colour by how full, bumped a level when you're burning well ahead of pace
167 let t = toneFor(l.pct, 50, 80)
168 if (p != null && p > 15 && t === 'success') t = 'warning'
169 if (cap != null) t = 'error'
170 const cells: Cell[] = [
171 { text: `${LABEL[l.kind] ?? l.kind} `, fg: PAL.mute }, { text: bar(l.pct, w), fg: PAL[t] },
172 { text: ` ${Math.round(l.pct)}%`, fg: PAL[t], bold: true },
173 ]
174 if (l.resetsAt != null) cells.push({ text: ` ${G.reset} ${fmtLeft(l.resetsAt - now)}`, fg: PAL.mute })
175 if (cap != null) cells.push({ text: ` ⚠ cap in ${fmtLeft(cap)}`, fg: PAL.error, bold: true })
176 else if (p != null && p > 15) cells.push({ text: ` ↑${p} ahead`, fg: PAL.warning })
177 const i = meters.length
178 meters.push({ key: l.kind, bg: PAL.bg[i % 2]!, priority: l.kind === 'five_hour' ? 8 : 6, cells })
179 }
180
181 if (git) {
182 const cells: Cell[] = [
183 { text: `${G.branch} `, fg: PAL.mute }, { text: git.repo, fg: PAL.fg, bold: true },
184 { text: ' · ', fg: PAL.mute }, { text: git.branch, fg: PAL.fg, bold: true },
185 git.dirty ? { text: ` ±${git.dirty}`, fg: PAL.warning, bold: true } : { text: ' ✓', fg: PAL.success, bold: true },
186 ]
187 if (git.ahead) cells.push({ text: ` ↑${git.ahead}`, fg: PAL.warning })
188 if (git.behind) cells.push({ text: ` ↓${git.behind}`, fg: PAL.error })
189 place.push({ key: 'git', bg: PAL.bg[2]!, priority: 7, cells })
190 }
191 if (model) place.push({ key: 'model', bg: PAL.bg[3]!, priority: 4, cells: [
192 { text: `${G.chip} `, fg: PAL.mute }, { text: shortModel(model), fg: PAL.fg, bold: true },
193 ...(effort ? [{ text: ` · ${effort}`, fg: PAL.mute }] : []),
194 ] })
195 if (usage) {
196 const mins = Math.max(0, Math.floor((now - usage.startedAt) / 60_000))
197 const time = mins >= 60 ? `${Math.floor(mins / 60)}h${String(mins % 60).padStart(2, '0')}m` : `${mins}m`
198 const cells: Cell[] = [{ text: `${G.clock} `, fg: PAL.mute }, { text: time, fg: PAL.fg, bold: true }]
199 if (usage.usd != null) cells.push({ text: ` $${usage.usd.toFixed(2)}`, fg: usage.usd >= 10 ? PAL.warning : PAL.mute })
200 place.push({ key: 'session', bg: PAL.bg[place.length % 2 ? 3 : 2]!, priority: 3, cells })
201 }
202 if (cwd) place.push({ key: 'cwd', bg: PAL.bg[place.length % 2 ? 3 : 2]!, priority: 2, cells: [{ text: `${G.folder} `, fg: PAL.mute }, { text: shortPath(cwd), fg: PAL.fg }] })
203
204 return [shade(meters, 0), shade(place, 1)].filter(r => r.length)
205}
206
207/**
208 * Alternate a row's backgrounds so neighbours never match: row 0 in the teal pair, row 1 in the blue pair.
209 * Run again after `fit` drops blocks. The mode block keeps its own colour.
210 */
211export function shade(row: Block[], r: number): Block[] {
212 let n = 0
213 return row.map(b => (b.key === 'mode' ? b : { ...b, bg: PAL.bg[r * 2 + (n++ % 2)]! }))
214}
215
216/**
217 * Columns the engine's mode pill takes to the left of the hint row ("⏵⏵ bypass permissions on · ").
218 * Read off the hint text, which names the mode; a shift+tab reminder with no readable label still means a pill.
219 */
220export function pillWidth(hint: string) {
221 const m = /(bypass permissions|accept edits|plan mode|auto[- ]mode|auto-accept edits)( on)?/i.exec(hint)
222 if (m) return [...m[0]].length + 6
223 return /shift\+tab/i.test(hint) ? 26 : 0
224}
225
226/** A block's drawn width: a space each side of its text, then its arrow. */
227export const blockWidth = (b: Block) => 2 + b.cells.reduce((a, c) => a + [...c.text].length, 0) + 1
228
229/** Drop the lowest-priority blocks until the row fits `columns`. */
230export function fit(row: Block[], columns: number): Block[] {
231 const kept = [...row]
232 const width = () => kept.reduce((a, b) => a + blockWidth(b), 0)
233 while (kept.length > 1 && width() > columns) {
234 let lo = 0
235 kept.forEach((b, i) => { if (b.priority < kept[lo]!.priority) lo = i })
236 kept.splice(lo, 1)
237 }
238 return kept
239}
240
241/** Truecolor ANSI for the statusLine script: one line per row, blocks joined by powerline arrows. */
242export function ansi(rs: Block[][], columns: number): string {
243 const rgb = (hex: string) => [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16)).join(';')
244 const bg = (h?: string) => (h ? `\x1b[48;2;${rgb(h)}m` : '\x1b[49m')
245 const fg = (h: string) => `\x1b[38;2;${rgb(h)}m`
246 const R = '\x1b[0m'
247 // folder and repo names come from disk: drop control characters so none can smuggle in escape sequences
248 const safe = (t: string) => t.replace(/[\u0000-\u001f\u007f-\u009f]/g, '')
249 return rs.map(row => {
250 // keep the row's own colour pair (row 1 can be first when there are no meters yet)
251 const pair = row.some(b => b.bg === PAL.bg[2] || b.bg === PAL.bg[3]) ? 1 : 0
252 const kept = shade(fit(row, columns), pair)
253 return kept.map((b, i) =>
254 bg(b.bg) + ' ' + b.cells.map(c => fg(c.fg ?? PAL.fg) + (c.bold ? '\x1b[1m' : '') + safe(c.text) + '\x1b[22m').join('') + ' '
255 + fg(b.bg) + bg(kept[i + 1]?.bg) + G.sep,
256 ).join('') + R
257 }).join('\n')
258}
259
260/** The statusLine command's stdin JSON → the same readings the rows take. */
261export function fromStatusInput(j: any, now: number) {
262 const cw = j?.context_window ?? {}
263 const cu = cw.current_usage
264 const tokens = cu ? (cu.input_tokens ?? 0) + (cu.cache_creation_input_tokens ?? 0) + (cu.cache_read_input_tokens ?? 0) : null
265 const limits = Object.entries(j?.rate_limits ?? {}).map(([kind, l]: [string, any]) => ({
266 kind, percentUsed: l?.used_percentage, resetsAt: l?.resets_at ? new Date(l.resets_at * 1000).toISOString() : null,
267 }))
268 const usage = usageFrom({
269 startedAt: now - (Number(j?.cost?.total_duration_ms) || 0),
270 context: { window: cw.context_window_size, tokens, percent: cw.used_percentage },
271 rateLimits: limits,
272 cost: typeof j?.cost?.total_cost_usd === 'number' ? { usd: j.cost.total_cost_usd } : undefined,
273 })
274 return {
275 usage, model: String(j?.model?.id ?? j?.model?.display_name ?? ''),
276 cwd: String(j?.workspace?.current_dir ?? j?.cwd ?? ''), effort: j?.effort?.level as string | undefined,
277 sessionId: String(j?.session_id ?? ''), transcriptPath: String(j?.transcript_path ?? ''),
278 }
279}
280
281/** Plain text, for surfaces that only take a status string. */
282export const plain = (rs: Block[][]) => rs.map(r => r.map(b => b.cells.map(c => c.text).join('')).join(' │ ')).join(' │ ')
283
284export function usageFrom(u: any): Usage {
285 return {
286 ctxPct: typeof u?.context?.percent === 'number' ? u.context.percent : null,
287 ctxTokens: typeof u?.context?.tokens === 'number' ? u.context.tokens : null,
288 ctxWindow: Number(u?.context?.window) || 200_000,
289 limits: (Array.isArray(u?.rateLimits) ? u.rateLimits : []).map((l: any) => ({
290 kind: String(l.kind), pct: Number(l.percentUsed) || 0,
291 resetsAt: l.resetsAt ? Date.parse(l.resetsAt) || null : null,
292 })).sort((a: any, b: any) => (a.kind === 'five_hour' ? -1 : b.kind === 'five_hour' ? 1 : 0)),
293 usd: typeof u?.cost?.usd === 'number' ? u.cost.usd : null,
294 startedAt: Number(u?.startedAt) || Date.now(),
295 }
296}
297types/index.d.ts 13 lines1export type GitInfo = { repo: string; branch: string; dirty: number; ahead: number; behind: number }
2export type Usage = {
3 ctxPct: number | null; ctxTokens: number | null; ctxWindow: number
4 limits: { kind: string; pct: number; resetsAt: number | null }[]
5 usd: number | null; startedAt: number
6}
7
8declare module 'claude-code' {
9 interface PluginState {
10 'flu-statusline': { file: string; mode: string }
11 }
12}
13