SLOPSHOPPER

statusline

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

newpanespinnercommandtoastprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · statusline
│ ┃ Status line ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ Status line │ ┃ Model, folder and branch, then context, 5h ⏺ Read(src/auth.ts) │ ┃ and 7d limits and cache. ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Status line ⎿ Added 2 lines, removed 1 line │ ┃ ○ On ⏺ Bash(bun test) │ ┃ ● Off ⎿ 3 pass, 1 fail │ ┃ On takes your own statusLine out of │ ┃ settings.json; Off puts it back. ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ↑↓ move · enter select · esc close ✻ Worked for 42s · done 4:20 PM │ │ › /statusline │ ⎿ statusline: Status line settings are open. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Status line
◆ Status line Model, folder and branch, then context, 5h and 7d limits and cache. Status line ○ On ● Off On takes your own statusLine out of settings.json; Off puts it back. ↑↓ move · enter select · esc close
README

statusline

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).
  • Turning it on moves your own 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.
  • Independent of the on/off switch, it shows a toast once per session when the context passes 60% ("consider /compact at the next pause") and again at 80% ("time to /compact").

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.

Install

/plugin marketplace add danielmalka/malka-claude-mods
/plugin install statusline@malka-claude-mods

Uninstall

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.

Development

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.


Português

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.

Source 6 files
hooks/register.tsx 277 lines
1import { 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}
277
hooks/settings.ts 36 lines
1// ~/.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}
36
hooks/context-toasts.ts 31 lines
1import 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}
31
hooks/status.ts 70 lines
1// 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}
70
hooks/status-line.tsx 140 lines
1import 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}
140
types/index.d.ts 27 lines
1export 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