A status line under the prompt: model, folder and branch, context, 5h/7d limits and cache. Off by default; /statusline on turns it on.

A status line under the prompt: model, folder and branch, a context bar with percent and tokens, the 5h and 7d rate limits with a reset countdown, and a cache dot (ok / over). It adapts to the terminal width (bars and reset times drop first, then the model and folder), and shows the engine's background shell/monitor pill on a second row while Claude has any running.
It is off by default. Turn it on with:
/statusline on
/statusline off turns it off./statusline with no argument opens its settings pane (On / Off).statusLine out of settings.json and keeps a copy in settings.json.statusline.bak; turning it off puts it back. If settings.json is not valid JSON, nothing is written.Works on its own, or together with the optional menu mod: then the menu lists "Status line" and shows this page in its pane, and a ◆ mods button appears at the end of the row.
/plugin marketplace add danielmalka/malka-claude-mods
/plugin install statusline@malka-claude-mods
Turn it off first, so your previous statusLine is restored:
/statusline off
/plugin uninstall statusline@malka-claude-mods
Backup files stay next to settings.json (settings.json.statusline.bak, and settings.json.statusline.prev.bak if a saved line was superseded); delete them if you no longer need them.
Type declarations in .claude-plugin/types/ are generated by Claude Code when the mod loads (not committed); the mod's own state contract is types/index.d.ts, and types/menu.d.ts vendors the menu mod's contract (apiVersion 1). Then claude plugin test plugins/statusline and tsc -p plugins/statusline.
Uma linha de status abaixo do prompt: modelo, pasta e branch, barra de contexto com porcentagem e tokens, limites de 5h e 7d com contagem regressiva até o reset e um ponto de cache (ok / over). Se adapta à largura do terminal e mostra a pílula de shells/monitores em segundo plano numa segunda linha.
Vem desligada. Para ligar: /statusline on. /statusline off desliga; /statusline sem argumento abre o painel de configuração (On / Off). Ao ligar, o seu statusLine do settings.json é guardado em settings.json.statusline.bak e volta ao desligar. Independente de ligar/desligar, mostra um aviso (toast) uma vez por sessão quando o contexto passa de 60% ("consider /compact") e de novo aos 80% ("time to /compact"). Funciona sozinha ou com o mod opcional menu (que a lista e mostra a página no seu painel, com um botão ◆ mods).
Instalar: /plugin marketplace add danielmalka/malka-claude-mods e depois /plugin install statusline@malka-claude-mods. Desinstalar: rode /statusline off antes (para restaurar o seu statusLine) e então /plugin uninstall statusline@malka-claude-mods.
hooks/register.tsx 277 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type {} from '../types/menu'
5import type { MenuEntry, StatusSnapshot } from '../types'
6import { configDir, moveStatusLine, parseSettings, type Settings } from './settings'
7import { checkContext, resetContextToasts } from './context-toasts'
8import { LIMIT_LABELS } from './status'
9import { statusPage, statusRow } from './status-line'
10
11const isOnAtom = atom({ plugin: 'statusline', key: 'isOn' } as const, false)
12const snapshotAtom = atom({ plugin: 'statusline', key: 'snapshot' } as const, null)
13const lastResponseAt = atom({ plugin: 'statusline', key: 'lastResponseAt' } as const, null)
14const clockAtom = atom({ plugin: 'statusline', key: 'clock' } as const, 0)
15// Foreign refs: the optional `menu` mod owns them (apiVersion 1); undefined = no menu.
16const menuPanel = { plugin: 'menu', key: 'panel' } as const
17const menuPresent = { plugin: 'menu', key: 'present' } as const
18const menuEntryRef = { plugin: 'statusline', key: 'menuEntry' } as const
19
20const PANE = 'statusline-settings'
21const MENU_PANE = 'mods-menu'
22const BRANCH_MS = 5_000
23const TICK_MS = 15_000
24
25// ~/.claude/settings.json ----------------------------------------------------------------
26// Parsed first, so an invalid file is never overwritten; the first change keeps the original text in settings.json.statusline.bak.
27
28async function settingsPath($: EngineInterface) {
29 const home = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE'))
30 return `${configDir(await $.env.get('CLAUDE_CONFIG_DIR'), home)}/settings.json`
31}
32
33// Moves and mode changes run one at a time: two quick presses cannot interleave their file writes.
34let queue: Promise<unknown> = Promise.resolve()
35function serial<T>(job: () => Promise<T>): Promise<T> {
36 const result = queue.then(job)
37 queue = result.catch(() => undefined)
38 return result
39}
40
41// On: the user's own statusLine is kept (plugin store and the .bak) and taken out of settings.json; off puts it back.
42// The latest statusLine the user set wins; a saved one it supersedes is appended to settings.json.statusline.prev.bak
43// ({ history: [{ statusLine, at }] }, never truncated).
44async function moveNow($: EngineInterface, isOn: boolean) {
45 const path = await settingsPath($)
46 const existed = await $.fs.exists(path)
47 const text = existed ? ((await $.fs.read(path)) as string) : '{}'
48 const settings: Settings = parseSettings(text)
49 const saved = (await $.store.get('savedStatusLine')) as { statusLine: unknown } | undefined
50 const moved = moveStatusLine(settings, isOn, saved)
51 if (moved === undefined) return
52 // the safe copies first, the settings.json write last: a failure never loses the user's value
53 if (moved.prev !== undefined) {
54 const prevPath = `${path}.statusline.prev.bak`
55 const old = (await $.fs.exists(prevPath)) ? parseSettings((await $.fs.read(prevPath)) as string) : {}
56 const history: { statusLine: unknown; at: string }[] = Array.isArray(old.history) ? old.history : []
57 if (JSON.stringify(history[history.length - 1]?.statusLine) !== JSON.stringify(moved.prev)) {
58 history.push({ statusLine: moved.prev, at: new Date(await $.clock.now()).toISOString() })
59 await $.fs.write(prevPath, `${JSON.stringify({ history }, null, 2)}\n`)
60 }
61 $.ui.toast(`Your earlier saved statusLine was kept in ${prevPath} (${history.length} ${history.length === 1 ? 'entry' : 'entries'})`)
62 }
63 if (moved.save !== undefined) await $.store.set('savedStatusLine', moved.save)
64 const backup = `${path}.statusline.bak`
65 if (existed && !(await $.fs.exists(backup))) await $.fs.write(backup, text)
66 if (moved.next !== settings) await $.fs.write(path, `${JSON.stringify(moved.next, null, 2)}\n`)
67 if (moved.drop) await $.store.delete('savedStatusLine')
68}
69
70const moveUserStatusLine = ($: EngineInterface, isOn: boolean) => serial(() => moveNow($, isOn))
71
72// Status data ---------------------------------------------------------------------------
73
74// The git branch is looked up at most once per BRANCH_MS (and when the folder changes).
75let branchAt = 0
76let branchCwd = ''
77let branch = ''
78let ticker: Timer | undefined
79
80async function refreshStatus($: EngineInterface, force = false) {
81 const [model, cwd, usage] = await Promise.all([$.session.model(), $.session.cwd(), $.session.usage()])
82 if (force || cwd !== branchCwd || Date.now() - branchAt >= BRANCH_MS) {
83 branchAt = Date.now()
84 branchCwd = cwd
85 branch = ''
86 try {
87 const ran = await $.process.run(['git', '-C', cwd, 'branch', '--show-current'])
88 if (ran.exitCode === 0) branch = ran.stdout.trim()
89 } catch {}
90 }
91 const snapshot: StatusSnapshot = {
92 model,
93 dir: cwd.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || cwd,
94 branch,
95 ctxPercent: usage.context.percent ?? null,
96 ctxTokens: usage.context.tokens ?? null,
97 ctxWindow: usage.context.window,
98 limits: usage.rateLimits
99 .filter(l => LIMIT_LABELS[l.kind] !== undefined)
100 .map(l => ({
101 label: LIMIT_LABELS[l.kind]!,
102 percent: Math.round(l.percentUsed),
103 resetsAt: l.resetsAt === undefined ? null : Date.parse(l.resetsAt),
104 })),
105 }
106 // nothing drawn changed: no write, no redraw
107 if (JSON.stringify(await read($, snapshotAtom)) !== JSON.stringify(snapshot)) await update($, snapshotAtom, () => snapshot)
108}
109
110const writeEntry = ($: EngineInterface, isOn: boolean) => {
111 const entry: MenuEntry = { apiVersion: 1, title: 'Status line', summary: isOn ? 'on' : 'off', order: 1 }
112 return $.state.set(menuEntryRef, entry)
113}
114
115function runTicker($: EngineInterface, isOn: boolean) {
116 ticker?.cancel()
117 ticker = undefined
118 if (!isOn) return
119 try {
120 ticker = $.clock.every(TICK_MS, () => {
121 void update($, clockAtom, () => Date.now())
122 void refreshStatus($).catch(() => undefined) // branch lookups are throttled by BRANCH_MS
123 })
124 } catch {}
125}
126
127async function setOnNow($: EngineInterface, isOn: boolean) {
128 // The mode is persisted before the move: a crash in between is repaired at the next session start.
129 const was = (await $.store.get('isOn')) === true
130 await $.store.set('isOn', isOn)
131 try {
132 await moveNow($, isOn)
133 } catch (error) {
134 await $.store.set('isOn', was)
135 throw error
136 }
137 // summary 'on' implies the footer is ours: turning on flips the mode first, turning off writes the entry first
138 if (isOn) await update($, isOnAtom, () => true)
139 await writeEntry($, isOn)
140 if (!isOn) await update($, isOnAtom, () => false)
141 runTicker($, isOn)
142 if (isOn) {
143 await update($, clockAtom, () => Date.now())
144 await refreshStatus($, true)
145 }
146}
147
148const setOn = ($: EngineInterface, isOn: boolean) => serial(() => setOnNow($, isOn))
149
150// The menu mod handles these presses itself (ui.press on this plugin's key); the closure only keeps the Button pressable.
151// The engine's hint on one row: it can carry a second line holding only a separator.
152const hintText = (hint: string) =>
153 hint
154 .split(/\r?\n/)
155 .map(line => line.trim())
156 .filter(line => /[\p{L}\p{N}]/u.test(line))
157 .join(' · ')
158
159const noop = () => undefined
160
161const tell = ($: EngineInterface) => (error: unknown) => $.ui.toast(`Status line: ${String(error)}`)
162
163async function page($: EngineInterface, e: any, hasBack: boolean) {
164 return statusPage($.ui.resolve(e), {
165 isOn: await read($, isOnAtom),
166 hasBack,
167 onSet: v => setOn($, v).catch(tell($)),
168 onBack: noop,
169 })
170}
171
172const openTasks = async ($: EngineInterface) => {
173 try {
174 await $.command.run({ command: 'tasks' })
175 } catch (error) {
176 $.ui.toast(`Could not open /tasks: ${String(error)}`)
177 }
178}
179
180export const register: Register = on => {
181 on('session.start', async ($, e, next) => {
182 resetContextToasts()
183 void checkContext($)
184 // Default off: only a stored "on" turns it on.
185 const isOn = (await $.store.get('isOn')) === true
186 await update($, isOnAtom, () => isOn)
187 await writeEntry($, isOn)
188 await update($, clockAtom, () => Date.now())
189 runTicker($, isOn)
190 // a statusLine back in settings.json (edited while off the mod's watch) must not draw under our row
191 // off with a saved value: a crash left it unrestored (or a newer statusLine supersedes it)
192 if (isOn || (await $.store.get('savedStatusLine')) !== undefined) await moveUserStatusLine($, isOn).catch(tell($))
193 if (isOn) await refreshStatus($, true).catch(() => undefined)
194 await $.command
195 .register({ name: 'statusline', description: 'Turn the status line on or off, or open its settings', argumentHint: '[on|off]' })
196 .catch(() => undefined)
197 return next(e)
198 })
199
200 on('command.run', { command: 'statusline' }, async ($, e) => {
201 const arg = e.args.trim().toLowerCase()
202 if (arg === 'on' || arg === 'off') {
203 try {
204 await setOn($, arg === 'on')
205 return { text: `Status line is ${arg}.` }
206 } catch (error) {
207 return { text: `Status line could not be turned ${arg}: ${String(error)}` }
208 }
209 }
210 await $.ui.open({ id: PANE, title: 'Status line', focus: true, closeOnEscape: true, rows: 14, columns: 56 })
211 return { text: 'Status line settings are open.' }
212 })
213
214 on('ui.render', { component: 'Pane', requestId: PANE }, ($, e) => page($, e, false))
215
216 // Inside the menu's pane (apiVersion 1): drawn only when the menu is on this panel.
217 on('ui.render', { component: 'Pane', requestId: MENU_PANE }, async ($, e, next) => {
218 const { value } = await $.state.get(menuPanel)
219 if (value !== 'statusline') return next(e)
220 return page($, e, true)
221 })
222
223 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
224 if (!(await read($, isOnAtom))) return next(e)
225 const snapshot = await read($, snapshotAtom)
226 const ui = $.ui.resolve(e)
227 const { value: hasMenu } = await $.state.get(menuPresent)
228 const label = '◆ mods'
229 const button = hasMenu === true ? <ui.Button key="mods-open" plain label={label} onPress={noop} /> : undefined
230 // No snapshot yet (or it keeps failing): the engine's hint row, so the footer and the button never vanish.
231 if (snapshot === null) {
232 return (
233 <ui.Box justifyContent="space-between" flexGrow={1} gap={1}>
234 <ui.Text dimColor wrap="truncate">
235 {hintText(e.props.hint)}
236 </ui.Text>
237 {button ?? null}
238 </ui.Box>
239 )
240 }
241 return statusRow(ui, {
242 snapshot,
243 clock: await read($, clockAtom),
244 lastResponseAt: await read($, lastResponseAt),
245 columns: e.viewport?.columns ?? 120,
246 label: button === undefined ? '' : label,
247 hint: e.props.hint,
248 button,
249 onPill: () => openTasks($),
250 })
251 })
252
253 on('turn.complete', async ($, e, next) => {
254 const r = await next(e)
255 void checkContext($)
256 return r
257 })
258
259 // fire-and-forget: reading usage never delays the spawn
260 on('agent.spawn', ($, e, next) => {
261 void checkContext($)
262 return next(e)
263 })
264
265 // The main loop's responses keep the prompt cache warm; subagents' do not.
266 on('turn.step', async function* ($, e, next) {
267 const answered = yield* next(e)
268 if (e.agentId === undefined && answered.usage !== null && (await read($, isOnAtom).catch(() => false))) {
269 const at = Date.now()
270 await update($, lastResponseAt, () => at).catch(() => undefined)
271 await update($, clockAtom, () => at).catch(() => undefined)
272 void refreshStatus($).catch(() => undefined)
273 }
274 return answered
275 })
276}
277hooks/settings.ts 36 lines1// ~/.claude/settings.json edits on a parsed object: other keys are kept; an invalid file throws before anything is written.
2export type Settings = Record<string, any>
3
4export function parseSettings(text: string): Settings {
5 const s = JSON.parse(text)
6 if (!s || typeof s !== 'object' || Array.isArray(s)) throw new Error('settings.json is not a JSON object')
7 return s
8}
9
10const ABSOLUTE = /^(\/|[A-Za-z]:[\\/]|\\\\)/
11
12// CLAUDE_CONFIG_DIR when it is an absolute path, else <home>/.claude; never a relative path.
13export function configDir(configDirEnv: string | undefined, home: string | undefined): string {
14 const dir = configDirEnv && ABSOLUTE.test(configDirEnv) ? configDirEnv : home ? `${home}/.claude` : ''
15 if (!ABSOLUTE.test(dir)) throw new Error('no absolute CLAUDE_CONFIG_DIR, HOME or USERPROFILE: settings.json not touched')
16 return dir.replaceAll('\\', '/').replace(/\/+$/, '')
17}
18
19const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
20
21// The user's latest explicit statusLine wins; a saved value it supersedes is returned as `prev` so the caller keeps it.
22// On: a live statusLine leaves the file and is saved. Off: a live one stays and the stale saved one is dropped;
23// else the saved one comes back. `drop` = delete the saved value afterwards. undefined when nothing changes.
24export function moveStatusLine(s: Settings, isOn: boolean, saved: { statusLine: unknown } | undefined) {
25 const live = s.statusLine
26 const prev = saved !== undefined && live !== undefined && !same(saved.statusLine, live) ? saved.statusLine : undefined
27 if (isOn) {
28 if (live === undefined) return undefined
29 const { statusLine, ...next } = s
30 return { next, save: { statusLine }, prev, drop: false }
31 }
32 if (saved === undefined) return undefined
33 if (live !== undefined) return { next: s, save: undefined, prev, drop: true }
34 return { next: { ...s, statusLine: saved.statusLine }, save: undefined, prev: undefined, drop: true }
35}
36hooks/context-toasts.ts 31 lines1import type { EngineInterface } from 'claude-code'
2
3const THRESHOLDS = [60, 80] as const
4const MESSAGES: Record<number, (p: number) => string> = {
5 60: p => `Context at ${p}% - consider /compact at the next pause`,
6 80: p => `Context at ${p}% - time to /compact`,
7}
8
9// Thresholds already warned this session (module-level; session.start clears it).
10// One session per plugin instance; a plugin reload re-warns once.
11const warned = new Set<number>()
12
13export const resetContextToasts = () => warned.clear()
14
15// Fail-open: any error is swallowed. Independent of the status line on/off.
16export async function checkContext($: EngineInterface): Promise<void> {
17 try {
18 const percent = (await $.session.usage()).context.percent
19 if (typeof percent !== 'number') return
20 // a reading crossing several thresholds at once shows only the highest; lower ones count as warned
21 const crossed = THRESHOLDS.filter(t => percent >= t)
22 const top = crossed[crossed.length - 1]
23 if (top === undefined) return
24 const isNew = !warned.has(top)
25 for (const t of crossed) warned.add(t)
26 if (isNew) $.ui.toast(MESSAGES[top]!(Math.round(percent)))
27 } catch {
28 // ignore
29 }
30}
31hooks/status.ts 70 lines1// The status line's arithmetic. Pure; drawing is in status-line.tsx, data gathering in register.tsx.
2export const STATUS_GREEN = '#22c55e'
3export const STATUS_YELLOW = '#eab308'
4export const STATUS_RED = '#ef4444'
5export const STATUS_CYAN = '#22d3ee'
6export const BAR_WIDTH = 10
7
8// Claude Code's main-thread prompt cache lives for an hour on a subscription.
9export const CACHE_TTL_MS = 60 * 60 * 1000
10
11export const colorFor = (percent: number): string =>
12 percent >= 90 ? STATUS_RED : percent >= 70 ? STATUS_YELLOW : STATUS_GREEN
13
14export const fmtTokens = (n: number): string => {
15 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
16 if (n >= 1000) return `${Math.round(n / 1000)}k`
17 return String(n)
18}
19
20// Short countdown: 45s, 12m, 4h38m, 5d18h.
21export const fmtDuration = (ms: number): string => {
22 const secs = Math.max(0, Math.floor(ms / 1000))
23 if (secs < 60) return `${secs}s`
24 const m = Math.floor(secs / 60)
25 if (m < 60) return `${m}m`
26 const h = Math.floor(m / 60)
27 if (h < 24) return `${h}h${String(m % 60).padStart(2, '0')}m`
28 return `${Math.floor(h / 24)}d${String(h % 24).padStart(2, '0')}h`
29}
30
31export const barFill = (percent: number): number =>
32 Math.floor((Math.min(Math.max(percent, 0), 100) * BAR_WIDTH) / 100)
33
34// "ok" while the last response is younger than the cache, "over" after; null before the first response.
35export const cacheState = (lastResponseAt: number | null, now: number): 'ok' | 'over' | null =>
36 lastResponseAt === null ? null : now - lastResponseAt < CACHE_TTL_MS ? 'ok' : 'over'
37
38export const LIMIT_LABELS: Record<string, string> = { five_hour: '5h', seven_day: '7d' }
39
40// The engine's background-work pill as its hint reads it (`1 shell`, `2 shells, 1 monitor`, `3 background tasks`);
41// null while none runs. `ran 2 shell commands` is a finished tool group, not a running shell.
42const BACKGROUND_PILL = /\b\d+ (?:shells?(?:, \d+ monitors?)?|(?:Artifact comment )?monitors?|background tasks?)\b(?! commands?\b)/
43
44export const backgroundPill = (hint: string): string | null => hint.match(BACKGROUND_PILL)?.[0] ?? null
45
46// Detail levels, most detailed first: bars and reset times, then no bars, then no reset times.
47export type Detail = 0 | 1 | 2
48
49export const statsWidth = (
50 ctxText: string,
51 limits: readonly { label: string; percentText: string; resetText: string }[],
52 cacheText: string,
53 detail: Detail,
54): number => {
55 const bar = detail === 0 ? BAR_WIDTH + 1 : 0
56 const sep = 5
57 let width = 4 + bar + ctxText.length
58 for (const l of limits) width += sep + l.label.length + 1 + bar + l.percentText.length + (detail < 2 ? l.resetText.length : 0)
59 if (cacheText !== '') width += sep + cacheText.length
60 return width
61}
62
63// `claude-opus-5-5` as `Opus 5.5`; a name that is not a Claude model id stays as it is.
64export function prettyModel(id: string): string {
65 const match = id.match(/^claude-([a-z]+)-(\d+)(?:-(\d+))?/i)
66 if (match === null) return id
67 const [, family = '', major = '', minor] = match
68 return `${family.charAt(0).toUpperCase()}${family.slice(1)} ${major}${minor === undefined ? '' : `.${minor}`}`
69}
70hooks/status-line.tsx 140 lines1import type { StatusSnapshot } from '../types'
2import {
3 backgroundPill, BAR_WIDTH, barFill, cacheState, colorFor, type Detail, fmtDuration, fmtTokens, prettyModel, STATUS_CYAN,
4 STATUS_GREEN, STATUS_RED, statsWidth,
5} from './status'
6
7// Room the engine's own mode label takes left of the hint row (⏵⏵ auto mode on · ).
8const MODE_LABEL_COLUMNS = 22
9
10type Ui = { Box: any; Text: any; Button: any }
11
12// The status line: model, folder and branch, context, 5h/7d limits and cache, then the optional button; under it,
13// while Claude has a background shell or monitor running, the engine's pill for them.
14export function statusRow(
15 { Box, Text, Button }: Ui,
16 p: {
17 snapshot: StatusSnapshot
18 clock: number
19 lastResponseAt: number | null
20 columns: number
21 label: string // text of the button, '' when there is none
22 hint: string
23 button: unknown // undefined when there is none
24 onPill: () => unknown
25 },
26) {
27 const { snapshot, clock } = p
28 const cache = cacheState(p.lastResponseAt, clock)
29 const sep = <Text dimColor>{' │ '}</Text>
30 const bar = (percent: number, color: string) => (
31 <Text>
32 <Text color={color}>{'█'.repeat(barFill(percent))}</Text>
33 <Text dimColor>{'░'.repeat(BAR_WIDTH - barFill(percent))}</Text>{' '}
34 </Text>
35 )
36
37 const ctxPercent = Math.round(snapshot.ctxPercent ?? 0)
38 const ctxTokens =
39 snapshot.ctxTokens === null ? '' : ` ${fmtTokens(snapshot.ctxTokens)}${snapshot.ctxWindow ? `/${fmtTokens(snapshot.ctxWindow)}` : ''}`
40 const limits = snapshot.limits.map(l => ({
41 ...l,
42 percentText: `${l.percent}%`,
43 resetText: l.resetsAt === null ? '' : ` (resets ${fmtDuration(l.resetsAt - clock)})`,
44 }))
45 const cacheText = cache === null ? '' : `● cache ${cache}`
46 const model = prettyModel(snapshot.model)
47 const head = `${model} · ${snapshot.dir}${snapshot.branch === '' ? '' : ` · ${snapshot.branch}`}`
48 // One row beside the engine's mode label: the most detailed level that leaves room for the button; past the
49 // least detailed one, the model and folder go too.
50 const room = p.columns - MODE_LABEL_COLUMNS - p.label.length - 4
51 const fits = (d: Detail, withHead: boolean) =>
52 statsWidth(`${ctxPercent}%${ctxTokens}`, limits, cacheText, d) + (withHead ? head.length + 5 : 0) <= room
53 const showHead = fits(2, true)
54 const detail: Detail = ([0, 1, 2] as const).find(d => fits(d, showHead)) ?? 2
55
56 const stats = [
57 showHead && (
58 <Text key="head">
59 <Text color={STATUS_CYAN}>{model}</Text>
60 <Text dimColor> · </Text>
61 <Text>{snapshot.dir}</Text>
62 {snapshot.branch !== '' && <Text dimColor> · </Text>}
63 {snapshot.branch !== '' && <Text color={STATUS_GREEN}>{snapshot.branch}</Text>}
64 {sep}
65 </Text>
66 ),
67 <Text key="ctx">
68 <Text>ctx </Text>
69 {detail === 0 && bar(ctxPercent, colorFor(ctxPercent))}
70 <Text bold color={colorFor(ctxPercent)}>{`${ctxPercent}%`}</Text>
71 <Text dimColor>{ctxTokens}</Text>
72 </Text>,
73 ...limits.map(l => (
74 <Text key={`limit-${l.label}`}>
75 {sep}
76 <Text>{l.label} </Text>
77 {detail === 0 && bar(l.percent, colorFor(l.percent))}
78 <Text bold color={colorFor(l.percent)}>{l.percentText}</Text>
79 {detail < 2 && <Text dimColor>{l.resetText}</Text>}
80 </Text>
81 )),
82 cache !== null && (
83 <Text key="cache">
84 {sep}
85 <Text color={cache === 'ok' ? STATUS_GREEN : STATUS_RED}>●</Text>
86 <Text dimColor> cache {cache}</Text>
87 </Text>
88 ),
89 ]
90
91 const statsRow = (
92 <Box justifyContent="space-between" flexGrow={1} gap={1}>
93 <Text wrap="truncate">{stats}</Text>
94 {p.button ?? null}
95 </Box>
96 )
97 const pill = backgroundPill(p.hint)
98 if (pill === null) return statsRow
99 return (
100 <Box flexDirection="column" flexGrow={1}>
101 {statsRow}
102 <Box gap={1}>
103 <Button key="background-tasks" plain label={`⎿ ${pill}`} onPress={p.onPill} />
104 <Text dimColor wrap="truncate">
105 · /tasks to manage
106 </Text>
107 </Box>
108 </Box>
109 )
110}
111
112// The settings page: On/Off. In the menu's pane it carries a back button (key mods-back), whose press the menu handles.
113export function statusPage({ Box, Text, Button }: Ui, p: { isOn: boolean; hasBack: boolean; onSet: (v: boolean) => unknown; onBack: () => unknown }) {
114 const option = (key: string, label: string, isOn: boolean, onPress: () => unknown) => (
115 <Box key={`row-${key}`} gap={1}>
116 <Button key={key} plain label={`${isOn ? '●' : '○'} ${label}`} dimColor={!isOn} onPress={onPress} />
117 </Box>
118 )
119 return (
120 <Box flexDirection="column" paddingX={1}>
121 <Box justifyContent="space-between">
122 <Text bold color="claude">
123 ◆ Status line
124 </Text>
125 {p.hasBack && <Button key="mods-back" plain dimColor hotkey="b" label="← back" onPress={p.onBack} />}
126 </Box>
127 <Text dimColor>Model, folder and branch, then context, 5h and 7d limits and cache.</Text>
128 <Box marginTop={1}>
129 <Text bold>Status line</Text>
130 </Box>
131 {option('status-on', 'On', p.isOn, () => p.onSet(true))}
132 {option('status-off', 'Off', !p.isOn, () => p.onSet(false))}
133 <Text dimColor>On takes your own statusLine out of settings.json; Off puts it back.</Text>
134 <Box marginTop={1}>
135 <Text dimColor>↑↓ move · enter select · esc close</Text>
136 </Box>
137 </Box>
138 )
139}
140types/index.d.ts 27 lines1export type StatusRateLimit = { label: string; percent: number; resetsAt: number | null }
2
3export type StatusSnapshot = {
4 model: string
5 dir: string
6 branch: string
7 ctxPercent: number | null
8 ctxTokens: number | null
9 ctxWindow: number | null
10 limits: StatusRateLimit[]
11}
12
13// Menu contract, apiVersion 1: an optional `menu` mod lists this entry.
14export type MenuEntry = { apiVersion: 1; title: string; summary: string; order: number }
15
16declare module 'claude-code' {
17 interface PluginState {
18 statusline: {
19 menuEntry: MenuEntry
20 isOn: boolean
21 snapshot: StatusSnapshot | null
22 lastResponseAt: number | null
23 clock: number
24 }
25 }
26}
27