SLOPSHOPPER

rich-statusline

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

newbandspinnerguardcommandprocess
★ 1v0.1.2Apache-2.0updated 2026-10-05blackpaw-studio/claude-plugins/plugins/rich-statusline
A shopper browsing a rack in a slop shop
README

rich-statusline

A Claude Code mod (function-hook plugin) that draws a multi-row status dashboard under the prompt: model and effort, where you are (path, git branch, linked worktree, uncommitted diff stats, PR), the context window broken down by category, the 5-hour and weekly usage limits with reset countdowns, and the session's cost.

Three layouts, switchable live (the short names 1a, 1b and 1c below refer to them):

  • Labeled grid (1b, the default): model / where / context / limits rows, a category legend ending in · N free · compact N%.
  • Grouped rows (1a): identity, a 60-cell context bar with the auto-compact marker, a category legend, and the limits.
  • Compact (1c): a full-width bar and two justified rows.

Every layout ends in a full-width ─ rule.

Screenshots

Labeled grid Grouped rows Compact Switching layouts

Claude Code's own permission-mode line and its hint text (? for shortcuts, esc to interrupt, task pills) stay exactly as the engine draws them.

Requirements

  • Claude Code 2.1.289 or later.
  • Function-hook plugins (mods) are early access; the API may change between releases.

Install

From this marketplace:

/plugin marketplace add blackpaw-studio/claude-plugins
/plugin install rich-statusline@blackpaw-plugins

Then remove any statusLine entry from your settings.json. A mod cannot hide the settings status line, so leaving it in place draws both.

Settings

Run /rich-statusline (or /rich-statusline settings) to open the settings menu in the band above the prompt; run it again to close it. Press ctrl+x tab to focus the menu, then Tab or the arrow keys move between controls and Enter changes one. Done (d) closes the menu, Reset to defaults (r) restores the defaults. Changes apply immediately and are saved in the plugin's store.

The menu lives in the band rather than a side pane because panes do not draw in some terminal setups (Claude Code inside tmux, for one).

SettingDefault
LayoutLabeled gridGrouped rows, Labeled grid or Compact
Show coston1a: end of the first row; 1b: limits row; 1c: before 5h
Show PRon#123 from gh pr view; no PR when there is none or gh is missing
Show diff statson(+12,-3) from git diff HEAD --shortstat, after the branch
Show worktreeonwt <name> before the branch inside a linked git worktree
Show legendonthe category legend of 1a and 1b
Amber at70%context and each limit turn amber from here
Red at90%...and red from here
Git refresh10 sbranch, worktree and diff stats
PR refresh60 salso refreshed at once when the branch changes

There is no keyboard shortcut: this build of the plugin API lets a Button bind only the engine's own keybinding actions, not a plugin-defined one.

Behaviour

  • Terminal only. On desktop, VS Code and mobile the hint line is left as the engine draws it.
  • Width: the rows fill the terminal less the 4 columns the engine pads the hint line by (2 each side), and the breakpoints below count that same width.
  • Narrow terminals: the context bar shrinks to fit; the 1a and 1b legends show whenever they fit the row, shedding notes first (1b: the · compact N% note, then · N free; 1a: the ┊ compact N% note) and hiding only when even the bare legend overflows; under 80 columns of row the reset countdowns and the PR go; under 60 the compact layout is used whatever is chosen.
  • No rate limits (API key, gateway): the limits row is hidden.
  • Before the first response: — replaces the context percentage, over an empty bar.
  • Zero-token categories are left out of the legends. MCP tools loaded via tool search are deferred (outside the window), so mcp is often 0.
  • The category split uses the local summary estimate of /context (no token-count requests), refreshed two seconds after the context last changed.
  • git and gh run through the host with a timeout; a render never waits on them. Outside a repository the row reads no git and gh is not asked.

Colours

The rows use your terminal theme's own palette (its normal colour slots, plus gray), so they follow light and dark themes alike.

Limitations

  • Column widths count characters, not display cells: a path or branch with wide characters (CJK, emoji) can push a 1c row past the edge, where it is cut off.
  • Category sizes are estimates, so they need not add up to the exact 28k/200k figure, which is the last API response's.

Development

claude plugin validate plugins/rich-statusline
claude plugin test plugins/rich-statusline

Type-checking needs the declarations the engine lays into .claude-plugin/types/ when it loads the mod from a folder you own (a --plugin-dir or the session's mods folder); tsc -p that folder.

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