SLOPSHOPPER

statusline-pro

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

newpanespinnercommandprocesstimer
★ 2v1.0.4MITupdated 2026-10-20alexcloudstar/statusline-pro
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · statusline-pro
│ ┃ settings ✕ › fix the failing auth test and add an audit log call │ ┃ Status line settings │ ┃ Opus 5.5 · ctx ▰▰▱▱▱ 49% · 5h ▰▰▱▱▱ … ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Model [ auto ] ↑ ↓ ⏺ Update(src/auth.ts) │ ┃ Effort [ auto ] ↑ ↓ ⎿ Added 2 lines, removed 1 line │ ┃ Context [ auto ] ↑ ↓ ⏺ Bash(bun test) │ ┃ 5h limit [ auto ] ↑ ↓ ⎿ 3 pass, 1 fail │ ┃ 7d limit [ auto ] ↑ ↓ │ ┃ Directory [ auto ] ↑ ↓ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Git [ auto ] ↑ ↓ │ ┃ Cost [ auto ] ↑ ↓ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ auto: show when it matters. always / › /statusline-pro │ ┃ never: force it. ⎿ statusline-pro: Status line hidden. │ ┃ More segments: drop JSON files in │ ┃ ~/.claude/status-segments/ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · settings
Status line settings Opus 5.5 · ctx ▰▰▱▱▱ 49% · 5h ▰▰▱▱▱ 31% · ↻ now ·… Model [ auto ] ↑ ↓ Effort [ auto ] ↑ ↓ Context [ auto ] ↑ ↓ 5h limit [ auto ] ↑ ↓ 7d limit [ auto ] ↑ ↓ Directory [ auto ] ↑ ↓ Git [ auto ] ↑ ↓ Cost [ auto ] ↑ ↓ auto: show when it matters. always / never: force it. More segments: drop JSON files in ~/.claude/status-segments/
README

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.

Version Claude Code mod License: MIT

<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%">

Install

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.

Segments

IdShowsauto means
modelthe active modelalways
effortreasoning effortwhen the model reports one
contextcontext window usealways
five_hour5-hour rate limit and resetalways
week7-day rate limitonce usage reaches week_min_pct (60) and the limit hasn't reset
dirworking directory, shortenedalways
gitbranch, * dirty, ↑n unpushedinside a git repo
costsession costonce 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%).

Settings

/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.

FieldDefault
show_<id>autoauto, always or never for each segment above
week_min_pct60usage at which auto shows the 7-day limit
cost_min_usd1cost at which auto shows the cost
separator · (a dot with two spaces each side)text between segments
orderemptycomma list of ids, first to last; the rest follow in default order
hidden_segmentsemptycomma list of drop-in ids to hide

/statusline-pro with no argument hides or shows the whole line.

Badges from other mods

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.
  • The line reads the folder every 10 seconds and after each turn. Delete the file to drop the segment.
  • At most 12 files of 2 KB each. Symlinks are skipped. Control characters are stripped.
  • Segments get the id ext:<name>. Use it in order and hidden_segments, or toggle it in the pane.

What it does and touches

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.

  • Claude Code's own session figures: model, effort, context, rate limits, cost, working directory.
  • The .json files in ~/.claude/status-segments/ (or $CLAUDE_CONFIG_DIR/status-segments/).
  • Its own settings, through the /config rows listed under Settings.
  • The environment variables 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.

CallWhat it does here
$.session.model, usage, cwd, surfacesreads the session's model, usage figures, working directory and screens
$.env.getreads HOME and CLAUDE_CONFIG_DIR
$.fs.exists, list, readreads the .json files in ~/.claude/status-segments/
$.process.runruns the three read-only git commands above, with the argument list written out in the call
$.config.listreads this mod's own settings rows
$.config.setwrites 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.setkeeps the figures in the session's memory; nothing is saved to disk
$.clock.now, $.clock.everythe current time, and the 10-second refresh
$.command.registeradds the /statusline-pro command
$.ui.resolve, $.ui.opendraws the line and opens the settings pane
$.ui.logwrites a failure message to the debug log only (claude --debug), never to the screen or the model

Develop

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 .).

License

MIT

Source 7 files
hooks/register.tsx 284 lines
1import { 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}
284
hooks/extras.ts 39 lines
1import 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}
39
hooks/segments.ts 89 lines
1import 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}
89
hooks/settings.ts 65 lines
1export 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})
65
hooks/settings-view.tsx 78 lines
1import 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}
78
hooks/format.ts 104 lines
1import 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})
104
types/index.d.ts 36 lines
1export 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