SLOPSHOPPER

Status Band

A designed status band under the prompt: model and effort, directory, git, a context gauge, the 5-hour and weekly quota you have left, and cost.

newpanebandcommandprocesstimer
★ 1v0.8.0MITupdated 2026-10-03dukechain2333/cc-status-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · status-band
│ ┃ status-band ✕ › fix the failing auth test and add an audit log call │ ┃ ❯ 1: Clay ✻ Opus 5.5 │ app │ ctx 49% │ │ ┃ 2: Paper ✻ Opus 5.5 │ app │ ctx 49% │ ⏺ Read(src/auth.ts) │ ┃ 3: Aurora ✻ Opus 5.5 /w/app ctx 49% ⎿ Read 6 lines │ ┃ 4: Ink ✻ Opus 5.5 │ /w/app │ ctx 49% ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ shape d: theme default c: chips a: a ⏺ Bash(bun test) │ ┃ width f: full width i: fit content ⎿ 3 pass, 1 fail │ ┃ rows m: up to two s: one row │ ┃ glyphs u: unicode n: nerd font p: pla ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ time e: session time k: clock 24h h │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ Saved for every session. Esc closes. /band │ ┃ help shows the current setup. › /band │ │ ▗ dev@dev │ ✻ Opus 5.5 │ /work/app ▖ ▝ ctx ━━━━━───── 49% 97k/200k │ 5h ━━━━━╸── 69% left │ $0.42 ◷ 30m ▘ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▗ dev@dev │ ✻ Opus 5.5 │ /work/app ▖ ▝ ctx ━━━━━───── 49% 97k/200k │ 5h ━━━━━╸── 69% left │ $0.42 ◷ 30m ▘
Pane · status-band
❯ 1: Clay ✻ Opus 5.5 │ app │ ctx 49% │ 5h 69% 2: Paper ✻ Opus 5.5 │ app │ ctx 49% │ 5h 69% 3: Aurora ✻ Opus 5.5 /w/app ctx 49% 5h 69% 4: Ink ✻ Opus 5.5 │ /w/app │ ctx 49% │ 5h 69% shape d: theme default c: chips a: arrows l: line width f: full width i: fit content rows m: up to two s: one row glyphs u: unicode n: nerd font p: plain ascii time e: session time k: clock 24h h: clock 12h o: Saved for every session. Esc closes. /band help shows the current setup.
README

Status Band

A designed status band for Claude Code. One full-width band above the prompt that shows who and where you are, the model and its effort, git, how full the context window is, how much of your 5-hour and weekly quota is left, and the time or what the session has cost. The band always stands two rows tall: on a wide terminal one line sits centred between half rows of fill; on a narrower one it splits into two lines, where you are on top and what you've used below, in the same block.

中文说明

The band in its four themes

It ships two ways from one codebase:

  • As a mod (Claude Code 2.1.287 or later): drawn natively under the prompt with Claude Code's own UI elements, with a /band picker that previews every theme on your live session.
  • As a classic statusLine command (any version with status lines): a dependency-free Node script that prints the same band with ANSI colors.

What it shows

| Segment | Example | Notes | | :- | :- | :- | | User · host | william@macbook | $USER and the short hostname. | | Model · effort | ✻ Opus 5.5 ●●●●○ | Five pips for low … max; follows /effort live. Hidden for models without an effort setting. | | Directory | ~/code/cc-status-band | Abbreviates to ~/c/cc-status-band, then the folder name, as space runs out. | | Git | ⎇ main +2 ~3 ↑1 | Staged, unstaged/untracked, ahead/behind. Folds to ⎇ main ● when narrow. | | Context | ctx ━━━━────── 42% 84k/200k | Fills as the window fills: amber from 60%, red from 85%. | | 5-hour quota left | 5h ━━━━━━── 72% left ↻2h14m | Pro/Max subscribers. Drains as you spend: amber at 40% left, red under 15%. ↻ counts down to the refill. | | Weekly quota left | 7d ━━━━╸─── 59% left ↻3d | Its own chip and gauge, same colors. A gateway spend limit gets a third chip, spend. | | Cost · time | $1.42 ◷ 23m | The session's running time by default, or the wall clock with /band time clock. /band hide cost drops the dollars and keeps the time. With API-key billing there are no quota windows, so spend is highlighted instead. |

States: fresh, steady, warm, hot, API key

The band stretches across the terminal and stands two rows tall. When everything fits on one line, that line is centred and usage sits at the right end; otherwise the two groups take a line each, and only a line that still overflows folds its detail:

The same session at 190, 140, 100 and 72 columns

Install the mod

Requires Claude Code 2.1.287+ (claude --version).

/plugin marketplace add dukechain2333/cc-status-band
/plugin install status-band@cc-status-band

The band appears above the prompt in your next session (or after /reload-plugins).

If you also have a statusLine command in your settings, that one still draws under the prompt. Remove statusLine from ~/.claude/settings.json if you only want the band.

To try it without installing, clone the repo and start a session with it loaded:

git clone https://github.com/dukechain2333/cc-status-band
claude --plugin-dir ./cc-status-band

Customize

Run /band to open the picker. A digit picks a theme, a letter picks a shape, width, rows, glyph set or time, and every row previews your current session:

❯ 1: Clay      ✻ Opus 5.5 ●●●●○  ~/c/cc-status-band  ⎇ main ●  ctx ━━━━── 42%  5h 72% ↻2h14m
  2: Paper     …
  3: Aurora    …
  4: Ink       …

shape    d: theme default   c: chips   a: arrows   l: line   w: one band
width    f: full width   i: fit content
rows     m: up to two   s: one row
glyphs   u: unicode   n: nerd font   p: plain ascii
time     e: session time   k: clock 24h   h: clock 12h   o: off

Or set things directly. Each word sets whatever it names:

/band aurora               theme: clay | paper | aurora | ink
/band band                 shape: auto | chips | band | arrows | line
/band nerd                 glyphs: unicode | nerd | ascii
/band rows 1               rows: 2 (default; a two-row-tall band) | 1 (one flat row)
/band fit                  width: full (default; the band spans the terminal) | fit
/band time clock           time beside the cost: elapsed | clock (13:24) | clock12 (01:24PM) | off
/band hide cost git        hide: host model dir git ctx 5h 7d spend cost (quota = all windows)
/band show cost
/band reset
/band help                 print the current setup

Choices are saved for every session on the machine.

Use it as a classic statusLine

For Claude Code versions without mods, or if you prefer the classic row. Needs Node 18+.

git clone https://github.com/dukechain2333/cc-status-band ~/.claude/cc-status-band
node ~/.claude/cc-status-band/scripts/install-statusline.mjs --theme clay

The installer backs up settings.json first, then sets:

{
  "statusLine": {
    "type": "command",
    "command": "node ~/.claude/cc-status-band/statusline/status-band.mjs --theme clay"
  }
}

Flags (or environment variables):

