Multi-row status dashboard under the prompt: model, location, git, context breakdown, usage limits and cost, in three layouts with a settings menu above the…

A Claude Code mod (function-hook plugin) that draws a multi-row status dashboard under the prompt: model and effort, where you are (path, git branch, linked worktree, uncommitted diff stats, PR), the context window broken down by category, the 5-hour and weekly usage limits with reset countdowns, and the session's cost.
Three layouts, switchable live (the short names 1a, 1b and 1c below refer to them):
model / where / context / limits rows, a category legend ending in · N free · compact N%.Every layout ends in a full-width ─ rule.

Claude Code's own permission-mode line and its hint text (? for shortcuts, esc to interrupt, task pills) stay exactly as the engine draws them.
From this marketplace:
/plugin marketplace add blackpaw-studio/claude-plugins
/plugin install rich-statusline@blackpaw-plugins
Then remove any statusLine entry from your settings.json. A mod cannot hide the settings status line, so leaving it in place draws both.
Run /rich-statusline (or /rich-statusline settings) to open the settings menu in the band above the prompt; run it again to close it. Press ctrl+x tab to focus the menu, then Tab or the arrow keys move between controls and Enter changes one. Done (d) closes the menu, Reset to defaults (r) restores the defaults. Changes apply immediately and are saved in the plugin's store.
The menu lives in the band rather than a side pane because panes do not draw in some terminal setups (Claude Code inside tmux, for one).
| Setting | Default | |
|---|---|---|
| Layout | Labeled grid | Grouped rows, Labeled grid or Compact |
| Show cost | on | 1a: end of the first row; 1b: limits row; 1c: before 5h |
| Show PR | on | #123 from gh pr view; no PR when there is none or gh is missing |
| Show diff stats | on | (+12,-3) from git diff HEAD --shortstat, after the branch |
| Show worktree | on | wt <name> before the branch inside a linked git worktree |
| Show legend | on | the category legend of 1a and 1b |
| Amber at | 70% | context and each limit turn amber from here |
| Red at | 90% | ...and red from here |
| Git refresh | 10 s | branch, worktree and diff stats |
| PR refresh | 60 s | also refreshed at once when the branch changes |
There is no keyboard shortcut: this build of the plugin API lets a Button bind only the engine's own keybinding actions, not a plugin-defined one.
· compact N% note, then · N free; 1a: the ┊ compact N% note) and hiding only when even the bare legend overflows; under 80 columns of row the reset countdowns and the PR go; under 60 the compact layout is used whatever is chosen.— replaces the context percentage, over an empty bar.summary estimate of /context (no token-count requests), refreshed two seconds after the context last changed.git and gh run through the host with a timeout; a render never waits on them. Outside a repository the row reads no git and gh is not asked.The rows use your terminal theme's own palette (its normal colour slots, plus gray), so they follow light and dark themes alike.
28k/200k figure, which is the last API response's.claude plugin validate plugins/rich-statusline
claude plugin test plugins/rich-statusline
Type-checking needs the declarations the engine lays into .claude-plugin/types/ when it loads the mod from a folder you own (a --plugin-dir or the session's mods folder); tsc -p that folder.
hooks/register.tsx 165 lines1// rich-statusline: wiring only. Collectors, layouts and the settings menu live in src/.
2// `$` is only ever spelled at its call sites here; src/ gets closures (Ports).
3import { atom, read, update } from 'claude-code'
4import type { Register } from 'claude-code'
5import { changeOnly, isSame, updateOnly } from '../src/change-only'
6import { withStep } from '../src/identity'
7import { toUsage } from '../src/collect/usage'
8import { DEFAULT_COLUMNS, layoutColumns, statusLines, statusTree } from '../src/render'
9import { createRuntime } from '../src/runtime'
10import { DEFAULT_SETTINGS, parseSettings, type Settings } from '../src/settings'
11import { applyPick } from '../src/settings-controls'
12import { COMMAND, COMMAND_NAME, settingsBand, togglesMenu } from '../src/settings-band'
13
14const STORE_KEY = 'settings'
15
16// The $.state values (contract: types/index.d.ts). Written here, beside their
17// readers, so the engine's scan can read every reference.
18const settingsAtom = atom({ plugin: 'rich-statusline', key: 'settings' } as const, null)
19const gitAtom = atom({ plugin: 'rich-statusline', key: 'git' } as const, null)
20const prAtom = atom({ plugin: 'rich-statusline', key: 'pr' } as const, null)
21const identityAtom = atom({ plugin: 'rich-statusline', key: 'identity' } as const, null)
22const usageAtom = atom({ plugin: 'rich-statusline', key: 'usage' } as const, null)
23const breakdownAtom = atom({ plugin: 'rich-statusline', key: 'breakdown' } as const, null)
24const nowAtom = atom({ plugin: 'rich-statusline', key: 'now' } as const, 0)
25const settingsOpenAtom = atom({ plugin: 'rich-statusline', key: 'settingsOpen' } as const, false)
26
27const isRetimed = (a: Settings, b: Settings): boolean =>
28 a.gitRefreshSeconds !== b.gitRefreshSeconds || a.prRefreshSeconds !== b.prRefreshSeconds
29
30const describeError = (error: unknown): string => (error instanceof Error ? error.message : String(error))
31
32export const register: Register = on => {
33 const runtime = createRuntime()
34 // Settings changes apply one after another, so the store sees them in order.
35 let applying: Promise<void> = Promise.resolve()
36
37 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
38 if (e.surface !== 'terminal') return next(e)
39 if (!runtime.isAttached()) {
40 // Collectors start from a timer: a render hook itself never writes state.
41 runtime.attach({
42 run: (argv, init) => $.process.run(argv, init),
43 model: () => $.session.model(),
44 cwd: () => $.session.cwd(),
45 home: () => $.env.get('HOME'),
46 configuredEffort: async () => (await $.settings.read()).effortLevel,
47 usage: () => $.session.usage({ breakdown: 'summary' }),
48 now: () => $.clock.now(),
49 every: (ms, fn) => $.clock.every(ms, fn),
50 after: (ms, fn) => $.clock.after(ms, fn),
51 storedSettings: () => $.store.get(STORE_KEY),
52 registerCommand: () => $.command.register(COMMAND),
53 log: text => $.ui.log(text, { to: 'debug' }),
54 git: {
55 get: () => read($, gitAtom),
56 set: changeOnly(() => read($, gitAtom), value => update($, gitAtom, () => value)),
57 },
58 pr: { set: changeOnly(() => read($, prAtom), value => update($, prAtom, () => value)) },
59 identity: {
60 get: () => read($, identityAtom),
61 update: updateOnly(() => read($, identityAtom), change => update($, identityAtom, change)),
62 },
63 usageState: { set: changeOnly(() => read($, usageAtom), value => update($, usageAtom, () => value)) },
64 breakdown: { set: changeOnly(() => read($, breakdownAtom), value => update($, breakdownAtom, () => value)) },
65 clockState: { set: changeOnly(() => read($, nowAtom), value => update($, nowAtom, () => value)) },
66 settings: { set: changeOnly(() => read($, settingsAtom), value => update($, settingsAtom, () => value)) },
67 })
68 }
69 const [engine, settings, git, pr, identity, usage, breakdown, now] = await Promise.all([
70 next(e),
71 read($, settingsAtom),
72 read($, gitAtom),
73 read($, prAtom),
74 read($, identityAtom),
75 read($, usageAtom),
76 read($, breakdownAtom),
77 read($, nowAtom),
78 ])
79 // Until the stored settings load, the engine's line alone: no flash of 1a.
80 // Null once loaded means the session's state was emptied (a /clear the
81 // session.end hook missed): ask for a reseed, which the runtime bounds.
82 if (settings === null) {
83 runtime.stateMissing()
84 return engine
85 }
86 const inputs = { settings: parseSettings(settings), git, pr, identity, usage, breakdown, now }
87 return statusTree($.ui.resolve(e), statusLines(inputs, layoutColumns(e.viewport?.columns ?? DEFAULT_COLUMNS)), engine)
88 })
89
90 // A new or resumed session: the cwd, branch and effort may all have moved.
91 on('session.start', async ($, e, next) => {
92 const started = await next(e)
93 runtime.sessionStarted().catch(error => $.ui.log(`rich-statusline: session: ${describeError(error)}`, { to: 'debug' }))
94 return started
95 })
96
97 // A /clear empties the session's state and no session.start follows: seed it
98 // again once the engine's end step has run (scheduling only: the end is bounded).
99 on('session.end', async (_$, e, next) => {
100 const ended = await next(e)
101 if (e.reason === 'clear') runtime.sessionCleared()
102 return ended
103 })
104
105 on('session.measure', async ($, e, next) => {
106 // One writer per atom: the runtime's serialized port once attached.
107 const usage = toUsage(e)
108 const isWritten = await runtime.setUsage(usage)
109 if (!isWritten && !isSame(await read($, usageAtom), usage)) await update($, usageAtom, () => usage)
110 if (e.changed.includes('context')) runtime.contextChanged()
111 return next(e)
112 })
113
114 on('turn.step', async function* ($, e, next) {
115 if (e.agentId === undefined) {
116 // Through the runtime's serialized identity writer once attached, so a
117 // step never races the session's own identity refreshes.
118 const change = (held: Parameters<typeof withStep>[0]) => withStep(held, e)
119 runtime
120 .updateIdentity(change)
121 .then(isApplied => (isApplied ? undefined : update($, identityAtom, change)))
122 .catch(error => $.ui.log(`rich-statusline: step: ${describeError(error)}`, { to: 'debug' }))
123 }
124 return yield* next(e)
125 })
126
127 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
128 const ran = await next(e)
129 runtime.cwdMaybeChanged().catch(error => $.ui.log(`rich-statusline: cwd: ${describeError(error)}`, { to: 'debug' }))
130 return ran
131 })
132
133 // Toggles the settings menu in the band above the prompt; no output row.
134 on('command.run', { command: 'rich-statusline' }, async ($, e) => {
135 if (!togglesMenu(e.args)) return { text: `Usage: /${COMMAND_NAME} [settings]` }
136 await update($, settingsOpenAtom, isOpen => !isOpen)
137 return {}
138 })
139
140 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
141 // The band is raised on the terminal and desktop, the two with a Select.
142 if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e)
143 if (e.props.hasSurvey || !(await read($, settingsOpenAtom))) return next(e)
144 const settings = parseSettings(await read($, settingsAtom))
145 const fail = (error: unknown) => $.ui.log(`rich-statusline: settings: ${describeError(error)}`, { to: 'debug' })
146 const applyNow = async (change: (held: Settings) => unknown): Promise<void> => {
147 const before = parseSettings(await read($, settingsAtom))
148 const after = parseSettings(change(before))
149 // One writer per atom: the runtime's serialized port once attached.
150 if (!(await runtime.setSettings(after))) await update($, settingsAtom, () => after)
151 await $.store.set(STORE_KEY, after)
152 if (isRetimed(before, after)) runtime.retime(after)
153 }
154 const apply = (change: (held: Settings) => unknown): Promise<void> => {
155 applying = applying.then(() => applyNow(change)).catch(fail)
156 return applying
157 }
158 return settingsBand($.ui.resolve(e), settings, e.props, {
159 onPick: (key, value) => void apply(held => applyPick(held, key, value)),
160 onReset: () => void apply(() => DEFAULT_SETTINGS),
161 onDone: () => void update($, settingsOpenAtom, () => false).catch(fail),
162 })
163 })
164}
165src/change-only.ts 34 lines1// Setters that skip writes equal to the held value: `$.state` redraws every
2// reader on any write, equal or not (an updater returning `held` included).
3
4export const isSame = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b)
5
6/**
7 * A setter over closures, so each `read`/`update` names its atom literally at
8 * the call site, as the engine's scan requires. Calls run one after another,
9 * so a skip is never decided against a value another call is replacing.
10 */
11export const changeOnly = <T,>(get: () => Promise<T>, write: (value: T) => Promise<unknown>) => {
12 let queue: Promise<void> = Promise.resolve()
13 return (value: T): Promise<void> => {
14 const next = queue.then(async () => {
15 if (!isSame(await get(), value)) await write(value)
16 })
17 queue = next.catch(() => undefined)
18 return next
19 }
20}
21
22/** The updater form: applies `change` unless it would leave the value as held; serialized too. */
23export const updateOnly = <T,>(get: () => Promise<T>, write: (change: (held: T) => T) => Promise<unknown>) => {
24 let queue: Promise<void> = Promise.resolve()
25 return (change: (held: T) => T): Promise<void> => {
26 const next = queue.then(async () => {
27 const held = await get()
28 if (!isSame(held, change(held))) await write(change)
29 })
30 queue = next.catch(() => undefined)
31 return next
32 }
33}
34src/identity.ts 32 lines1// Pure updates of the identity value: from the session, and from a model step.
2import type { RichStatuslineIdentity } from '../types'
3
4export const effortOf = (value: unknown): string | undefined =>
5 typeof value === 'string' || typeof value === 'number' ? String(value) : undefined
6
7export type SessionFacts = { model: string; cwd: string; home?: string; configuredEffort?: string }
8
9/** The session's facts; an effort a step already reported wins over settings. */
10export const withSession = (held: RichStatuslineIdentity | null, facts: SessionFacts): RichStatuslineIdentity => {
11 const effort = held?.effort ?? facts.configuredEffort
12 return {
13 model: facts.model,
14 cwd: facts.cwd,
15 ...(facts.home === undefined ? {} : { home: facts.home }),
16 ...(effort === undefined ? {} : { effort }),
17 }
18}
19
20/** A main-loop step's model and effort; a step without effort clears it. */
21export const withStep = (
22 held: RichStatuslineIdentity | null,
23 step: { model: string; effort?: string | number },
24): RichStatuslineIdentity => {
25 const effort = effortOf(step.effort)
26 const base = { model: step.model, cwd: held?.cwd ?? '', ...(held?.home === undefined ? {} : { home: held.home }) }
27 return effort === undefined ? base : { ...base, effort }
28}
29
30export const withCwd = (held: RichStatuslineIdentity | null, cwd: string): RichStatuslineIdentity | null =>
31 held === null ? held : { ...held, cwd }
32src/collect/usage.ts 42 lines1// Session usage figures to the plain values the state contract holds.
2import type { RichStatuslineBreakdown, RichStatuslineRateLimit, RichStatuslineUsage } from '../../types'
3import { type BreakdownRow, foldCategories } from '../categories'
4
5export type MeasuredUsage = {
6 context: { tokens?: number; window: number }
7 rateLimits: readonly { kind: string; percentUsed: number; resetsAt?: string }[]
8 cost?: { usd: number }
9}
10
11export type MeasuredBreakdown = {
12 categories: readonly BreakdownRow[]
13 rawMaxTokens: number
14 autoCompactThreshold?: number
15 isAutoCompactEnabled: boolean
16}
17
18const toRateLimit = (limit: MeasuredUsage['rateLimits'][number]): RichStatuslineRateLimit => {
19 const resetsAt = limit.resetsAt === undefined ? Number.NaN : Date.parse(limit.resetsAt)
20 const base = { kind: limit.kind, percentUsed: limit.percentUsed }
21 return Number.isFinite(resetsAt) ? { ...base, resetsAt } : base
22}
23
24export const toUsage = (measured: MeasuredUsage): RichStatuslineUsage => ({
25 ...(measured.context.tokens === undefined ? {} : { tokens: measured.context.tokens }),
26 window: measured.context.window,
27 rateLimits: measured.rateLimits.map(toRateLimit),
28 ...(measured.cost === undefined ? {} : { costUsd: measured.cost.usd }),
29})
30
31const compactThresholdOf = ({ autoCompactThreshold, isAutoCompactEnabled }: MeasuredBreakdown): number | undefined =>
32 isAutoCompactEnabled && autoCompactThreshold !== undefined && autoCompactThreshold > 0 ? autoCompactThreshold : undefined
33
34export const toBreakdown = (measured: MeasuredBreakdown): RichStatuslineBreakdown => {
35 const compactThreshold = compactThresholdOf(measured)
36 return {
37 categories: foldCategories(measured.categories),
38 rawMaxTokens: measured.rawMaxTokens,
39 ...(compactThreshold === undefined ? {} : { compactThreshold }),
40 }
41}
42src/render.tsx 50 lines1// Layout lines to terminal elements, and the status rows over the engine's line.
2import type { Elements, RenderElement } from 'claude-code'
3import { renderLayout } from './layouts/index'
4import { BLANK_LINE, type Line, type Span } from './line'
5import { buildSnapshot, type SnapshotInputs } from './snapshot'
6import { viewOptions } from './view-options'
7
8export const DEFAULT_COLUMNS = 120
9/**
10 * Columns the engine pads the PromptHint tree by: 2 on the left (its own hint
11 * line sits at the same inset) and 2 on the right, measured in a live session.
12 * PromptHint carries no width prop, so a row sized to `viewport.columns` runs
13 * this far past the edge and is cut with `…`.
14 */
15export const PROMPT_HINT_INSET = 4
16
17/** The width our rows lay out in at a viewport this wide: the inset taken off, never below 0. Pure. */
18export const layoutColumns = (viewportColumns: number): number => Math.max(0, viewportColumns - PROMPT_HINT_INSET)
19
20export type StatusElements = Pick<Elements['terminal'], 'Box' | 'Text'>
21
22const spanNode = (Text: StatusElements['Text'], { text, color, dim, bold }: Span) =>
23 color === undefined && dim !== true && bold !== true ? (
24 text
25 ) : (
26 <Text
27 {...(color === undefined ? {} : { color })}
28 {...(dim === true ? { dimColor: true } : {})}
29 {...(bold === true ? { bold } : {})}
30 >
31 {text}
32 </Text>
33 )
34
35export const lineNode = (Text: StatusElements['Text'], line: Line) => (
36 <Text wrap="truncate">{line.map(s => spanNode(Text, s))}</Text>
37)
38
39/** The rows the inputs draw at `columns`. Pure. */
40export const statusLines = (inputs: SnapshotInputs, columns: number): Line[] =>
41 renderLayout(buildSnapshot(inputs), viewOptions(inputs.settings, columns))
42
43/** One blank row above our rows, then the engine's own line unchanged, right after them. */
44export const statusTree = ({ Box, Text }: StatusElements, lines: readonly Line[], engine: RenderElement) => (
45 <Box flexDirection="column">
46 {[BLANK_LINE, ...lines].map(line => lineNode(Text, line))}
47 {engine}
48 </Box>
49)
50src/runtime.ts 279 lines1// The collectors' lifecycle: timers, a debounced breakdown, cached results.
2// Created once per module load (a hot reload drops it with its timers). It
3// never sees `$`: the hooks hand it Ports, closures over their own `$` calls.
4import type { RichStatuslineBreakdown, RichStatuslineGit, RichStatuslineIdentity, RichStatuslinePr, RichStatuslineUsage } from '../types'
5import { coalesce } from './coalesce'
6import { collectGit } from './collect/git'
7import { collectPr } from './collect/pr'
8import type { Run } from './collect/run'
9import { type MeasuredBreakdown, type MeasuredUsage, toBreakdown, toUsage } from './collect/usage'
10import { effortOf, withCwd, withSession } from './identity'
11import { parseSettings, type Settings } from './settings'
12
13export const BREAKDOWN_DEBOUNCE_MS = 2_000
14export const TICK_MS = 60_000
15const MS_PER_SECOND = 1_000
16
17export type Timer = { cancel: () => void }
18
19type Change<T> = (held: T) => T
20
21export type Ports = {
22 run: Run
23 model: () => Promise<string>
24 cwd: () => Promise<string>
25 home: () => Promise<string | undefined>
26 configuredEffort: () => Promise<unknown>
27 usage: () => Promise<MeasuredUsage & { context: { breakdown?: MeasuredBreakdown } }>
28 now: () => Promise<number>
29 every: (ms: number, fn: () => void) => Timer
30 after: (ms: number, fn: () => void) => Timer
31 storedSettings: () => Promise<unknown>
32 registerCommand: () => Promise<unknown>
33 log: (text: string) => void
34 git: { get: () => Promise<RichStatuslineGit | null>; set: (value: RichStatuslineGit) => Promise<unknown> }
35 pr: { set: (value: RichStatuslinePr) => Promise<unknown> }
36 identity: {
37 get: () => Promise<RichStatuslineIdentity | null>
38 update: (change: Change<RichStatuslineIdentity | null>) => Promise<unknown>
39 }
40 usageState: { set: (value: RichStatuslineUsage) => Promise<unknown> }
41 breakdown: { set: (value: RichStatuslineBreakdown) => Promise<unknown> }
42 clockState: { set: (value: number) => Promise<unknown> }
43 settings: { set: (value: Settings) => Promise<unknown> }
44}
45
46export type Runtime = {
47 /** Starts collecting with these ports, once per load; later calls do nothing. */
48 attach: (ports: Ports) => void
49 isAttached: () => boolean
50 /** Asks for a breakdown once the context has been quiet for the debounce. */
51 contextChanged: () => void
52 cwdMaybeChanged: () => Promise<void>
53 /**
54 * Applies an identity change through the attached, serialized identity port;
55 * false before attach (nothing else writes identity then).
56 */
57 updateIdentity: (change: (held: RichStatuslineIdentity | null) => RichStatuslineIdentity | null) => Promise<boolean>
58 /** Writes usage through the attached port; false before attach. */
59 setUsage: (usage: RichStatuslineUsage) => Promise<boolean>
60 /** Writes settings through the attached port; false before attach. */
61 setSettings: (settings: Settings) => Promise<boolean>
62 /** A new or resumed session: identity, cwd, git and the breakdown again. */
63 sessionStarted: () => Promise<void>
64 /**
65 * A /clear emptied the session's state under this live runtime: seeds it
66 * again from a timer, refresh timers left running. A seed asked while one
67 * runs folds into one rerun; nothing before attach.
68 */
69 sessionCleared: () => void
70 /**
71 * A draw found the state empty (a clear the event missed): as
72 * sessionCleared, but at most once per TICK_MS since the last seed began, so
73 * a write that keeps failing cannot turn every redraw into a reseed. A
74 * failed clock lifts the bound: the reseed then runs per draw, as no seed
75 * that failed its write draws again by itself.
76 */
77 stateMissing: () => void
78 /** Restarts the refresh timers when their intervals changed. */
79 retime: (settings: Settings) => void
80}
81
82const describeError = (error: unknown): string => (error instanceof Error ? error.message : String(error))
83
84const collectors = (ports: Ports, background: (what: string, task: () => Promise<void>) => void) => {
85 const cwdOf = async (): Promise<string> => (await ports.identity.get())?.cwd || (await ports.cwd())
86 const refreshPr = coalesce(async () => {
87 const git = await ports.git.get()
88 const branch = git?.branch ?? null
89 const root = git?.root ?? null
90 const label = branch === null ? null : await collectPr(ports.run, root ?? (await cwdOf()))
91 const now = await ports.git.get()
92 const isCurrent = (now?.branch ?? null) === branch && (now?.root ?? null) === root
93 if (label !== undefined && isCurrent) await ports.pr.set({ label, root, branch })
94 })
95 const refreshGit = coalesce(async () => {
96 const cwd = await cwdOf()
97 const before = await ports.git.get()
98 const git = await collectGit(ports.run, cwd)
99 if (git === undefined || (await cwdOf()) !== cwd) return
100 await ports.git.set(git)
101 if (git.branch !== before?.branch || git.root !== before?.root) background('pr', refreshPr)
102 })
103 const loadBreakdown = async (): Promise<void> => {
104 const usage = await ports.usage()
105 await ports.usageState.set(toUsage(usage))
106 if (usage.context.breakdown !== undefined) await ports.breakdown.set(toBreakdown(usage.context.breakdown))
107 }
108 const loadIdentity = async (): Promise<void> => {
109 const [model, cwd, home, effort] = await Promise.all([
110 ports.model(),
111 ports.cwd(),
112 ports.home(),
113 ports.configuredEffort().catch(() => undefined),
114 ])
115 const configuredEffort = effortOf(effort)
116 await ports.identity.update(held =>
117 withSession(held, {
118 model,
119 cwd,
120 ...(home === undefined ? {} : { home }),
121 ...(configuredEffort === undefined ? {} : { configuredEffort }),
122 }),
123 )
124 }
125 const tick = async (): Promise<void> => {
126 await ports.clockState.set(await ports.now())
127 }
128 return { refreshPr, refreshGit, loadBreakdown, loadIdentity, tick }
129}
130
131export const createRuntime = (): Runtime => {
132 let ports: Ports | null = null
133 let work: ReturnType<typeof collectors> | null = null
134 let timers: Timer[] = []
135 let pendingBreakdown: Timer | null = null
136 // Set at attach, each coalesced (one at a time, a burst folds into one rerun):
137 // the seed itself, and the draw-asked one that first checks the bound.
138 let seeding: (() => Promise<void>) | null = null
139 let seedingIfStale: (() => Promise<void>) | null = null
140 // When the last seed began; a clock that failed reads as long ago.
141 let lastSeedAt: Promise<number> | null = null
142
143 const background = (what: string, task: () => Promise<void>): void => {
144 task().catch(error => ports?.log(`rich-statusline: ${what} failed: ${describeError(error)}`))
145 }
146
147 const startTimers = (p: Ports, w: ReturnType<typeof collectors>, settings: Settings): void => {
148 timers.forEach(timer => timer.cancel())
149 timers = [
150 p.every(settings.gitRefreshSeconds * MS_PER_SECOND, () => background('git', w.refreshGit)),
151 p.every(settings.prRefreshSeconds * MS_PER_SECOND, () => background('pr', w.refreshPr)),
152 p.every(TICK_MS, () => background('tick', w.tick)),
153 ]
154 }
155
156 /** Starts the refresh timers unless running; a failed start is retried by the next seed. */
157 const ensureTimers = (p: Ports, w: ReturnType<typeof collectors>, settings: Settings): void => {
158 if (timers.length > 0) return
159 try {
160 startTimers(p, w, settings)
161 } catch (error) {
162 p.log(`rich-statusline: timers failed: ${describeError(error)}`)
163 }
164 }
165
166 /**
167 * Settings, then the timers (the first seed starts them), then each first
168 * read, each guarded: no failure stops the rest. Without the settings
169 * written nothing draws, so the reads are skipped: their writes would only
170 * redraw an empty line, which asks for another seed.
171 */
172 const seed = async (p: Ports, w: ReturnType<typeof collectors>): Promise<void> => {
173 lastSeedAt = p.now().catch(() => Number.NEGATIVE_INFINITY)
174 const settings = parseSettings(await p.storedSettings().catch(() => undefined))
175 const isWritten = await p.settings.set(settings).then(
176 () => true,
177 error => (p.log(`rich-statusline: settings failed: ${describeError(error)}`), false),
178 )
179 ensureTimers(p, w, settings)
180 if (!isWritten) return
181 // Again on a reseed: a command is declared per session, and a second
182 // register replaces the first.
183 background('command', async () => {
184 await p.registerCommand()
185 })
186 background('identity', w.loadIdentity)
187 background('tick', w.tick)
188 background('breakdown', w.loadBreakdown)
189 background('git', w.refreshGit)
190 background('pr', w.refreshPr)
191 }
192
193 /**
194 * Whether the last seed began at least a tick ago (or none has). A failed
195 * clock reads as stale; a seed begun while this waited reads as fresh.
196 */
197 const isSeedStale = async (p: Ports): Promise<boolean> => {
198 const last = lastSeedAt
199 if (last === null) return true
200 const [now, then] = await Promise.all([p.now().catch(() => Number.POSITIVE_INFINITY), last])
201 return lastSeedAt === last && now - then >= TICK_MS
202 }
203
204 /**
205 * Each ask gets its own timer (a render hook must not write state), so no
206 * flag waits on a callback the engine may refuse to run.
207 */
208 const later = (p: Ports, what: string, task: () => Promise<void>): void => {
209 p.after(0, () => background(what, task))
210 }
211
212 return {
213 attach: next => {
214 if (ports !== null) return
215 ports = next
216 const w = collectors(next, background)
217 work = w
218 const run = coalesce(() => seed(next, w))
219 seeding = run
220 seedingIfStale = coalesce(async () => {
221 if (await isSeedStale(next)) await run()
222 })
223 later(next, 'start', run)
224 },
225 isAttached: () => ports !== null,
226 contextChanged: () => {
227 const p = ports
228 const w = work
229 if (p === null || w === null) return
230 pendingBreakdown?.cancel()
231 pendingBreakdown = p.after(BREAKDOWN_DEBOUNCE_MS, () => {
232 pendingBreakdown = null
233 background('breakdown', w.loadBreakdown)
234 })
235 },
236 cwdMaybeChanged: async () => {
237 const p = ports
238 const w = work
239 if (p === null || w === null) return
240 const cwd = await p.cwd()
241 if ((await p.identity.get())?.cwd === cwd) return
242 await p.identity.update(held => withCwd(held, cwd))
243 await w.refreshGit()
244 },
245 sessionStarted: async () => {
246 const w = work
247 if (w === null) return
248 // Identity first (git reads its cwd), but a failure there stops nothing.
249 await w.loadIdentity().catch(error => ports?.log(`rich-statusline: identity failed: ${describeError(error)}`))
250 background('breakdown', w.loadBreakdown)
251 background('git', w.refreshGit)
252 },
253 sessionCleared: () => {
254 if (ports !== null && seeding !== null) later(ports, 'reseed', seeding)
255 },
256 stateMissing: () => {
257 if (ports !== null && seedingIfStale !== null) later(ports, 'reseed', seedingIfStale)
258 },
259 updateIdentity: async change => {
260 if (ports === null) return false
261 await ports.identity.update(change)
262 return true
263 },
264 setUsage: async usage => {
265 if (ports === null) return false
266 await ports.usageState.set(usage)
267 return true
268 },
269 setSettings: async settings => {
270 if (ports === null) return false
271 await ports.settings.set(settings)
272 return true
273 },
274 retime: settings => {
275 if (ports !== null && work !== null) startTimers(ports, work, settings)
276 },
277 }
278}
279src/settings.ts 65 lines1// The settings model: defaults, bounds and a validating parser.
2import type { RichStatuslineLayout, RichStatuslineSettings } from '../types'
3
4export type Settings = RichStatuslineSettings
5
6export const LAYOUTS: readonly RichStatuslineLayout[] = ['1a', '1b', '1c']
7
8export const DEFAULT_SETTINGS: Settings = Object.freeze({
9 layout: '1b',
10 showCost: true,
11 showPr: true,
12 showDiff: true,
13 showWorktree: true,
14 showLegend: true,
15 amberPercent: 70,
16 redPercent: 90,
17 gitRefreshSeconds: 10,
18 prRefreshSeconds: 60,
19})
20
21type Range = { min: number; max: number }
22
23export const BOUNDS = {
24 amberPercent: { min: 1, max: 99 },
25 redPercent: { min: 2, max: 100 },
26 gitRefreshSeconds: { min: 2, max: 600 },
27 prRefreshSeconds: { min: 10, max: 3600 },
28} as const satisfies Record<string, Range>
29
30const isRecord = (value: unknown): value is Record<string, unknown> =>
31 typeof value === 'object' && value !== null && !Array.isArray(value)
32
33const pickBoolean = (value: unknown, fallback: boolean): boolean => (typeof value === 'boolean' ? value : fallback)
34
35const pickInteger = (value: unknown, { min, max }: Range, fallback: number): number =>
36 typeof value === 'number' && Number.isInteger(value) && value >= min && value <= max ? value : fallback
37
38const pickLayout = (value: unknown): RichStatuslineLayout =>
39 LAYOUTS.find(layout => layout === value) ?? DEFAULT_SETTINGS.layout
40
41const pickThresholds = (raw: Record<string, unknown>): Pick<Settings, 'amberPercent' | 'redPercent'> => {
42 const amberPercent = pickInteger(raw.amberPercent, BOUNDS.amberPercent, DEFAULT_SETTINGS.amberPercent)
43 const redPercent = pickInteger(raw.redPercent, BOUNDS.redPercent, DEFAULT_SETTINGS.redPercent)
44 if (amberPercent < redPercent) return { amberPercent, redPercent }
45 return amberPercent < DEFAULT_SETTINGS.redPercent
46 ? { amberPercent, redPercent: DEFAULT_SETTINGS.redPercent }
47 : { amberPercent: DEFAULT_SETTINGS.amberPercent, redPercent: DEFAULT_SETTINGS.redPercent }
48}
49
50/** Stored JSON to a valid Settings: each invalid field falls back to its default. */
51export const parseSettings = (raw: unknown): Settings => {
52 if (!isRecord(raw)) return DEFAULT_SETTINGS
53 return {
54 layout: pickLayout(raw.layout),
55 showCost: pickBoolean(raw.showCost, DEFAULT_SETTINGS.showCost),
56 showPr: pickBoolean(raw.showPr, DEFAULT_SETTINGS.showPr),
57 showDiff: pickBoolean(raw.showDiff, DEFAULT_SETTINGS.showDiff),
58 showWorktree: pickBoolean(raw.showWorktree, DEFAULT_SETTINGS.showWorktree),
59 showLegend: pickBoolean(raw.showLegend, DEFAULT_SETTINGS.showLegend),
60 ...pickThresholds(raw),
61 gitRefreshSeconds: pickInteger(raw.gitRefreshSeconds, BOUNDS.gitRefreshSeconds, DEFAULT_SETTINGS.gitRefreshSeconds),
62 prRefreshSeconds: pickInteger(raw.prRefreshSeconds, BOUNDS.prRefreshSeconds, DEFAULT_SETTINGS.prRefreshSeconds),
63 }
64}
65src/settings-controls.ts 80 lines1// The settings menu's controls as plain data: one Select per setting. Pure.
2import { LAYOUTS, type Settings } from './settings'
3
4export type ControlOption = { value: string; label: string }
5
6export type Control = { key: keyof Settings; label: string; value: string; options: ControlOption[] }
7
8const LAYOUT_LABELS = { '1a': 'Grouped rows', '1b': 'Labeled grid', '1c': 'Compact' } as const
9
10const ON_OFF: ControlOption[] = [
11 { value: 'on', label: 'on' },
12 { value: 'off', label: 'off' },
13]
14
15const numbered = (values: readonly number[], current: number, suffix: string): ControlOption[] =>
16 [...new Set([...values, current])].sort((a, b) => a - b).map(value => ({ value: String(value), label: `${value}${suffix}` }))
17
18const AMBER_CHOICES = [50, 60, 65, 70, 75, 80, 85]
19const RED_CHOICES = [75, 80, 85, 90, 95, 100]
20const GIT_CHOICES = [5, 10, 15, 30, 60]
21const PR_CHOICES = [30, 60, 120, 300, 600]
22
23const toggle = (key: keyof Settings, label: string, isOn: boolean): Control => ({
24 key,
25 label,
26 value: isOn ? 'on' : 'off',
27 options: ON_OFF,
28})
29
30export const settingsControls = (s: Settings): Control[] => [
31 {
32 key: 'layout',
33 label: 'Layout',
34 value: s.layout,
35 options: LAYOUTS.map(layout => ({ value: layout, label: LAYOUT_LABELS[layout] })),
36 },
37 toggle('showCost', 'Show cost', s.showCost),
38 toggle('showPr', 'Show PR', s.showPr),
39 toggle('showDiff', 'Show diff stats', s.showDiff),
40 toggle('showWorktree', 'Show worktree', s.showWorktree),
41 toggle('showLegend', 'Show legend (1a/1b)', s.showLegend),
42 {
43 key: 'amberPercent',
44 label: 'Amber at',
45 value: String(s.amberPercent),
46 options: numbered(AMBER_CHOICES.filter(v => v < s.redPercent), s.amberPercent, '%'),
47 },
48 {
49 key: 'redPercent',
50 label: 'Red at',
51 value: String(s.redPercent),
52 options: numbered(RED_CHOICES.filter(v => v > s.amberPercent), s.redPercent, '%'),
53 },
54 { key: 'gitRefreshSeconds', label: 'Git refresh', value: String(s.gitRefreshSeconds), options: numbered(GIT_CHOICES, s.gitRefreshSeconds, 's') },
55 { key: 'prRefreshSeconds', label: 'PR refresh', value: String(s.prRefreshSeconds), options: numbered(PR_CHOICES, s.prRefreshSeconds, 's') },
56]
57
58const BOOLEAN_KEYS: ReadonlySet<keyof Settings> = new Set([
59 'showCost',
60 'showPr',
61 'showDiff',
62 'showWorktree',
63 'showLegend',
64])
65const NUMBER_KEYS: ReadonlySet<keyof Settings> = new Set([
66 'amberPercent',
67 'redPercent',
68 'gitRefreshSeconds',
69 'prRefreshSeconds',
70])
71
72/** A pick applied to the settings as raw data; parseSettings validates it. */
73export const applyPick = (settings: Settings, key: string, value: string): Record<string, unknown> => {
74 const field = key as keyof Settings
75 if (BOOLEAN_KEYS.has(field)) return { ...settings, [field]: value === 'on' }
76 if (NUMBER_KEYS.has(field)) return { ...settings, [field]: Number(value) }
77 if (field === 'layout') return { ...settings, layout: value }
78 return settings
79}
80src/settings-band.tsx 125 lines1// The settings menu, drawn in the band above the prompt (AbovePrompt) while
2// /rich-statusline has it open. The band, not a Pane: in some terminals (tmux
3// under Leo) a placed Pane is never drawn, while the band always is.
4import type { CommandSpec, Elements } from 'claude-code'
5import type { Settings } from './settings'
6import { type Control, settingsControls } from './settings-controls'
7
8export const COMMAND_NAME = 'rich-statusline'
9export const BAND_TITLE = 'rich-statusline settings'
10export const BAND_HINT = 'ctrl+x tab to focus · ↑↓/tab move · enter change'
11/** What a Select draws around its label and value; the types do not say. */
12const SELECT_CHROME = 4
13/** Rows besides the controls: the title row (with Done and Reset), the hint. */
14const TITLE_ROWS = 1
15const HINT_ROWS = 1
16const PREFERRED_PER_ROW = 2
17const MOST_PER_ROW = 3
18/** Columns between two cells of a row: a Select draws to its cell's edge, so cells need it to stay apart. */
19const CELL_GAP = 2
20
21export const COMMAND: CommandSpec = {
22 name: COMMAND_NAME,
23 description: 'Rich statusline settings: layout, cost, PR, diff stats, legend, thresholds, refresh',
24 argumentHint: '[settings]',
25 immediate: true,
26}
27
28/** True for the arguments that toggle the menu: none, or `settings`. */
29export const togglesMenu = (args: string): boolean => ['', 'settings'].includes(args.trim().toLowerCase())
30
31export type BandHandlers = {
32 onPick: (key: string, value: string) => void
33 onReset: () => void
34 onDone: () => void
35}
36
37export type BandElements = Pick<Elements['terminal'], 'Box' | 'Text' | 'Select' | 'Button'>
38
39/** Controls in rows of `perRow`, in order. Pure. */
40export const rowsOfControls = (controls: readonly Control[], perRow: number): Control[][] =>
41 controls.reduce<Control[][]>(
42 (rows, control, index) =>
43 index % perRow === 0 ? [...rows, [control]] : [...rows.slice(0, -1), [...(rows[rows.length - 1] ?? []), control]],
44 [],
45 )
46
47const cellsOf = (text: string): number => [...text].length
48const longest = (texts: readonly string[]): number => Math.max(0, ...texts.map(cellsOf))
49
50/** Cells one control needs unwrapped: longest label + longest option + chrome. */
51export const cellNeed = (controls: readonly Control[]): number =>
52 longest(controls.map(control => control.label)) +
53 longest(controls.flatMap(control => control.options.map(option => option.label))) +
54 SELECT_CHROME
55
56export type BandLayout = { perRow: number; cellWidth: number; gap: number; labelWidth: number; showHint: boolean }
57
58/** Cells of `need` that fit `bodyColumns` with `CELL_GAP` between each two. Pure. */
59const cellsFitting = (bodyColumns: number, need: number): number => Math.floor((bodyColumns + CELL_GAP) / (need + CELL_GAP))
60
61/**
62 * How the band fits `bodyColumns` × `maxRows`: two controls to a row when no
63 * cell would wrap and `CELL_GAP` still parts them; over `maxRows`, drop the
64 * hint first, then three to a row when the width allows. Pure.
65 */
66export const bandLayout = (controls: readonly Control[], bodyColumns: number, maxRows: number): BandLayout => {
67 const need = cellNeed(controls)
68 const widest = Math.max(1, Math.min(MOST_PER_ROW, cellsFitting(bodyColumns, need)))
69 const rowsAt = (perRow: number, showHint: boolean) =>
70 TITLE_ROWS + (showHint ? HINT_ROWS : 0) + Math.ceil(controls.length / perRow)
71 const preferred = Math.min(PREFERRED_PER_ROW, widest)
72 const showHint = rowsAt(preferred, true) <= maxRows
73 const perRow = showHint || rowsAt(preferred, false) <= maxRows ? preferred : widest
74 return {
75 perRow,
76 cellWidth: Math.floor((bodyColumns - CELL_GAP * (perRow - 1)) / perRow),
77 gap: CELL_GAP,
78 labelWidth: longest(controls.map(control => control.label)),
79 showHint,
80 }
81}
82
83export const settingsBand = (
84 { Box, Text, Select, Button }: BandElements,
85 settings: Settings,
86 { bodyColumns, maxRows }: { bodyColumns: number; maxRows: number },
87 handlers: BandHandlers,
88) => {
89 const controls = settingsControls(settings)
90 const { perRow, cellWidth, gap, labelWidth, showHint } = bandLayout(controls, bodyColumns, maxRows)
91 const select = (control: Control, isFirst: boolean) => (
92 <Box width={cellWidth}>
93 <Select
94 key={control.key}
95 label={control.label.padEnd(labelWidth)}
96 options={control.options}
97 value={control.value}
98 onSelect={value => handlers.onPick(control.key, value)}
99 {...(isFirst ? { autoFocus: true as const } : {})}
100 />
101 </Box>
102 )
103 return (
104 <Box flexDirection="column" width={bodyColumns}>
105 <Box flexDirection="row" gap={2}>
106 <Text bold wrap="truncate">
107 {BAND_TITLE}
108 </Text>
109 <Button key="done" label="Done" hotkey="d" variant="primary" role="dismiss" onPress={handlers.onDone} />
110 <Button key="reset" label="Reset to defaults" hotkey="r" onPress={handlers.onReset} />
111 </Box>
112 {showHint ? (
113 <Text dimColor wrap="truncate">
114 {BAND_HINT}
115 </Text>
116 ) : null}
117 {rowsOfControls(controls, perRow).map((row, rowIndex) => (
118 <Box flexDirection="row" gap={gap}>
119 {row.map((control, index) => select(control, rowIndex === 0 && index === 0))}
120 </Box>
121 ))}
122 </Box>
123 )
124}
125src/categories.ts 33 lines1// Folds /context's breakdown rows into the five categories the bar draws.
2import type { RichStatuslineCategory, RichStatuslineCategoryKey } from '../types'
3
4export type BreakdownRow = { name: string; tokens: number; kind: string }
5
6export const CATEGORY_ORDER: readonly RichStatuslineCategoryKey[] = ['system', 'tools', 'mcp', 'memory', 'chat']
7
8const BY_NAME: Readonly<Record<string, RichStatuslineCategoryKey>> = {
9 'System prompt': 'system',
10 'System tools': 'tools',
11 Skills: 'tools',
12 'Custom agents': 'tools',
13 'Slash commands': 'tools',
14 'MCP tools': 'mcp',
15 'Memory files': 'memory',
16 Messages: 'chat',
17}
18
19const keyFor = (name: string): RichStatuslineCategoryKey => BY_NAME[name] ?? 'tools'
20
21/**
22 * Only `kind === 'used'` rows count (the API: branch on kind, never on name).
23 * MCP tools loaded through tool search are `deferred`, outside the window,
24 * which is why mcp is often 0; the legends then leave it out.
25 */
26export const foldCategories = (rows: readonly BreakdownRow[]): RichStatuslineCategory[] => {
27 const used = rows.filter(row => row.kind === 'used')
28 return CATEGORY_ORDER.map(key => ({
29 key,
30 tokens: used.filter(row => keyFor(row.name) === key).reduce((total, row) => total + Math.max(0, row.tokens), 0),
31 }))
32}
33src/layouts/index.ts 16 lines1// Picks the layout the view options name; every layout ends in the shared rule.
2import type { Line } from '../line'
3import type { Snapshot } from '../snapshot'
4import type { ViewOptions } from '../view-options'
5import { layout1a } from './1a'
6import { layout1b } from './1b'
7import { layout1c } from './1c'
8import { ruleRow } from './parts'
9
10const LAYOUTS = { '1a': layout1a, '1b': layout1b, '1c': layout1c } as const
11
12export const renderLayout = (snapshot: Snapshot, options: ViewOptions): Line[] => [
13 ...LAYOUTS[options.layout](snapshot, options),
14 ruleRow(options.columns),
15]
16src/line.ts 72 lines1// A layout's output: rows of styled spans, independent of any surface.
2
3/** How a span is tinted: a named terminal colour (absent: the default fg), dimmed or not. */
4export type Tone = { readonly color?: string; readonly dim?: true }
5
6export type Span = { readonly text: string; readonly color?: string; readonly dim?: true; readonly bold?: boolean }
7
8export type Line = readonly Span[]
9
10export const span = (text: string, tone: Tone = {}, bold?: boolean): Span => ({
11 text,
12 ...(tone.color === undefined ? {} : { color: tone.color }),
13 ...(tone.dim === true ? { dim: true as const } : {}),
14 ...(bold === true ? { bold } : {}),
15})
16
17export const GAP: Span = span(' ')
18
19/** An empty row (padding); one space so the row keeps its height. */
20export const BLANK_LINE: Line = [span(' ')]
21
22export const widthOf = (spans: readonly Span[]): number =>
23 spans.reduce((total, { text }) => total + [...text].length, 0)
24
25/** Groups of spans joined by a separator, empty groups dropped. */
26export const joinGroups = (groups: readonly (readonly Span[])[], separator: readonly Span[] = [GAP]): Span[] =>
27 groups
28 .filter(group => group.length > 0)
29 .flatMap((group, index) => (index === 0 ? [...group] : [...separator, ...group]))
30
31/** Left and right spans pushed to the row's two ends across `width` cells. */
32export const justify = (left: readonly Span[], right: readonly Span[], width: number): Span[] => {
33 const pad = Math.max(left.length > 0 && right.length > 0 ? 2 : 0, width - widthOf(left) - widthOf(right))
34 return [...left, ...(pad > 0 ? [span(' '.repeat(pad))] : []), ...right]
35}
36
37/** Neighbouring spans of one style merged, empty spans dropped. */
38export const mergeRuns = (spans: readonly Span[]): Span[] =>
39 spans.reduce<Span[]>((merged, next) => {
40 if (next.text === '') return merged
41 const last = merged[merged.length - 1]
42 return last !== undefined && last.color === next.color && last.dim === next.dim && last.bold === next.bold
43 ? [...merged.slice(0, -1), { ...last, text: last.text + next.text }]
44 : [...merged, next]
45 }, [])
46
47/** The first candidate that fits `width` cells, else the last (the leanest). */
48export const firstFitting = (candidates: readonly Line[], width: number): Line =>
49 candidates.find(line => widthOf(line) <= width) ?? candidates[candidates.length - 1] ?? []
50
51const ELLIPSIS = '…'
52
53/** The line cut to `width` cells, its last cell an ellipsis; as is when it fits. */
54export const truncateLine = (line: Line, width: number): Line => {
55 if (widthOf(line) <= width) return line
56 if (width < 1) return []
57 const { kept } = line.reduce<{ kept: Span[]; room: number }>(
58 ({ kept, room }, next) => {
59 if (room <= 0) return { kept, room }
60 const cells = [...next.text]
61 if (cells.length < room) return { kept: [...kept, next], room: room - cells.length }
62 return { kept: [...kept, { ...next, text: cells.slice(0, room - 1).join('') + ELLIPSIS }], room: 0 }
63 },
64 { kept: [], room: width },
65 )
66 return kept
67}
68
69/** firstFitting, the leanest candidate cut to `width` when none fits. */
70export const fitOrTruncate = (candidates: readonly Line[], width: number): Line =>
71 truncateLine(firstFitting(candidates, width), width)
72