A configurable status line under the prompt: model, effort, context, rate limits, directory, git, cost and badges from other mods. Settings: /statusline-pro…

A configurable status line for Claude Code.<br> Model, effort, context, rate limits, directory, git and cost on one line under the prompt, plus badges from your other mods.
<img src="assets/statusline.png" alt="The status line under the Claude Code prompt: model, effort, context, 5h limit, directory, git branch and cost" width="100%">
Needs a Claude Code build that supports mods (function hooks).
/plugin marketplace add alexcloudstar/statusline-pro
/plugin install statusline-pro@statusline-pro
claude plugin marketplace add alexcloudstar/statusline-pro
claude plugin install statusline-pro@statusline-pro
The line replaces the hint row under the prompt ("? for shortcuts"). It does not touch a statusLine configured in settings.json; delete that entry if you want only this one.
| Id | Shows | auto means |
|---|---|---|
model | the active model | always |
effort | reasoning effort | when the model reports one |
context | context window use | always |
five_hour | 5-hour rate limit and reset | always |
week | 7-day rate limit | once usage reaches week_min_pct (60) and the limit hasn't reset |
dir | working directory, shortened | always |
git | branch, * dirty, ↑n unpushed | inside a git repo |
cost | session cost | once it reaches cost_min_usd (1) |
Gauges turn to the theme's warning color from 60% and its error color from 85% (context: 50% and 80%).
/statusline-pro settings opens a pane with a live preview. Click a segment's state to cycle auto, always, never; use the arrows to reorder. The same fields are in the /config menu and on the install screen.
| Field | Default | |
|---|---|---|
show_<id> | auto | auto, always or never for each segment above |
week_min_pct | 60 | usage at which auto shows the 7-day limit |
cost_min_usd | 1 | cost at which auto shows the cost |
separator | · (a dot with two spaces each side) | text between segments |
order | empty | comma list of ids, first to last; the rest follow in default order |
hidden_segments | empty | comma list of drop-in ids to hide |
/statusline-pro with no argument hides or shows the whole line.
Any mod or script can add a segment by writing a JSON file to ~/.claude/status-segments/<name>.json:
{ "label": "deploy", "text": "v42 live", "color": "warning" }
text is required (40 characters). label is optional (16). color is a theme color name such as claude, warning or error.ext:<name>. Use it in order and hidden_segments, or toggle it in the pane.What it sends and where. Nothing. It makes no network calls and sends nothing to the model, so it adds nothing to the model's context or your token use. Everything it reads stays on your machine and is drawn on screen.
Read files never reach a command. The mod reads local files (the drop-in .json files) and runs a program (git), but nothing flows from one to the other. The contents of a drop-in file are cleaned, cut to a length limit, and drawn on screen; they are never passed to git, to any other command, to the network or to the model. The only values handed to git are the three fixed argument lists listed below and the session's working directory, which comes from the session itself, not from a file.
What it reads.
.json files in ~/.claude/status-segments/ (or $CLAUDE_CONFIG_DIR/status-segments/)./config rows listed under Settings.HOME and CLAUDE_CONFIG_DIR. It sets none.Programs it runs. Only git, read-only, in the session's working directory, every 10 seconds and after each turn, to draw the git segment:
git rev-parse --abbrev-ref HEAD for the branch name,git status --porcelain to tell whether the tree is dirty,git rev-list --count @{upstream}..HEAD for the number of unpushed commits.What it changes. Nothing on disk. It writes no files. The only thing it sets is its own settings fields (statusline-pro.*), and only when you click in the settings pane (/statusline-pro settings): the same rows you can edit in /config.
What each hook does.
session.start: registers the /statusline-pro command, loads the first figures, and starts the 10-second refresh of the directory, git state and drop-in segments.turn.step, session.measure, session.compact, turn.complete: keep the model, effort, context, limits and cost figures current.command.run: /statusline-pro hides or shows the line; /statusline-pro settings opens the settings pane.ui.render for the prompt hint row: draws the status line in place of the hint row.ui.render for the settings pane: draws the pane and its buttons.Every engine call it makes, and where it goes. None of these sends anything off your machine: each one reads or draws locally, or runs git.
| Call | What it does here |
|---|---|
$.session.model, usage, cwd, surfaces | reads the session's model, usage figures, working directory and screens |
$.env.get | reads HOME and CLAUDE_CONFIG_DIR |
$.fs.exists, list, read | reads the .json files in ~/.claude/status-segments/ |
$.process.run | runs the three read-only git commands above, with the argument list written out in the call |
$.config.list | reads this mod's own settings rows |
$.config.set | writes this mod's own settings rows (statusline-pro.*) when you click in the settings pane; each call names one fixed settings key |
$.state.get, $.state.set | keeps the figures in the session's memory; nothing is saved to disk |
$.clock.now, $.clock.every | the current time, and the 10-second refresh |
$.command.register | adds the /statusline-pro command |
$.ui.resolve, $.ui.open | draws the line and opens the settings pane |
$.ui.log | writes a failure message to the debug log only (claude --debug), never to the screen or the model |
claude plugin validate .
claude plugin test .
Type-check with tsc against the types Claude Code writes into .claude-plugin/types/ once the mod has loaded (claude --plugin-dir .).
hooks/register.tsx 284 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionUsage } from 'claude-code'
3
4import type { Extra, Figures, GitSummary } from '../types'
5
6import { EXTRAS_DIR_NAME, JSON_SUFFIX, parseExtra, pickExtraFiles } from './extras'
7import { summaryLine } from './segments'
8import { MODES, parseSettings } from './settings'
9import type { BuiltinId, Mode, Settings } from './settings'
10import { SETTINGS_PANE_TITLE, settingsView } from './settings-view'
11
12const EMPTY_FIGURES: Figures = { dir: null, git: null, model: null, effort: null }
13const figures = atom({ plugin: 'statusline-pro', key: 'figures' } as const, EMPTY_FIGURES)
14const isHidden = atom({ plugin: 'statusline-pro', key: 'isHidden' } as const, false)
15const revision = atom({ plugin: 'statusline-pro', key: 'revision' } as const, 0)
16
17const COMMAND_NAME = 'statusline-pro'
18const SETTINGS_ARG = 'settings'
19const SETTINGS_PANE_ID = 'settings'
20const CONFIG_PREFIX = 'statusline-pro.'
21const REFRESH_INTERVAL_MS = 10_000
22const GIT_TIMEOUT_MS = 4_000
23const HIDDEN_TEXT = 'Status line hidden.'
24const SHOWN_TEXT = 'Status line shown.'
25const SETTINGS_OPENED_TEXT = 'Status line settings opened.'
26
27const findLimit = (usage: Pick<SessionUsage, 'rateLimits'>, kind: string) => {
28 const limit = usage.rateLimits.find(candidate => candidate.kind === kind)
29 return limit ? { pct: limit.percentUsed, resetsAt: limit.resetsAt } : undefined
30}
31
32const fromUsage = (usage: Pick<SessionUsage, 'context' | 'rateLimits' | 'cost'>): Partial<Figures> => ({
33 ctxPct: usage.context.percent,
34 ctxTokens: usage.context.tokens,
35 ctxWindow: usage.context.window,
36 fiveHour: findLimit(usage, 'five_hour'),
37 week: findLimit(usage, 'seven_day'),
38 costUsd: usage.cost?.usd,
39})
40
41const homeRelative = (cwd: string, home: string | undefined) =>
42 home && (cwd === home || cwd.startsWith(`${home}/`)) ? `~${cwd.slice(home.length)}` : cwd
43
44const nextMode = (mode: Mode) => MODES[(MODES.indexOf(mode) + 1) % MODES.length] ?? 'auto'
45
46function logFailure($: EngineInterface, task: string, error: unknown) {
47 const reason = error instanceof Error ? error.message : String(error)
48 $.ui.log(`statusline-pro: ${task} failed: ${reason}`, { to: 'debug' })
49}
50
51async function isDrawnHere($: EngineInterface) {
52 const surfaces = await $.session.surfaces()
53 return surfaces.some(surface => surface !== 'desktop')
54}
55
56async function readGit($: EngineInterface, cwd: string): Promise<GitSummary | null> {
57 const branch = await $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { cwd, timeoutMs: GIT_TIMEOUT_MS })
58 if (branch.exitCode !== 0) return null
59
60 const porcelain = await $.process.run(['git', 'status', '--porcelain'], { cwd, timeoutMs: GIT_TIMEOUT_MS })
61 const ahead = await $.process.run(['git', 'rev-list', '--count', '@{upstream}..HEAD'], {
62 cwd,
63 timeoutMs: GIT_TIMEOUT_MS,
64 })
65 const hasUpstream = ahead.exitCode === 0
66
67 return {
68 branch: branch.stdout.trim(),
69 isDirty: porcelain.stdout.trim().length > 0,
70 unpushedCount: hasUpstream ? Number(ahead.stdout.trim()) : 0,
71 hasUpstream,
72 }
73}
74
75async function readConfigDir($: EngineInterface) {
76 return (await $.env.get('CLAUDE_CONFIG_DIR')) || `${await $.env.get('HOME')}/.claude`
77}
78
79async function readExtras($: EngineInterface, configDir: string): Promise<Extra[]> {
80 const dir = `${configDir}/${EXTRAS_DIR_NAME}`
81 if (!(await $.fs.exists(dir))) return []
82
83 const extras: Extra[] = []
84 for (const file of pickExtraFiles(await $.fs.list(dir))) {
85 try {
86 const raw = await $.fs.read(`${dir}/${file.name}`)
87 const extra = parseExtra(file.name.slice(0, -JSON_SUFFIX.length), raw)
88 if (extra) extras.push(extra)
89 } catch (error) {
90 logFailure($, `reading ${file.name}`, error)
91 }
92 }
93 return extras
94}
95
96async function refreshExtras($: EngineInterface) {
97 try {
98 const extras = await readExtras($, await readConfigDir($))
99 await update($, figures, current => ({ ...current, extras }))
100 } catch (error) {
101 logFailure($, 'reading drop-in segments', error)
102 }
103}
104
105async function refreshPlace($: EngineInterface) {
106 try {
107 if (!(await isDrawnHere($))) return
108 const cwd = await $.session.cwd()
109 const dir = homeRelative(cwd, await $.env.get('HOME'))
110 await update($, figures, current => ({ ...current, dir }))
111 const git = await readGit($, cwd)
112 await update($, figures, current => ({ ...current, git }))
113 } catch (error) {
114 logFailure($, 'reading the working directory and git state', error)
115 }
116}
117
118async function readSettings($: EngineInterface): Promise<Settings> {
119 const rows = await $.config.list()
120 const options = Object.fromEntries(
121 rows
122 .filter(row => row.key.startsWith(CONFIG_PREFIX))
123 .map(row => [row.key.slice(CONFIG_PREFIX.length), row.value]),
124 )
125 return parseSettings(options)
126}
127
128async function setShowMode($: EngineInterface, id: BuiltinId, value: Mode) {
129 switch (id) {
130 case 'model':
131 await $.config.set({ key: 'statusline-pro.show_model', value: value })
132 break
133 case 'effort':
134 await $.config.set({ key: 'statusline-pro.show_effort', value: value })
135 break
136 case 'context':
137 await $.config.set({ key: 'statusline-pro.show_context', value: value })
138 break
139 case 'five_hour':
140 await $.config.set({ key: 'statusline-pro.show_five_hour', value: value })
141 break
142 case 'week':
143 await $.config.set({ key: 'statusline-pro.show_week', value: value })
144 break
145 case 'dir':
146 await $.config.set({ key: 'statusline-pro.show_dir', value: value })
147 break
148 case 'git':
149 await $.config.set({ key: 'statusline-pro.show_git', value: value })
150 break
151 case 'cost':
152 await $.config.set({ key: 'statusline-pro.show_cost', value: value })
153 break
154 }
155}
156
157async function cycleMode($: EngineInterface, settings: Settings, id: BuiltinId) {
158 try {
159 await setShowMode($, id, nextMode(settings.modes[id]))
160 await update($, revision, count => count + 1)
161 } catch (error) {
162 logFailure($, `changing ${id}`, error)
163 }
164}
165
166async function toggleHidden($: EngineInterface, settings: Settings, id: string) {
167 const hidden = settings.hidden.includes(id) ? settings.hidden.filter(item => item !== id) : [...settings.hidden, id]
168 try {
169 await $.config.set({ key: 'statusline-pro.hidden_segments', value: hidden.join(',') })
170 await update($, revision, count => count + 1)
171 } catch (error) {
172 logFailure($, `toggling ${id}`, error)
173 }
174}
175
176async function moveSegment($: EngineInterface, ids: readonly string[], index: number, step: -1 | 1) {
177 const target = index + step
178 const moved = ids[index]
179 const swapped = ids[target]
180 if (moved === undefined || swapped === undefined) return
181
182 const order = [...ids]
183 order[index] = swapped
184 order[target] = moved
185 try {
186 await $.config.set({ key: 'statusline-pro.order', value: order.join(',') })
187 await update($, revision, count => count + 1)
188 } catch (error) {
189 logFailure($, `moving ${moved}`, error)
190 }
191}
192
193export const register: Register = (on, options) => {
194 const settings = parseSettings(options)
195
196 on('session.start', async ($, e, next) => {
197 await $.command.register({ name: COMMAND_NAME, description: 'Hide or show the status line; "settings" opens its settings' })
198 const model = await $.session.model()
199 const usage = fromUsage(await $.session.usage())
200 await update($, figures, current => ({ ...current, ...usage, model: model || null }))
201 $.clock.every(REFRESH_INTERVAL_MS, () => {
202 void refreshPlace($)
203 void refreshExtras($)
204 })
205 void refreshPlace($)
206 void refreshExtras($)
207 return next(e)
208 })
209
210 on('command.run', { command: COMMAND_NAME }, async ($, e) => {
211 if (e.args.trim() === SETTINGS_ARG) {
212 await $.ui.open({ id: SETTINGS_PANE_ID, title: SETTINGS_PANE_TITLE })
213 return { text: SETTINGS_OPENED_TEXT }
214 }
215 const hidden = await update($, isHidden, current => !current)
216 return { text: hidden ? HIDDEN_TEXT : SHOWN_TEXT }
217 }).catch(($, e, next) => next(e))
218
219 on('turn.complete', async ($, e, next) => {
220 const result = await next(e)
221 if (!e.agentId) {
222 void refreshPlace($)
223 void refreshExtras($)
224 }
225 return result
226 })
227
228 on('turn.step', async function* ($, e, next) {
229 if (!e.agentId) {
230 const effort = e.effort === undefined ? null : String(e.effort)
231 await update($, figures, current => ({ ...current, model: e.model, effort }))
232 }
233 return yield* next(e)
234 })
235
236 on('session.measure', async ($, e, next) => {
237 const usage = fromUsage(e)
238 await update($, figures, current => ({ ...current, ...usage }))
239 return next(e)
240 })
241
242 on('session.compact', async ($, e, next) => {
243 const result = await next(e)
244 const hasCompacted = !e.agentId && e.trigger !== 'precompute' && !result.skip
245 if (hasCompacted) {
246 await update($, figures, current => ({ ...current, ctxPct: undefined, ctxTokens: undefined }))
247 }
248 return result
249 }).catch(($, e, next) => next(e))
250
251 on('ui.render', { component: 'Pane', requestId: SETTINGS_PANE_ID }, async ($, e, next) => {
252 if (e.surface !== 'terminal') return next(e)
253
254 await read($, revision)
255 const paneSettings = await readSettings($)
256 return settingsView({
257 elements: $.ui.resolve(e),
258 settings: paneSettings,
259 figures: await read($, figures),
260 now: await $.clock.now(),
261 onCycleMode: id => void cycleMode($, paneSettings, id),
262 onToggleHidden: id => void toggleHidden($, paneSettings, id),
263 onMove: (ids, index, step) => void moveSegment($, ids, index, step),
264 })
265 })
266
267 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
268 if (e.surface === 'desktop' || (await read($, isHidden))) return next(e)
269
270 const { Text } = $.ui.resolve(e)
271 const parts = summaryLine(await read($, figures), await $.clock.now(), settings)
272
273 return (
274 <Text wrap="truncate-end">
275 {parts.map(part => (
276 <Text color={part.color} dimColor={part.dim} bold={part.bold}>
277 {part.text}
278 </Text>
279 ))}
280 </Text>
281 )
282 })
283}
284hooks/extras.ts 39 lines1import type { Extra } from '../types'
2
3export const EXTRAS_DIR_NAME = 'status-segments'
4export const EXTRA_ID_PREFIX = 'ext:'
5export const JSON_SUFFIX = '.json'
6
7const MAX_FILES = 12
8const MAX_FILE_BYTES = 2_048
9const MAX_STEM_LENGTH = 40
10const MAX_LABEL_LENGTH = 16
11const MAX_TEXT_LENGTH = 40
12const COLOR_PATTERN = /^[a-z]{1,20}$/
13const STEM_PATTERN = /^[A-Za-z0-9_-]+$/
14const CONTROL_PATTERN = /[\u0000-\u001f\u007f-\u009f]/g
15
16type ListedFile = { name: string; kind: string; size: number }
17
18const clean = (value: unknown, max: number) =>
19 typeof value === 'string' ? value.replace(CONTROL_PATTERN, '').trim().slice(0, max) : ''
20
21export const pickExtraFiles = <T extends ListedFile>(entries: readonly T[]) =>
22 entries
23 .filter(entry => entry.kind === 'file' && entry.name.endsWith(JSON_SUFFIX) && entry.size <= MAX_FILE_BYTES)
24 .sort((a, b) => a.name.localeCompare(b.name))
25 .slice(0, MAX_FILES)
26
27export const parseExtra = (stem: string, raw: string): Extra | undefined => {
28 if (!STEM_PATTERN.test(stem) || stem.length > MAX_STEM_LENGTH) return undefined
29 const data: unknown = JSON.parse(raw)
30 if (typeof data !== 'object' || data === null) return undefined
31
32 const fields = data as Record<string, unknown>
33 const text = clean(fields.text, MAX_TEXT_LENGTH)
34 if (!text) return undefined
35
36 const color = typeof fields.color === 'string' && COLOR_PATTERN.test(fields.color) ? fields.color : undefined
37 return { id: `${EXTRA_ID_PREFIX}${stem}`, label: clean(fields.label, MAX_LABEL_LENGTH), text, color }
38}
39hooks/segments.ts 89 lines1import type { Extra, Figures } from '../types'
2
3import { contextColor, formatGit, gaugeParts, limitLine, modelPart, PENDING, PERCENT_GAP, shortDir } from './format'
4import type { Line } from './format'
5import { BUILTIN_IDS, DEFAULT_SETTINGS } from './settings'
6import type { BuiltinId, Mode, Settings } from './settings'
7
8const CONTEXT_GAUGE_WIDTH = 5
9
10type Context = { figures: Figures; now: number; settings: Settings; mode: Mode }
11
12type Builtin = {
13 label: string
14 render: (context: Context) => Line | undefined
15}
16
17const contextSegment = ({ figures }: Context): Line => {
18 if (figures.ctxPct === undefined) return [{ text: 'ctx ', dim: true }, { text: PENDING, dim: true }]
19 return [
20 { text: 'ctx ', dim: true },
21 ...gaugeParts(figures.ctxPct, CONTEXT_GAUGE_WIDTH, contextColor(figures.ctxPct)),
22 { text: `${PERCENT_GAP}${Math.round(figures.ctxPct)}%`, bold: true },
23 ]
24}
25
26const weekSegment = ({ figures, now, settings, mode }: Context) => {
27 const week = figures.week
28 const isFresh = week !== undefined && !(week.resetsAt && Date.parse(week.resetsAt) <= now)
29 const isWorthShowing = isFresh && week.pct >= settings.weekMinPct
30 return mode === 'always' || isWorthShowing ? limitLine('7d', week, now) : undefined
31}
32
33const costSegment = ({ figures, settings, mode }: Context) => {
34 const cost = figures.costUsd
35 if (cost === undefined) return undefined
36 return mode === 'always' || cost >= settings.costMinUsd ? [{ text: `$${cost.toFixed(2)}` }] : undefined
37}
38
39export const BUILTINS: Record<BuiltinId, Builtin> = {
40 model: { label: 'Model', render: ({ figures }) => [modelPart(figures)] },
41 effort: {
42 label: 'Effort',
43 render: ({ figures }) => (figures.effort ? [{ text: figures.effort, color: 'claude' }] : undefined),
44 },
45 context: { label: 'Context', render: contextSegment },
46 five_hour: { label: '5h limit', render: ({ figures, now }) => limitLine('5h', figures.fiveHour, now) },
47 week: { label: '7d limit', render: weekSegment },
48 dir: { label: 'Directory', render: ({ figures }) => (figures.dir ? [{ text: shortDir(figures.dir) }] : undefined) },
49 git: { label: 'Git', render: ({ figures }) => (figures.git ? [{ text: formatGit(figures.git) }] : undefined) },
50 cost: { label: 'Cost', render: costSegment },
51}
52
53const isBuiltin = (id: string): id is BuiltinId => (BUILTIN_IDS as readonly string[]).includes(id)
54
55export const orderedIds = (order: readonly string[], extras: readonly Extra[]) => {
56 const known = new Set<string>([...BUILTIN_IDS, ...extras.map(extra => extra.id)])
57 const ids = [...new Set(order.filter(id => known.has(id)))]
58 const rest = [...BUILTIN_IDS, ...extras.map(extra => extra.id)].filter(id => !ids.includes(id))
59 return [...ids, ...rest]
60}
61
62const extraSegment = (extra: Extra): Line => [
63 ...(extra.label ? [{ text: `${extra.label} `, dim: true }] : []),
64 { text: extra.text, color: extra.color },
65]
66
67export const summaryLine = (
68 figures: Figures,
69 now: number,
70 settings: Settings = DEFAULT_SETTINGS,
71 extras: readonly Extra[] = figures.extras ?? [],
72): Line => {
73 const extrasById = new Map(extras.map(extra => [extra.id, extra]))
74
75 const segments = orderedIds(settings.order, extras).flatMap((id): Line[] => {
76 if (isBuiltin(id)) {
77 const mode = settings.modes[id]
78 const segment = mode === 'never' ? undefined : BUILTINS[id].render({ figures, now, settings, mode })
79 return segment ? [segment] : []
80 }
81 const extra = extrasById.get(id)
82 return extra && !settings.hidden.includes(id) ? [extraSegment(extra)] : []
83 })
84
85 return segments.flatMap((segment, index) =>
86 index ? [{ text: settings.separator, dim: true }, ...segment] : segment,
87 )
88}
89hooks/settings.ts 65 lines1export const BUILTIN_IDS = [
2 'model',
3 'effort',
4 'context',
5 'five_hour',
6 'week',
7 'dir',
8 'git',
9 'cost',
10] as const
11
12export type BuiltinId = (typeof BUILTIN_IDS)[number]
13export type Mode = 'auto' | 'always' | 'never'
14
15export type Settings = {
16 modes: Record<BuiltinId, Mode>
17 weekMinPct: number
18 costMinUsd: number
19 separator: string
20 order: string[]
21 hidden: string[]
22}
23
24export const MODES: readonly Mode[] = ['auto', 'always', 'never']
25
26const DEFAULT_WEEK_MIN_PCT = 60
27const DEFAULT_COST_MIN_USD = 1
28const DEFAULT_SEPARATOR = ' · '
29
30export const DEFAULT_SETTINGS: Settings = {
31 modes: Object.fromEntries(BUILTIN_IDS.map(id => [id, 'auto'])) as Record<BuiltinId, Mode>,
32 weekMinPct: DEFAULT_WEEK_MIN_PCT,
33 costMinUsd: DEFAULT_COST_MIN_USD,
34 separator: DEFAULT_SEPARATOR,
35 order: [],
36 hidden: [],
37}
38
39const asMode = (value: unknown): Mode => MODES.find(mode => mode === value) ?? 'auto'
40
41const asNumber = (value: unknown, fallback: number) =>
42 typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : fallback
43
44export const splitList = (value: unknown) =>
45 typeof value === 'string'
46 ? value
47 .split(',')
48 .map(item => item.trim())
49 .filter(Boolean)
50 : []
51
52export const showKey = (id: BuiltinId) => `show_${id}`
53
54export const parseSettings = (options: Readonly<Record<string, unknown>>): Settings => ({
55 modes: Object.fromEntries(BUILTIN_IDS.map(id => [id, asMode(options[showKey(id)])])) as Record<
56 BuiltinId,
57 Mode
58 >,
59 weekMinPct: asNumber(options.week_min_pct, DEFAULT_WEEK_MIN_PCT),
60 costMinUsd: asNumber(options.cost_min_usd, DEFAULT_COST_MIN_USD),
61 separator: typeof options.separator === 'string' && options.separator ? options.separator : DEFAULT_SEPARATOR,
62 order: splitList(options.order),
63 hidden: splitList(options.hidden_segments),
64})
65hooks/settings-view.tsx 78 lines1import type { Elements } from 'claude-code'
2
3import type { Extra, Figures } from '../types'
4
5import { EXTRAS_DIR_NAME } from './extras'
6import { BUILTINS, orderedIds, summaryLine } from './segments'
7import type { BuiltinId, Settings } from './settings'
8
9export const SETTINGS_PANE_TITLE = 'Status line settings'
10
11const LABEL_WIDTH = 16
12const STATE_WIDTH = 8
13const SHOWN = 'shown'
14const HIDDEN = 'hidden'
15
16type SettingsViewProps = {
17 elements: Elements['terminal']
18 settings: Settings
19 figures: Figures
20 now: number
21 onCycleMode: (id: BuiltinId) => void
22 onToggleHidden: (id: string) => void
23 onMove: (ids: readonly string[], index: number, step: -1 | 1) => void
24}
25
26const labelOf = (id: string, extra: Extra | undefined) =>
27 extra ? extra.label || id : BUILTINS[id as BuiltinId].label
28
29const stateOf = (id: string, extra: Extra | undefined, settings: Settings) => {
30 if (!extra) return settings.modes[id as BuiltinId]
31 return settings.hidden.includes(id) ? HIDDEN : SHOWN
32}
33
34export const settingsView = ({
35 elements,
36 settings,
37 figures,
38 now,
39 onCycleMode,
40 onToggleHidden,
41 onMove,
42}: SettingsViewProps) => {
43 const { Box, Button, Text } = elements
44 const extras = figures.extras ?? []
45 const ids = orderedIds(settings.order, extras)
46 const preview = summaryLine(figures, now, settings, extras)
47
48 return (
49 <Box flexDirection="column" paddingX={1}>
50 <Text bold>{SETTINGS_PANE_TITLE}</Text>
51 <Text wrap="truncate-end">
52 {preview.map(part => (
53 <Text color={part.color} dimColor={part.dim} bold={part.bold}>
54 {part.text}
55 </Text>
56 ))}
57 </Text>
58 <Text> </Text>
59 {ids.map((id, index) => {
60 const extra = extras.find(item => item.id === id)
61 const onToggle = extra ? () => onToggleHidden(id) : () => onCycleMode(id as BuiltinId)
62
63 return (
64 <Box key={`row:${id}`}>
65 <Text>{labelOf(id, extra).padEnd(LABEL_WIDTH)}</Text>
66 <Button key={`state:${id}`} label={stateOf(id, extra, settings).padEnd(STATE_WIDTH)} onPress={onToggle} />
67 <Button key={`up:${id}`} label=" ↑ " plain onPress={() => onMove(ids, index, -1)} />
68 <Button key={`down:${id}`} label=" ↓ " plain onPress={() => onMove(ids, index, 1)} />
69 </Box>
70 )
71 })}
72 <Text> </Text>
73 <Text dimColor>auto: show when it matters. always / never: force it.</Text>
74 <Text dimColor>More segments: drop JSON files in ~/.claude/{EXTRAS_DIR_NAME}/</Text>
75 </Box>
76 )
77}
78hooks/format.ts 104 lines1import type { Figures, GitSummary, Limit } from '../types'
2
3export type Part = { text: string; color?: string; dim?: boolean; bold?: boolean }
4export type Line = Part[]
5
6const LIMIT_GAUGE_WIDTH = 5
7export const PENDING = '-'
8export const PERCENT_GAP = ' '
9const RESET_JOINT = ' · '
10const MAX_DIR_WIDTH = 32
11const MAX_BRANCH_WIDTH = 18
12const CONTEXT_WARN_PCT = 50
13const CONTEXT_ERROR_PCT = 80
14const LIMIT_WARN_PCT = 60
15const LIMIT_ERROR_PCT = 85
16const SECONDS_PER_DAY = 86_400
17const SECONDS_PER_HOUR = 3_600
18const SECONDS_PER_MINUTE = 60
19const DIRTY_MARK = '*'
20const UNPUSHED_MARK = '↑'
21const NO_UPSTREAM_MARK = ' (no upstream)'
22const MODEL_PATTERN = /^claude-([a-z]+)-(\d{1,2})(?:-(\d{1,2}))?(?:-\d{8})?(\[1m\])?$/
23
24const levelColor = (pct: number, warnAt: number, errorAt: number) =>
25 pct >= errorAt ? 'error' : pct >= warnAt ? 'warning' : 'claude'
26
27export const contextColor = (pct: number) => levelColor(pct, CONTEXT_WARN_PCT, CONTEXT_ERROR_PCT)
28export const limitColor = (pct: number) => levelColor(pct, LIMIT_WARN_PCT, LIMIT_ERROR_PCT)
29
30export const fit = (text: string, max: number, keep: 'start' | 'end' = 'start') => {
31 if (text.length <= max) return text
32 return keep === 'end' ? `…${text.slice(text.length - (max - 1))}` : `${text.slice(0, max - 1)}…`
33}
34
35export const shortDir = (dir: string, max = MAX_DIR_WIDTH) => {
36 if (dir.length <= max) return dir
37 const segments = dir.split('/')
38 let tail = segments.pop() ?? ''
39 if (tail.length + 2 > max) return fit(tail, max, 'end')
40 while (segments.length && `…/${segments[segments.length - 1]}/${tail}`.length <= max) {
41 tail = `${segments.pop()}/${tail}`
42 }
43 return `…/${tail}`
44}
45
46export const prettyModel = (id: string) => {
47 const match = MODEL_PATTERN.exec(id)
48 const family = match?.[1]
49 if (!match || !family) return id
50 const minor = match[3] ? `.${match[3]}` : ''
51 const window = match[4] ? ' (1M)' : ''
52 return `${family[0]?.toUpperCase()}${family.slice(1)} ${match[2]}${minor}${window}`
53}
54
55export const gauge = (pct: number, width: number) => {
56 const filled = Math.min(width, Math.max(0, Math.round((pct * width) / 100)))
57 return { filled: '▰'.repeat(filled), empty: '▱'.repeat(width - filled) }
58}
59
60export const formatEta = (resetsAt: string | undefined, now: number) => {
61 if (!resetsAt) return ''
62 const seconds = Math.floor((Date.parse(resetsAt) - now) / 1000)
63 if (!(seconds > 0)) return 'now'
64 const days = Math.floor(seconds / SECONDS_PER_DAY)
65 const hours = Math.floor((seconds % SECONDS_PER_DAY) / SECONDS_PER_HOUR)
66 const minutes = Math.floor((seconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE)
67 return days > 0 ? `${days}d${hours}h` : `${hours}h${minutes}m`
68}
69
70export const formatGit = (git: GitSummary) => {
71 const dirty = git.isDirty ? DIRTY_MARK : ''
72 const unpushed = git.unpushedCount > 0 ? ` ${UNPUSHED_MARK}${git.unpushedCount}` : ''
73 const upstream = git.hasUpstream ? '' : NO_UPSTREAM_MARK
74 return `⎇ ${fit(git.branch, MAX_BRANCH_WIDTH)}${dirty}${unpushed}${upstream}`
75}
76
77export const gaugeParts = (pct: number, width: number, color: string): Part[] => {
78 const { filled, empty } = gauge(pct, width)
79 return [
80 { text: filled, color },
81 { text: empty, color: 'subtle' },
82 ]
83}
84
85export const limitLine = (label: string, limit: Limit | undefined, now: number): Line => {
86 if (!limit) return [{ text: `${label} `, dim: true }, { text: PENDING, dim: true }]
87 const color = limitColor(limit.pct)
88 const eta = formatEta(limit.resetsAt, now)
89 return [
90 { text: `${label} `, dim: true },
91 ...gaugeParts(limit.pct, LIMIT_GAUGE_WIDTH, color),
92 color === 'claude'
93 ? { text: `${PERCENT_GAP}${Math.round(limit.pct)}%`, dim: true }
94 : { text: `${PERCENT_GAP}${Math.round(limit.pct)}%`, color },
95 ...(eta ? [{ text: `${RESET_JOINT}↻ ${eta}`, dim: true }] : []),
96 ]
97}
98
99export const modelPart = (figures: Figures): Part => ({
100 text: figures.model ? prettyModel(figures.model) : PENDING,
101 color: 'claude',
102 bold: true,
103})
104types/index.d.ts 36 lines1export type Limit = { pct: number; resetsAt?: string }
2
3export type GitSummary = {
4 branch: string
5 isDirty: boolean
6 unpushedCount: number
7 hasUpstream: boolean
8}
9
10export type Extra = {
11 id: string
12 label: string
13 text: string
14 color?: string
15}
16
17export type Figures = {
18 dir: string | null
19 git: GitSummary | null
20 model: string | null
21 effort: string | null
22 extras?: Extra[]
23 ctxPct?: number
24 ctxTokens?: number
25 ctxWindow?: number
26 fiveHour?: Limit
27 week?: Limit
28 costUsd?: number
29}
30
31declare module 'claude-code' {
32 interface PluginState {
33 'statusline-pro': { figures: Figures; isHidden: boolean; revision: number }
34 }
35}
36