| Flag | Env | Values | | :- | :- | :- | | --theme | STATUS_BAND_THEME | clay (default), paper, aurora, ink | | --shape | STATUS_BAND_SHAPE | auto (the theme's own), chips, band, arrows, line | | --glyphs | STATUS_BAND_GLYPHS | unicode (default), nerd, ascii | | --time | STATUS_BAND_TIME | elapsed (default), clock, clock12, off | | --rows | STATUS_BAND_ROWS | 2 (default) or 1 | | --width | STATUS_BAND_WIDTH | full (default) or fit | | --hide | STATUS_BAND_HIDE | comma list: host,model,dir,git,ctx,5h,7d,spend,quota,cost | | --colors | STATUS_BAND_COLORS | truecolor or 256 (detected from COLORTERM/TERM_PROGRAM by default) |

Undo with node scripts/install-statusline.mjs --uninstall.

Themes, shapes and glyphs

| Theme | For | Default shape | | :- | :- | :- | | Clay | warm dark terminals | band | | Paper | light terminals | band | | Aurora | cool, vivid dark terminals | arrows | | Ink | anything; colored text, no fills | line |

Any shape works with any filled theme: chips (a pill per segment), band (one block around everything, segments split by │, two rows tall with stepped corners drawn from block characters), arrows (powerline) or line (no fills). Chips and arrows draw rounded and pointed caps with Nerd Font glyphs when you pick the nerd glyph set; with the default unicode set they have square ends, which every font can draw. ascii avoids everything outside ASCII.

Preview them in your own terminal and font:

node scripts/preview.mjs                    # every theme
node scripts/preview.mjs --states           # every state in one theme
node scripts/preview.mjs --glyphs nerd

How it works

core/        pure ES modules shared by both front ends
  themes.js    palettes, shapes, glyph sets
  snapshot.js  statusLine JSON or mod session figures → one snapshot shape
  band.js      snapshot → segments → fold to width → colored runs
  ansi.js      runs → ANSI (truecolor or 256)
  elements.js  runs → mod Text elements
hooks/register.js        the mod: gathers figures via the mods API, draws, runs /band
statusline/status-band.mjs  the classic statusLine command

The mod reads the user, host, model, working directory and usage (context, rateLimits, cost) from $.session, takes the live effort from each turn.step, runs git status --porcelain=v2 through $.process.run after each turn and every 20 seconds, and redraws on session.measure. It draws only in the AbovePrompt render site, the band directly above the prompt, and leaves the line under the prompt to Claude Code.

Develop

npm test                 # core unit tests (node --test) and mod tests (claude plugin test)
npm run validate         # claude plugin validate for the plugin and the marketplace
npm run preview          # see every theme in your terminal
npm run docs             # regenerate docs/*.svg from the real renderer
claude --plugin-dir .    # load your checkout; edits hot-reload

Tested with Claude Code 2.1.288. The mods API can change between releases; if something stops drawing, run claude plugin validate .claude-plugin/plugin.json and check the debug log.

Design

The band was designed on a Claude Design canvas (overview, anatomy, themes, states, responsive widths and the picker) before it was built. docs/DESIGN.md records the decisions: palettes, thresholds, folding order.

License

MIT

Source 7 files
hooks/register.js 319 lines
1// status-band: draws the band above the Claude Code prompt.
2//
3// The band itself is built by ../core, which the classic statusLine script
4// shares. This module only gathers the session's figures through the mods
5// API, draws the band in the AbovePrompt render site, and runs the /band
6// picker.
7
8import { HIDEABLE, TIME_MODES, layoutBand, paint } from '../core/band.js'
9import { toElements } from '../core/elements.js'
10import { GIT_STATUS_ARGS, parseGitStatus } from '../core/git.js'
11import { fromSession } from '../core/snapshot.js'
12import { GLYPH_NAMES, SHAPES, THEMES, THEME_NAMES } from '../core/themes.js'
13
14const PANE = 'status-band'
15const DEFAULTS = {
16  theme: 'clay',
17  shape: 'auto',
18  glyphs: 'unicode',
19  time: 'elapsed',
20  rows: 2,
21  width: 'full',
22  hide: [],
23}
24const WIDTHS = ['full', 'fit']
25const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max']
26
27// What the band shows between redraws. Preferences live in $.store so every
28// session on the machine shares them; the rest is refreshed as the session runs.
29let prefs = { ...DEFAULTS }
30let git = null
31let effort = null
32let home = ''
33let user = ''
34let host = ''
35
36// Keeps only the keys the band still reads, so settings saved by an older
37// version (such as `place`) drop away.
38function cleanPrefs(saved) {
39  const s = saved && typeof saved === 'object' ? saved : {}
40  const p = { ...DEFAULTS }
41  if (THEME_NAMES.includes(s.theme)) p.theme = s.theme
42  if (SHAPES.includes(s.shape)) p.shape = s.shape
43  if (GLYPH_NAMES.includes(s.glyphs)) p.glyphs = s.glyphs
44  if (TIME_MODES.includes(s.time)) p.time = s.time
45  if (s.rows === 1 || s.rows === 2) p.rows = s.rows
46  if (WIDTHS.includes(s.width)) p.width = s.width
47  if (Array.isArray(s.hide)) p.hide = s.hide.filter((id) => HIDEABLE.includes(id))
48  return p
49}
50
51function describePrefs(p) {
52  const parts = [`theme ${p.theme}`, `shape ${p.shape}`, `glyphs ${p.glyphs}`, `time ${p.time}`, `rows ${p.rows}`, `width ${p.width}`]
53  if (p.hide.length) parts.push(`hiding ${p.hide.join(', ')}`)
54  return parts.join(' · ')
55}
56
57// `/band <words>`: each word sets whatever it names, so `/band aurora line`
58// works. Returns the new prefs, or an error string.
59function applyArgs(p, args) {
60  const words = args.toLowerCase().split(/\s+/).filter(Boolean)
61  const next = { ...p, hide: [...p.hide] }
62  for (let i = 0; i < words.length; i++) {
63    const w = words[i]
64    if (w === 'reset') return { ...DEFAULTS, hide: [] }
65    if (THEME_NAMES.includes(w)) next.theme = w
66    else if (SHAPES.includes(w)) next.shape = w
67    else if (GLYPH_NAMES.includes(w)) next.glyphs = w
68    else if (w === 'below') return 'The band always sits above the prompt.'
69    else if (w === 'above') continue
70    else if (w === 'time' && TIME_MODES.includes(words[i + 1])) next.time = words[(i += 1)]
71    else if (TIME_MODES.includes(w)) next.time = w
72    else if (w === 'rows' && ['1', '2'].includes(words[i + 1])) next.rows = Number(words[(i += 1)])
73    else if (WIDTHS.includes(w)) next.width = w
74    else if (w === 'hide' || w === 'show') {
75      const ids = words.slice(i + 1).filter((id) => HIDEABLE.includes(id))
76      if (!ids.length) return `Name a segment to ${w}: ${HIDEABLE.join(', ')}`
77      next.hide = w === 'hide' ? [...new Set([...next.hide, ...ids])] : next.hide.filter((id) => !ids.includes(id))
78      i = words.length
79    } else {
80      return `Unknown option "${w}". Themes: ${THEME_NAMES.join(', ')}; shapes: ${SHAPES.join(', ')}; glyphs: ${GLYPH_NAMES.join(', ')}; time: ${TIME_MODES.join(', ')}; rows 1|2; full|fit; hide/show <segment>; reset.`
81    }
82  }
83  return next
84}
85
86async function loadPrefs($) {
87  try {
88    prefs = cleanPrefs(await $.store.get('prefs'))
89  } catch {
90    prefs = cleanPrefs(null)
91  }
92}
93
94async function savePrefs($, p) {
95  prefs = cleanPrefs(p)
96  $.ui.invalidate('ui.render')
97  await $.store.set('prefs', prefs)
98}
99
100async function refreshGit($) {
101  try {
102    const cwd = await $.session.cwd()
103    const r = await $.process.run(['git', '-C', cwd, ...GIT_STATUS_ARGS], { timeoutMs: 3000 })
104    git = r.exitCode === 0 ? parseGitStatus(r.stdout) : null
105  } catch {
106    git = null
107  }
108}
109
110// Until the first request reports the live effort, use what the last session
111// saw for this model, or else what settings ask for.
112async function readSettings($) {
113  try {
114    const settings = (await $.settings.read()) || {}
115    const model = await $.session.model()
116    const seen = (await $.store.get('effortByModel')) || {}
117    const perModel = settings.modelSettings && settings.modelSettings[model]
118    const level = model in seen ? seen[model] : (perModel && perModel.effortLevel) || settings.effortLevel
119    effort = EFFORTS.includes(level) ? level : null
120  } catch {}
121}
122
123async function rememberEffort($, model, level) {
124  const seen = (await $.store.get('effortByModel')) || {}
125  if (seen[model] === level) return
126  await $.store.set('effortByModel', { ...seen, [model]: level })
127}
128
129async function snapshot($) {
130  const [modelId, cwd, usage, now] = await Promise.all([
131    $.session.model(),
132    $.session.cwd(),
133    $.session.usage(),
134    $.clock.now(),
135  ])
136  return fromSession({ modelId, effort, cwd, home, usage, git, now, user, host })
137}
138
139function drawBand(snap, options, columns, elements) {
140  return toElements(paint(layoutBand(snap, options, columns)), elements)
141}
142
143async function readHost($) {
144  try {
145    const r = await $.process.run(['hostname', '-s'], { timeoutMs: 2000 })
146    if (r.exitCode === 0 && r.stdout.trim()) return r.stdout.trim()
147  } catch {}
148  return (await $.env.get('COMPUTERNAME')) || ''
149}
150
151export function register(on) {
152  on('session.start', async ($, e, next) => {
153    await loadPrefs($)
154    home = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || ''
155    user = (await $.env.get('USER')) || (await $.env.get('USERNAME')) || ''
156    host = await readHost($)
157    await readSettings($)
158    await refreshGit($)
159
160    // Keeps the reset countdowns and git state current while the session idles.
161    $.clock.every(20000, async () => {
162      await refreshGit($)
163      $.ui.invalidate('ui.render')
164    })
165
166    try {
167      await $.command.register({
168        name: 'band',
169        description: 'Pick the status band theme, shape and layout',
170        argumentHint: '[theme | shape | glyphs | time <mode> | rows 1|2 | full | fit | hide <segment> | show <segment> | reset]',
171        immediate: true,
172      })
173    } catch {}
174    return next(e)
175  })
176
177  // The figures moved: context fill, a quota window, or cost.
178  on('session.measure', async ($, e, next) => {
179    $.ui.invalidate('ui.render')
180    return next(e)
181  })
182
183  // Each request on the main loop carries the live effort, /effort included,
184  // and none for a model that takes no effort setting.
185  on('turn.step', async function* ($, e, next) {
186    const live = EFFORTS.includes(e.effort) ? e.effort : null
187    if (!e.agentId && live !== effort) {
188      effort = live
189      $.ui.invalidate('ui.render')
190      await rememberEffort($, e.model, live)
191    }
192    return yield* next(e)
193  })
194
195  on('turn.complete', async ($, e, next) => {
196    const result = await next(e)
197    await refreshGit($)
198    $.ui.invalidate('ui.render')
199    return result
200  })
201
202  // Above the prompt, in the band other mods share; theirs stays beneath ours.
203  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
204    if (e.props.hasSurvey || e.surface !== 'terminal') return next(e)
205    const { Box, Text } = $.ui.resolve(e)
206    // Claude Code draws its `[-]` collapse control over the band's last three
207    // cells, so the band stops one cell short of it.
208    const band = drawBand(await snapshot($), prefs, Math.max(20, e.props.bodyColumns - 4), { Box, Text })
209    // Claude Code draws nothing here itself; only another mod's tree is kept.
210    const theirs = await next(e)
211    return theirs && theirs.type !== 'engine' ? Box({ flexDirection: 'column', children: [band, theirs] }) : band
212  })
213
214  on('command.run', { command: 'band' }, async ($, e) => {
215    const args = (e.args || '').trim()
216    if (!args) {
217      await $.ui.open({ id: PANE, title: 'status-band', focus: true, closeOnEscape: true })
218      return {}
219    }
220    if (args === 'help') return { text: describePrefs(prefs) }
221    const result = applyArgs(prefs, args)
222    if (typeof result === 'string') return { text: result }
223    await savePrefs($, result)
224    return { text: describePrefs(prefs) }
225  })
226
227  // The /band picker: one row per theme, previewed with this session's own
228  // figures, then the shape, layout, glyph and time choices.
229  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
230    if (e.requestId !== PANE) return next(e)
231    const { Box, Text, Button } = $.ui.resolve(e)
232    const snap = await snapshot($)
233    const previewColumns = Math.max(30, (e.props.bodyColumns || 80) - 17)
234    const choose = (key, value) => () => savePrefs($, { ...prefs, [key]: value })
235
236    const themeRows = THEME_NAMES.map((name, i) => {
237      const active = prefs.theme === name
238      return Box({
239        flexDirection: 'row',
240        columnGap: 1,
241        children: [
242          Text({ color: THEMES[name].accent, children: [active ? '❯' : ' '] }),
243          Box({
244            width: 12,
245            flexShrink: 0,
246            children: [
247              Button({
248                key: 'theme-' + name,
249                label: THEMES[name].label,
250                hotkey: String(i + 1),
251                plain: true,
252                dimColor: !active,
253                onPress: choose('theme', name),
254              }),
255            ],
256          }),
257          drawBand(snap, { ...prefs, theme: name, rows: 1, width: 'fit' }, previewColumns, { Box, Text }),
258        ],
259      })
260    })
261
262    const optionRow = (title, key, options) =>
263      Box({
264        flexDirection: 'row',
265        columnGap: 3,
266        children: [
267          Box({ width: 7, flexShrink: 0, children: [Text({ dimColor: true, children: [title] })] }),
268          ...options.map(([value, label, hotkey]) =>
269            Button({
270              key: key + '-' + value,
271              label,
272              hotkey,
273              plain: true,
274              dimColor: prefs[key] !== value,
275              onPress: choose(key, value),
276            }),
277          ),
278        ],
279      })
280
281    return Box({
282      flexDirection: 'column',
283      rowGap: 0,
284      children: [
285        ...themeRows,
286        Text({ children: [' '] }),
287        optionRow('shape', 'shape', [
288          ['auto', 'theme default', 'd'],
289          ['chips', 'chips', 'c'],
290          ['arrows', 'arrows', 'a'],
291          ['line', 'line', 'l'],
292          ['band', 'one band', 'w'],
293        ]),
294        optionRow('width', 'width', [
295          ['full', 'full width', 'f'],
296          ['fit', 'fit content', 'i'],
297        ]),
298        optionRow('rows', 'rows', [
299          [2, 'up to two', 'm'],
300          [1, 'one row', 's'],
301        ]),
302        optionRow('glyphs', 'glyphs', [
303          ['unicode', 'unicode', 'u'],
304          ['nerd', 'nerd font', 'n'],
305          ['ascii', 'plain ascii', 'p'],
306        ]),
307        optionRow('time', 'time', [
308          ['elapsed', 'session time', 'e'],
309          ['clock', 'clock 24h', 'k'],
310          ['clock12', 'clock 12h', 'h'],
311          ['off', 'off', 'o'],
312        ]),
313        Text({ children: [' '] }),
314        Text({ dimColor: true, children: ['Saved for every session. Esc closes. /band help shows the current setup.'] }),
315      ],
316    })
317  })
318}
319
core/band.js 401 lines
1// The band's layout: a snapshot of the session goes in, one or two rows of
2// colored runs come out. Both renderers share it, so the mod and the
3// statusLine script fold, color and space the band the same way.
4//
5// A snapshot is plain data:
6//
7//   {
8//     user: 'william', host: 'macbook',
9//     model: 'Opus 5.5', effort: 'xhigh' | null,
10//     cwd: '/Users/me/code/app', home: '/Users/me',
11//     git: { branch, staged, changed, ahead, behind } | null,
12//     context: { percent, tokens, window } | null,
13//     limits: { five: { left, resetsAt }, seven: {…}, spend: {…} },   // each may be null
14//     cost: 1.42 | null, elapsedMs: 1380000 | null, now: Date.now(),   // now also drives the clock
15//   }
16//
17// `left` is the percentage of a quota window still unused, and `resetsAt` is
18// epoch milliseconds.
19
20import { effortPips, formatClock, formatDuration, formatPath, formatResetIn, formatTokens, formatUsd, textWidth } from './format.js'
21import { resolveGlyphs, resolveShape, resolveTheme } from './themes.js'
22
23export const SEGMENT_IDS = ['host', 'model', 'dir', 'git', 'ctx', '5h', '7d', 'spend', 'cost']
24
25// The band reads as two groups: where you are, then how much you have used.
26// On one row the second group sits at the right end; on two rows each group
27// gets its own row.
28const IDENTITY = ['host', 'model', 'dir', 'git']
29
30// Names `hide` accepts: every segment, plus `quota` for all three windows.
31// Hiding `cost` drops the dollar figure and keeps the time beside it.
32export const HIDEABLE = [...SEGMENT_IDS, 'quota']
33
34export function expandHide(list) {
35  const out = new Set()
36  for (const id of Array.isArray(list) ? list : []) {
37    if (id === 'quota') ['5h', '7d', 'spend'].forEach((w) => out.add(w))
38    else if (SEGMENT_IDS.includes(id)) out.add(id)
39  }
40  return [...out]
41}
42
43// What the time beside the cost shows: the session's running time, the wall
44// clock (24-hour or 12-hour), or nothing.
45export const TIME_MODES = ['elapsed', 'clock', 'clock12', 'off']
46
47// Context fills up: it turns amber at 60% used and red at 85%.
48export function contextRole(percent) {
49  return percent >= 85 ? 'crit' : percent >= 60 ? 'warn' : 'ok'
50}
51
52// Quota drains: amber at 40% left, red under 15%.
53export function quotaRole(left) {
54  return left < 15 ? 'crit' : left <= 40 ? 'warn' : 'ok'
55}
56
57// When the row is too narrow, detail folds away in this order. Each step sets
58// one segment's level: 0 is full, 1 compact, 2 minimal, and below 0 hidden.
59// API-key sessions have no quota windows, so their cost is kept until the
60// very last step.
61const FOLDS = [
62  ['ctx', 1],
63  ['7d', 1],
64  ['spend', 1],
65  ['5h', 1],
66  ['cost', 1],
67  ['git', 1],
68  ['dir', 1],
69  ['cost', -1],
70  ['host', -1],
71  ['7d', 2],
72  ['spend', 2],
73  ['5h', 2],
74  ['git', -1],
75  ['ctx', 2],
76  ['dir', 2],
77  ['7d', -1],
78  ['spend', -1],
79  ['model', 2],
80  ['cost', -2],
81  ['5h', 3],
82  ['dir', -1],
83]
84
85const text = (t, role, bold) => ({ text: t, role, bold: !!bold })
86const bar = (percent, role, width) => ({ bar: Math.max(0, Math.min(100, percent)), role, width })
87
88function hostSegment(s) {
89  if (!s.user && !s.host) return null
90  const items = []
91  if (s.user) items.push(text(s.user, 'fg'))
92  if (s.user && s.host) items.push(text('@', 'muted'))
93  if (s.host) items.push(text(s.host, 'fg'))
94  return { id: 'host', label: 'user@host', items }
95}
96
97function modelSegment(s, g, level) {
98  const items = [text(g.model + ' ', 'icon'), text(s.model || 'Claude', 'fg', true)]
99  const pips = effortPips(s.effort)
100  if (level < 2 && pips) items.push(text(' ' + g.pipOn.repeat(pips), 'pip'), text(g.pipOff.repeat(5 - pips), 'pipOff'))
101  return { id: 'model', label: 'model · effort', items }
102}
103
104function dirSegment(s, g, level) {
105  const path = formatPath(s.cwd, s.home, level)
106  return path ? { id: 'dir', label: 'directory', items: [text(path, 'fg', true)] } : null
107}
108
109function gitSegment(s, g, level) {
110  if (!s.git || !s.git.branch) return null
111  const { branch, staged, changed, ahead, behind } = s.git
112  const items = []
113  if (g.git) items.push(text(g.git + ' ', 'icon'))
114  items.push(text(branch, 'fg'))
115  if (level === 0) {
116    if (staged) items.push(text(' +' + staged, 'ok'))
117    if (changed) items.push(text(' ~' + changed, 'warn'))
118    if (ahead) items.push(text(' ' + g.ahead + ahead, 'muted'))
119    if (behind) items.push(text(' ' + g.behind + behind, 'muted'))
120  } else if (staged || changed) {
121    items.push(text(' ' + g.dirty, 'warn'))
122  }
123  return { id: 'git', label: 'git', items }
124}
125
126function ctxSegment(s, g, level) {
127  if (!s.context) return null
128  const { percent, tokens, window } = s.context
129  const items = [text('ctx ', 'muted')]
130  if (percent == null) {
131    items.push(text(g.none, 'muted'))
132  } else {
133    const p = Math.round(percent)
134    const role = contextRole(p)
135    if (level < 2) items.push(bar(p, role, level === 0 ? 10 : 6), text(' ', 'fg'))
136    items.push(text(p + '%', role, true))
137    if (level === 0 && tokens && window) items.push(text(` ${formatTokens(tokens)}/${formatTokens(window)}`, 'muted'))
138  }
139  return { id: 'ctx', label: 'context window', items }
140}
141
142// One chip per quota window. Each bar drains as the window is spent.
143const WINDOWS = { '5h': 'five', '7d': 'seven', spend: 'spend' }
144
145function windowSegment(id) {
146  return (s, g, level) => {
147    const w = (s.limits || {})[WINDOWS[id]]
148    if (!w || w.left == null) return null
149    const left = Math.max(0, Math.round(w.left))
150    const role = quotaRole(left)
151    const reset = formatResetIn(w.resetsAt, s.now)
152    const items = [text(id + ' ', 'muted')]
153    if (level < 2) items.push(bar(left, role, level === 0 ? 8 : 5), text(' ', 'fg'))
154    items.push(text(left + '%', role, true))
155    if (level === 0) items.push(text(' left', 'muted'))
156    // A low window keeps its countdown until the very last fold.
157    if (reset && (level < 2 || (level === 2 && role === 'crit'))) {
158      items.push(text(' ' + g.reset + reset, role === 'crit' ? 'crit' : 'muted'))
159    }
160    return { id, label: id === 'spend' ? 'spend limit left' : id + ' quota left', items }
161  }
162}
163
164function timeText(s, time) {
165  if (time === 'off') return ''
166  if (time === 'clock' || time === 'clock12') return formatClock(s.now, time === 'clock12')
167  return s.elapsedMs == null ? '' : formatDuration(s.elapsedMs)
168}
169
170function costSegment(s, g, level, { hero, time, hideMoney }) {
171  if (level === -1 && !hero) return null
172  const items = []
173  if (s.cost != null && !hideMoney) items.push(text(formatUsd(s.cost), hero ? 'accent' : 'fg', hero))
174  const t = level === 0 ? timeText(s, time) : ''
175  if (t) items.push(text((items.length ? '  ' : '') + (g.clock ? g.clock + ' ' : '') + t, 'muted'))
176  const label = hideMoney ? 'time' : hero ? 'api spend · time' : 'cost · time'
177  return items.length ? { id: 'cost', label, items } : null
178}
179
180function buildSegments(s, g, levels, { hide, time }) {
181  const limits = s.limits || {}
182  const hideMoney = hide.includes('cost')
183  // Without quota windows (API-key billing) spend is the number to watch.
184  const hero = !hideMoney && !limits.five && !limits.seven && !limits.spend && s.cost > 0
185  const extra = { hero, time, hideMoney }
186  const out = []
187  const add = (id, make) => {
188    // `cost` stays to carry the time; its segment drops the money itself.
189    if ((hide.includes(id) && id !== 'cost') || levels[id] < -1 || (levels[id] < 0 && id !== 'cost')) return
190    const seg = make(s, g, levels[id], extra)
191    if (seg) out.push(seg)
192  }
193  add('host', hostSegment)
194  add('model', modelSegment)
195  add('dir', dirSegment)
196  add('git', gitSegment)
197  add('ctx', ctxSegment)
198  add('5h', windowSegment('5h'))
199  add('7d', windowSegment('7d'))
200  add('spend', windowSegment('spend'))
201  add('cost', costSegment)
202  return out
203}
204
205function itemWidth(item) {
206  return item.bar != null ? item.width : textWidth(item.text)
207}
208
209// Cells the shape adds around n segments.
210// A band has an edge glyph at each end when it is two rows tall (block
211// elements) or when Nerd Font caps round it off.
212function chromeWidth(shape, g, n, edged) {
213  if (n === 0) return 0
214  if (shape === 'line') return (n - 1) * 3
215  if (shape === 'chips') return n * (2 + (g.capLeft ? 2 : 0)) + (n - 1)
216  if (shape === 'band') return (n - 1) * 3 + 2 + (edged ? 2 : 0)
217  return n * (2 + (g.arrow ? 1 : 0))
218}
219
220export function measure(segments, shape, g, edged = !!g.capLeft) {
221  let w = chromeWidth(shape, g, segments.length, edged)
222  for (const seg of segments) for (const item of seg.items) w += itemWidth(item)
223  return w
224}
225
226// Lays the snapshot out in at most `rows` rows of `columns` cells.
227//   options: { theme, shape, glyphs, hide, time, rows: 1 | 2, width: 'full' | 'fit' }
228//
229// Everything on one row when it fits at full detail. Otherwise, with two rows
230// allowed, the two groups split onto their own rows, and only a row that
231// still overflows folds its own segments.
232export function layoutBand(snapshot, options = {}, columns = 120) {
233  const theme = resolveTheme(options.theme)
234  // A palette without fills has nothing to draw chips or arrows with.
235  const shape = theme.model.bg ? resolveShape(options.shape, theme) : 'line'
236  const glyphs = resolveGlyphs(options.glyphs)
237  const maxRows = options.rows === 1 ? 1 : 2
238  // With two rows allowed the band stands two rows tall: one line centred
239  // between half rows of fill, or two lines when one is too short.
240  const tall = shape === 'band' && maxRows === 2 && !!glyphs.padTop
241  const edged = tall || !!glyphs.capLeft
242  const pick = {
243    hide: expandHide(options.hide),
244    time: TIME_MODES.includes(options.time) ? options.time : 'elapsed',
245  }
246  const levels = Object.fromEntries(SEGMENT_IDS.map((id) => [id, 0]))
247  const build = () => buildSegments(snapshot, glyphs, levels, pick)
248  const fits = (row) => measure(row, shape, glyphs, edged) <= columns
249  const split = (segs) =>
250    [segs.filter((seg) => IDENTITY.includes(seg.id)), segs.filter((seg) => !IDENTITY.includes(seg.id))].filter((row) => row.length)
251
252  let rows = [build()]
253  if (!fits(rows[0])) {
254    const steps = FOLDS.slice()
255    if (maxRows === 2) rows = split(rows[0])
256    for (;;) {
257      const over = new Set(rows.filter((row) => !fits(row)).flatMap((row) => row.map((seg) => seg.id)))
258      if (!over.size) break
259      const next = steps.findIndex(([id]) => over.has(id))
260      if (next < 0) break
261      const [id, level] = steps.splice(next, 1)[0]
262      levels[id] = level
263      rows = maxRows === 2 ? split(build()) : [build()]
264    }
265  }
266  return {
267    rows,
268    segments: rows.flat(),
269    theme,
270    shape,
271    glyphs,
272    columns,
273    // Only the one-band shape stretches; other shapes keep their own width.
274    fill: shape === 'band' && options.width !== 'fit',
275    tall,
276    edged,
277  }
278}
279
280function barText(item, g) {
281  const halves = Math.round((item.bar / 100) * item.width * 2) || (item.bar > 0 ? 1 : 0)
282  const full = Math.min(item.width, Math.floor(halves / 2))
283  const half = full < item.width && halves % 2 ? 1 : 0
284  return {
285    filled: g.fill.repeat(full) + (half ? g.half : ''),
286    empty: g.track.repeat(item.width - full - half),
287  }
288}
289
290// Turns a laid-out band into rows of runs, { text, fg, bg, bold }. A null fg
291// or bg is the terminal's own color.
292export function paint(band) {
293  const { rows, theme: T, glyphs: g } = band
294  if (band.shape !== 'band') return rows.map((row) => paintRow(row, band, {}))
295  // Every row of a band shares one width, so the rows stack into one block.
296  const width = band.fill ? band.columns : Math.max(...rows.map((row) => measure(row, 'band', g, band.edged)))
297  if (!band.tall) return rows.map((row) => paintRow(row, band, { left: g.capLeft, right: g.capRight, width }))
298  if (rows.length === 1) {
299    // One line, centred: half a row of fill above and below, and edges only
300    // beside the text, so each corner steps in by a quarter cell.
301    const half = (glyph) => [
302      { text: ' ', fg: null, bg: null },
303      { text: glyph.repeat(Math.max(0, width - 2)), fg: T.b.bg, bg: null },
304      { text: ' ', fg: null, bg: null },
305    ]
306    return [half(g.padTop), paintRow(rows[0], band, { left: g.edgeLeft, right: g.edgeRight, width }), half(g.padBottom)]
307  }
308  // Two lines: the edge columns fill only the inner half of each row.
309  return rows.map((row, i) =>
310    paintRow(row, band, {
311      left: i === 0 ? g.cornerTL : g.cornerBL,
312      right: i === 0 ? g.cornerTR : g.cornerBR,
313      width,
314    }),
315  )
316}
317
318function paintRow(segments, band, frame) {
319  const { theme: T, shape, glyphs: g } = band
320  // On a bare row the model chip's on-accent colors would vanish into the
321  // terminal, so it borrows the plain tone and keeps the accent for its marks.
322  // The one-band shape does the same inside its single fill.
323  const bareModel = { bg: null, fg: T.a.fg, muted: T.a.muted, icon: T.accent, pip: T.accent, pipOff: T.track }
324  const single = shape === 'band'
325  const toneOf = (seg, i) => {
326    if (single) return seg.id === 'model' ? { ...bareModel, bg: T.b.bg } : T.b
327    return seg.id === 'model' ? (shape === 'line' && T.model.bg ? bareModel : T.model) : i % 2 === 1 ? T.a : T.b
328  }
329  const bgOf = (i) => (shape === 'line' || !segments[i] ? null : toneOf(segments[i], i).bg)
330  // A stretched band fills its spare cells: between the two groups when both
331  // share the row, else at the end.
332  const spare = single ? Math.max(0, frame.width - measure(segments, shape, g, band.edged)) : 0
333  const groupBreak = segments.findIndex((seg) => !IDENTITY.includes(seg.id))
334  const runs = []
335  if (single && segments.length) {
336    if (frame.left) runs.push({ text: frame.left, fg: T.b.bg, bg: null })
337    runs.push({ text: ' ', fg: T.b.fg, bg: T.b.bg })
338  }
339  segments.forEach((seg, i) => {
340    const tone = toneOf(seg, i)
341    const bg = bgOf(i)
342    const color = (role) =>
343      ({
344        fg: tone.fg,
345        muted: tone.muted,
346        icon: tone.icon,
347        pip: tone.pip || T.model.pip,
348        pipOff: tone.pipOff || T.model.pipOff,
349        ok: T.ok,
350        warn: T.warn,
351        crit: T.crit,
352        accent: T.accent,
353      })[role] || tone.fg
354
355    if (shape === 'line' && i > 0) runs.push({ text: ` ${g.sep} `, fg: T.sep, bg: null })
356    if (single && i > 0 && spare && band.fill && i === groupBreak) runs.push({ text: ' '.repeat(3 + spare), fg: T.divider, bg })
357    else if (single && i > 0) runs.push({ text: ` ${g.sep} `, fg: T.divider, bg })
358    if (shape === 'chips' && i > 0) runs.push({ text: ' ', fg: null, bg: null })
359    if (shape === 'chips' && g.capLeft) runs.push({ text: g.capLeft, fg: bg, bg: null })
360    if (bg && !single) runs.push({ text: ' ', fg: tone.fg, bg })
361    for (const item of seg.items) {
362      if (item.bar != null) {
363        const { filled, empty } = barText(item, g)
364        if (filled) runs.push({ text: filled, fg: color(item.role), bg })
365        if (empty) runs.push({ text: empty, fg: T.track, bg })
366      } else {
367        runs.push({ text: item.text, fg: color(item.role), bg, bold: item.bold })
368      }
369    }
370    if (bg && !single) runs.push({ text: ' ', fg: tone.fg, bg })
371    if (shape === 'chips' && g.capRight) runs.push({ text: g.capRight, fg: bg, bg: null })
372    if (shape === 'arrows' && g.arrow) runs.push({ text: g.arrow, fg: bg, bg: bgOf(i + 1) })
373  })
374  if (single && segments.length) {
375    if (spare && !(band.fill && groupBreak > 0)) runs.push({ text: ' '.repeat(spare), fg: T.b.fg, bg: T.b.bg })
376    runs.push({ text: ' ', fg: T.b.fg, bg: T.b.bg })
377    if (frame.right) runs.push({ text: frame.right, fg: T.b.bg, bg: null })
378  }
379  return mergeRuns(runs)
380}
381
382function mergeRuns(runs) {
383  const out = []
384  for (const r of runs) {
385    if (!r.text) continue
386    const last = out[out.length - 1]
387    const plain = r.text.trim() === ''
388    if (last && last.bg === r.bg && (last.fg === r.fg || plain) && !!last.bold === !!r.bold) {
389      last.text += r.text
390    } else {
391      out.push({ text: r.text, fg: r.fg || null, bg: r.bg || null, bold: !!r.bold })
392    }
393  }
394  return out
395}
396
397// The whole pipeline in one call: rows of runs.
398export function renderRuns(snapshot, options, columns) {
399  return paint(layoutBand(snapshot, options, columns))
400}
401
core/elements.js 17 lines
1// Runs → a mod element tree. Takes the `Text` constructor that
2// `$.ui.resolve(e)` hands out, so this file never touches the mods API.
3
4function span(Text, run) {
5  const props = { children: [run.text] }
6  if (run.fg) props.color = run.fg
7  if (run.bg) props.backgroundColor = run.bg
8  if (run.bold) props.bold = true
9  return Text(props)
10}
11
12// One Text per row; a row never wraps, and whatever doesn't fit is cut at the end.
13export function toElements(rows, { Box, Text }) {
14  const lines = rows.map((runs) => Text({ wrap: 'truncate-end', children: runs.map((run) => span(Text, run)) }))
15  return lines.length === 1 ? lines[0] : Box({ flexDirection: 'column', children: lines })
16}
17
core/git.js 36 lines
1// Parses `git status --porcelain=v2 --branch` into what the git segment shows.
2// Both renderers run git themselves (the mod through $.process.run, the
3// statusLine script through child_process) and hand the output here.
4
5export const GIT_STATUS_ARGS = ['--no-optional-locks', 'status', '--porcelain=v2', '--branch']
6
7export function parseGitStatus(stdout) {
8  if (!stdout) return null
9  let branch = null
10  let oid = null
11  let ahead = 0
12  let behind = 0
13  let staged = 0
14  let changed = 0
15  for (const line of String(stdout).split('\n')) {
16    if (line.startsWith('# branch.head ')) branch = line.slice(14).trim()
17    else if (line.startsWith('# branch.oid ')) oid = line.slice(13).trim()
18    else if (line.startsWith('# branch.ab ')) {
19      const m = line.match(/\+(\d+) -(\d+)/)
20      if (m) {
21        ahead = Number(m[1])
22        behind = Number(m[2])
23      }
24    } else if (line.startsWith('1 ') || line.startsWith('2 ')) {
25      const xy = line.slice(2, 4)
26      if (xy[0] !== '.') staged += 1
27      if (xy[1] !== '.') changed += 1
28    } else if (line.startsWith('u ') || line.startsWith('? ')) {
29      changed += 1
30    }
31  }
32  if (!branch) return null
33  if (branch === '(detached)') branch = oid && oid !== '(initial)' ? oid.slice(0, 7) : 'detached'
34  return { branch, staged, changed, ahead, behind }
35}
36
core/snapshot.js 72 lines
1// Builds the band's snapshot (see band.js) from what each host hands over:
2// the statusLine command's stdin JSON, or the mods API's session figures.
3
4import { modelName } from './format.js'
5
6const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null)
7
8function window(usedPercent, resetsAt) {
9  const used = num(usedPercent)
10  return used == null ? null : { left: 100 - used, resetsAt: resetsAt || null }
11}
12
13// The JSON Claude Code pipes to a statusLine command.
14export function fromStatusLine(input, { home, now, git, user, host } = {}) {
15  const i = input || {}
16  const ctx = i.context_window || {}
17  const rl = i.rate_limits || {}
18  const sec = (v) => (num(v) == null ? null : v * 1000)
19  return {
20    user: user || '',
21    host: host || '',
22    model: modelName(i.model && i.model.id, i.model && i.model.display_name),
23    effort: (i.effort && i.effort.level) || null,
24    cwd: (i.workspace && i.workspace.current_dir) || i.cwd || '',
25    home: home || '',
26    git: git || null,
27    context: {
28      percent: num(ctx.used_percentage),
29      tokens: num(ctx.total_input_tokens),
30      window: num(ctx.context_window_size),
31    },
32    limits: {
33      five: rl.five_hour ? window(rl.five_hour.used_percentage, sec(rl.five_hour.resets_at)) : null,
34      seven: rl.seven_day ? window(rl.seven_day.used_percentage, sec(rl.seven_day.resets_at)) : null,
35      spend: rl.spend_limit ? window(rl.spend_limit.used_percentage, sec(rl.spend_limit.resets_at)) : null,
36    },
37    cost: num(i.cost && i.cost.total_cost_usd),
38    elapsedMs: num(i.cost && i.cost.total_duration_ms),
39    now: now || Date.now(),
40  }
41}
42
43// What a mod reads through $.session: model(), cwd() and usage().
44export function fromSession({ modelId, effort, cwd, home, usage, git, now, user, host }) {
45  const u = usage || {}
46  const ctx = u.context || {}
47  const limits = { five: null, seven: null, spend: null }
48  for (const rl of u.rateLimits || []) {
49    const key = rl.kind === 'five_hour' ? 'five' : rl.kind === 'seven_day' ? 'seven' : rl.kind === 'spend_limit' ? 'spend' : null
50    if (key) limits[key] = window(rl.percentUsed, rl.resetsAt ? Date.parse(rl.resetsAt) : null)
51  }
52  const t = now || Date.now()
53  return {
54    user: user || '',
55    host: host || '',
56    model: modelName(modelId),
57    effort: typeof effort === 'string' ? effort : null,
58    cwd: cwd || '',
59    home: home || '',
60    git: git || null,
61    context: {
62      percent: num(ctx.percent),
63      tokens: num(ctx.tokens),
64      window: num(ctx.window),
65    },
66    limits,
67    cost: u.cost ? num(u.cost.usd) : null,
68    elapsedMs: num(u.startedAt) == null ? null : Math.max(0, t - u.startedAt),
69    now: t,
70  }
71}
72
core/themes.js 181 lines
1// Palettes, shapes and glyph sets for the band.
2//
3// Every color is a #rrggbb string, so the same palette feeds both renderers:
4// the mod's Text elements take it as-is, and the ANSI renderer turns it into a
5// truecolor (or 256-color) escape.
6//
7// A palette has two chip tones, `a` and `b`, that alternate left to right so
8// neighbouring chips stay distinct, plus the model chip, which carries the
9// accent. `ok`, `warn` and `crit` color the gauges and the numbers beside them.
10// The one-band shape fills everything with tone `b` and splits segments with
11// `divider`.
12
13export const THEMES = {
14  clay: {
15    label: 'Clay',
16    note: 'warm dark',
17    shape: 'band',
18    terminal: { bg: '#1A1917', fg: '#E8E5DA' },
19    accent: '#D97757',
20    model: { bg: '#D97757', fg: '#1A1917', icon: '#1A1917', pip: '#1A1917', pipOff: '#A9583C' },
21    a: { bg: '#2A2825', fg: '#E8E5DA', muted: '#8C877B', icon: '#D97757' },
22    b: { bg: '#33302C', fg: '#E8E5DA', muted: '#8C877B', icon: '#D97757' },
23    ok: '#A3C281',
24    warn: '#E9B44C',
25    crit: '#F0715A',
26    track: '#524D45',
27    sep: '#4A4640',
28    divider: '#5E5951',
29  },
30  paper: {
31    label: 'Paper',
32    note: 'for light terminals',
33    shape: 'band',
34    terminal: { bg: '#FAF9F5', fg: '#141413' },
35    accent: '#B4552F',
36    model: { bg: '#141413', fg: '#FAF9F5', icon: '#E08A68', pip: '#FAF9F5', pipOff: '#5E5D59' },
37    a: { bg: '#ECE9E0', fg: '#2B2A27', muted: '#6E6B64', icon: '#B4552F' },
38    b: { bg: '#E2DED3', fg: '#2B2A27', muted: '#6E6B64', icon: '#B4552F' },
39    ok: '#4A7533',
40    warn: '#9A5F0C',
41    crit: '#B23A27',
42    track: '#C9C3B5',
43    sep: '#C9C3B5',
44    divider: '#BDB7A9',
45  },
46  aurora: {
47    label: 'Aurora',
48    note: 'cool and vivid',
49    shape: 'arrows',
50    terminal: { bg: '#0E1120', fg: '#DCE1F5' },
51    accent: '#A68CFF',
52    model: { bg: '#8B6CFF', fg: '#0E1120', icon: '#0E1120', pip: '#0E1120', pipOff: '#5B45B8' },
53    a: { bg: '#1A2038', fg: '#DCE1F5', muted: '#8089AD', icon: '#6FE3D0' },
54    b: { bg: '#232B4A', fg: '#DCE1F5', muted: '#8089AD', icon: '#6FE3D0' },
55    ok: '#6FE3D0',
56    warn: '#FFCB6B',
57    crit: '#FF6B8B',
58    track: '#3B4570',
59    sep: '#343D63',
60    divider: '#4A5585',
61  },
62  ink: {
63    label: 'Ink',
64    note: 'no fills',
65    shape: 'line',
66    terminal: { bg: '#111111', fg: '#D4D4D4' },
67    accent: '#D97757',
68    model: { bg: null, fg: '#F2F2F2', icon: '#D97757', pip: '#D97757', pipOff: '#4D4D4D' },
69    a: { bg: null, fg: '#CFCFCF', muted: '#7A7A7A', icon: '#9A9A9A' },
70    b: { bg: null, fg: '#CFCFCF', muted: '#7A7A7A', icon: '#9A9A9A' },
71    ok: '#7FB685',
72    warn: '#D9A54A',
73    crit: '#E06C5A',
74    track: '#3A3A3A',
75    sep: '#3A3A3A',
76    divider: '#3A3A3A',
77  },
78}
79
80export const THEME_NAMES = Object.keys(THEMES)
81
82// `auto` means "the theme's own shape".
83export const SHAPES = ['auto', 'chips', 'arrows', 'line', 'band']
84
85// The two-row band draws its half rows, edges and stepped corners with block
86// elements (▄ ▀ ▐ ▌ ▗ ▖ ▝ ▘), which every terminal font has; ascii has none,
87// so there the band stays one flat row.
88//
89// Chips, the flat band and arrows draw their rounded and pointed caps with Nerd Font glyphs
90// from the Private Use Area. Without a Nerd Font those render as boxes, so the
91// unicode and ascii sets leave the caps out and the chips get square ends.
92export const GLYPHS = {
93  nerd: {
94    capLeft: '',
95    capRight: '',
96    arrow: '',
97    model: '✻',
98    git: '',
99    reset: '↻',
100    clock: '◷',
101    pipOn: '●',
102    pipOff: '○',
103    dirty: '●',
104    ahead: '↑',
105    behind: '↓',
106    sep: '│',
107    fill: '━',
108    half: '╸',
109    track: '─',
110    none: '—',
111    padTop: '▄',
112    padBottom: '▀',
113    edgeLeft: '▐',
114    edgeRight: '▌',
115    cornerTL: '▗',
116    cornerTR: '▖',
117    cornerBL: '▝',
118    cornerBR: '▘',
119  },
120  unicode: {
121    capLeft: '',
122    capRight: '',
123    arrow: '',
124    model: '✻',
125    git: '⎇',
126    reset: '↻',
127    clock: '◷',
128    pipOn: '●',
129    pipOff: '○',
130    dirty: '●',
131    ahead: '↑',
132    behind: '↓',
133    sep: '│',
134    fill: '━',
135    half: '╸',
136    track: '─',
137    none: '—',
138    padTop: '▄',
139    padBottom: '▀',
140    edgeLeft: '▐',
141    edgeRight: '▌',
142    cornerTL: '▗',
143    cornerTR: '▖',
144    cornerBL: '▝',
145    cornerBR: '▘',
146  },
147  ascii: {
148    capLeft: '',
149    capRight: '',
150    arrow: '',
151    model: '*',
152    git: '',
153    reset: '~',
154    clock: '',
155    pipOn: '#',
156    pipOff: '.',
157    dirty: '*',
158    ahead: '^',
159    behind: 'v',
160    sep: '|',
161    fill: '=',
162    half: '-',
163    track: '.',
164    none: '-',
165  },
166}
167
168export const GLYPH_NAMES = Object.keys(GLYPHS)
169
170export function resolveTheme(name) {
171  return THEMES[name] || THEMES.clay
172}
173
174export function resolveShape(shape, theme) {
175  return shape && shape !== 'auto' && SHAPES.includes(shape) ? shape : theme.shape
176}
177
178export function resolveGlyphs(name) {
179  return GLYPHS[name] || GLYPHS.unicode
180}
181
core/format.js 124 lines
1// Small, pure formatters shared by the mod and the statusLine script.
2
3// claude-opus-5-5 → Opus 5.5, claude-haiku-4-5-20251001 → Haiku 4.5,
4// claude-sonnet-5-5[1m] → Sonnet 5.5. Anything else is shown as given.
5export function modelName(id, displayName) {
6  const raw = String(id || '')
7  const m = raw.match(/^(?:[a-z0-9.-]+\.)?claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?(?:-v\d+(?::\d+)?)?(\[1m\])?$/i)
8  if (m) {
9    const family = m[1][0].toUpperCase() + m[1].slice(1).toLowerCase()
10    return `${family} ${m[2]}${m[3] ? '.' + m[3] : ''}`
11  }
12  return displayName || raw || 'Claude'
13}
14
15const EFFORT_PIPS = { low: 1, medium: 2, high: 3, xhigh: 4, max: 5 }
16
17export function effortPips(level) {
18  return EFFORT_PIPS[level] || 0
19}
20
21// Three levels of detail for a path:
22//   0  ~/code/cc-status-band   (long paths keep their last three parts)
23//   1  ~/c/cc-status-band      (every parent cut to its first letter)
24//   2  cc-status-band
25export function formatPath(cwd, home, level = 0) {
26  if (!cwd) return ''
27  let p = String(cwd).replace(/\\/g, '/')
28  if (home) {
29    const h = String(home).replace(/\\/g, '/').replace(/\/$/, '')
30    if (p === h) p = '~'
31    else if (p.startsWith(h + '/')) p = '~' + p.slice(h.length)
32  }
33  const parts = p.split('/')
34  const base = parts[parts.length - 1] || p
35  if (level >= 2) return base
36  if (level === 1) {
37    return parts
38      .map((part, i) => {
39        if (i === parts.length - 1 || part === '' || part === '~') return part
40        const start = part.startsWith('.') ? 2 : 1
41        return Array.from(part).slice(0, start).join('')
42      })
43      .join('/')
44  }
45  if (parts.length > 5) return [parts[0] === '~' ? '~' : '', '…', ...parts.slice(-3)].join('/')
46  return p
47}
48
49// 23m · 1h48m · 2d5h
50export function formatDuration(ms) {
51  if (ms == null || !Number.isFinite(ms) || ms < 0) return ''
52  const mins = Math.floor(ms / 60000)
53  if (mins < 60) return `${mins}m`
54  const hours = Math.floor(mins / 60)
55  if (hours < 24) return `${hours}h${String(mins % 60).padStart(2, '0')}m`
56  const days = Math.floor(hours / 24)
57  return hours % 24 ? `${days}d${hours % 24}h` : `${days}d`
58}
59
60// Time until a window refills, coarser than a duration: 38m · 2h14m · 3d
61export function formatResetIn(resetsAt, now) {
62  if (resetsAt == null || now == null) return ''
63  const ms = resetsAt - now
64  if (ms <= 0) return 'now'
65  const mins = Math.ceil(ms / 60000)
66  if (mins < 60) return `${mins}m`
67  const hours = Math.floor(mins / 60)
68  if (hours < 10) return `${hours}h${String(mins % 60).padStart(2, '0')}m`
69  if (hours < 24) return `${hours}h`
70  const days = Math.floor(hours / 24)
71  return days < 3 && hours % 24 ? `${days}d${hours % 24}h` : `${days}d`
72}
73
74// The wall clock in the machine's time zone: 13:24, or 01:24PM with `twelve`.
75export function formatClock(ms, twelve = false) {
76  if (ms == null || !Number.isFinite(ms)) return ''
77  const d = new Date(ms)
78  const mm = String(d.getMinutes()).padStart(2, '0')
79  if (!twelve) return `${String(d.getHours()).padStart(2, '0')}:${mm}`
80  const h = d.getHours() % 12 || 12
81  return `${String(h).padStart(2, '0')}:${mm}${d.getHours() < 12 ? 'AM' : 'PM'}`
82}
83
84// 950 · 84k · 1.2M
85export function formatTokens(n) {
86  if (n == null || !Number.isFinite(n)) return ''
87  if (n < 1000) return String(Math.round(n))
88  if (n < 1e6) return `${Math.round(n / 1000)}k`
89  const m = n / 1e6
90  return `${m >= 10 || Number.isInteger(m) ? Math.round(m) : m.toFixed(1)}M`
91}
92
93export function formatUsd(usd) {
94  if (usd == null || !Number.isFinite(usd)) return ''
95  if (usd >= 100) return `$${Math.round(usd)}`
96  return `$${usd.toFixed(2)}`
97}
98
99// Terminal cells a string takes: wide East Asian characters count two, and
100// combining marks and zero-width joiners count none.
101export function textWidth(s) {
102  let w = 0
103  for (const ch of String(s)) {
104    const cp = ch.codePointAt(0)
105    if (cp === 0x200d || (cp >= 0x300 && cp <= 0x36f) || (cp >= 0xfe00 && cp <= 0xfe0f)) continue
106    if (
107      (cp >= 0x1100 && cp <= 0x115f) ||
108      (cp >= 0x2e80 && cp <= 0xa4cf && cp !== 0x303f) ||
109      (cp >= 0xac00 && cp <= 0xd7a3) ||
110      (cp >= 0xf900 && cp <= 0xfaff) ||
111      (cp >= 0xfe30 && cp <= 0xfe4f) ||
112      (cp >= 0xff00 && cp <= 0xff60) ||
113      (cp >= 0xffe0 && cp <= 0xffe6) ||
114      (cp >= 0x1f300 && cp <= 0x1faff) ||
115      (cp >= 0x20000 && cp <= 0x3fffd)
116    ) {
117      w += 2
118    } else {
119      w += 1
120    }
121  }
122  return w
123}
124