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.

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.
It ships two ways from one codebase:
/band picker that previews every theme on your live session.statusLine command (any version with status lines): a dependency-free Node script that prints the same band with ANSI colors.| 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. |
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:
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
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.
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.
| 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
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.
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.
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.
MIT
hooks/register.js 319 lines1// 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}
319core/band.js 401 lines1// 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}
401core/elements.js 17 lines1// 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}
17core/git.js 36 lines1// 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}
36core/snapshot.js 72 lines1// 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}
72core/themes.js 181 lines1// 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}
181core/format.js 124 lines1// 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