Customizable status line for Claude Code: lualine-style sections, themes, and data from other mods

hooks/register.js 287 lines1import { components } from '../components.js'
2import { DEFAULT_THEME, THEME_NAMES, themeFor, mergeTheme } from '../theme.js'
3
4const DEFAULT_SECTIONS = {
5 a: [{ name: 'mode' }, { name: 'model' }],
6 b: [{ name: 'context' }, { name: 'contextBar' }],
7 c: [{ name: 'cwd' }],
8 x: [{ name: 'cost' }],
9 y: [{ name: 'rate5h' }, { name: 'rateWeek' }],
10 z: [{ name: 'effort' }],
11}
12
13const DEFAULT_SEPARATORS = { left: ' ', right: ' ' }
14const DROP_ORDER = ['c', 'b', 'x', 'y']
15const LEFT_KEYS = ['a', 'b', 'c']
16const RIGHT_KEYS = ['x', 'y', 'z']
17
18let working = false
19let effort = ''
20let dirty = true
21const published = new Map()
22const execCache = new Map()
23let cfg = null
24let dataCache = { at: 0, data: null }
25
26function publish({ id, values }) {
27 published.set(id, values)
28 dirty = true
29}
30
31function unwrap(result) {
32 return result && typeof result === 'object' && 'value' in result ? result.value : result
33}
34
35function parseJson(value, fallback) {
36 if (typeof value === 'string') {
37 try {
38 return JSON.parse(value)
39 } catch {
40 return fallback
41 }
42 }
43 return value == null ? fallback : value
44}
45
46function separators(value) {
47 const s = parseJson(value, DEFAULT_SEPARATORS) || {}
48 return {
49 left: typeof s.left === 'string' ? s.left : DEFAULT_SEPARATORS.left,
50 right: typeof s.right === 'string' ? s.right : DEFAULT_SEPARATORS.right,
51 }
52}
53
54async function readConfig($, options) {
55 const o = options || {}
56 let src = {}
57 try {
58 const s = unwrap(await $.settings.read())
59 const pc = s?.pluginConfigs ?? {}
60 const key = pc['statusline@claude-mods']
61 ? 'statusline@claude-mods'
62 : pc['statusline']
63 ? 'statusline'
64 : Object.keys(pc).find((k) => k.includes('statusline'))
65 const holder = key ? pc[key] : null
66 src = holder && typeof holder === 'object' ? (holder.options ?? holder) : {}
67 } catch {
68 src = {}
69 }
70 return {
71 theme: typeof o.theme === 'string' ? o.theme : DEFAULT_THEME,
72 position: o.position === 'below' ? 'below' : 'above',
73 componentSeparators: separators(src.componentSeparators),
74 sectionSeparators: separators(src.sectionSeparators),
75 sections: parseJson(src.sections, DEFAULT_SECTIONS),
76 themes: parseJson(src.themes, undefined),
77 }
78}
79
80async function collect($) {
81 const now = Date.now()
82 if (dataCache.data && now - dataCache.at < 1000) return dataCache.data
83 const data = {
84 model: null,
85 effort: effort || null,
86 working,
87 cwd: null,
88 context: { tokens: null, window: null, percent: null },
89 rateLimits: [],
90 cost: null,
91 startedAt: null,
92 }
93 try {
94 const u = unwrap(await $.session.usage())
95 if (u && typeof u === 'object') {
96 data.context = u.context ?? data.context
97 data.rateLimits = Array.isArray(u.rateLimits) ? u.rateLimits : []
98 data.cost = u.cost ?? null
99 data.startedAt = u.startedAt ?? null
100 }
101 } catch {}
102 try {
103 const m = unwrap(await $.session.model())
104 data.model = typeof m === 'string' ? m : (m?.name ?? m?.id ?? null)
105 } catch {}
106 try {
107 data.cwd = unwrap(await $.session.cwd()) ?? null
108 } catch {}
109 dataCache = { at: now, data }
110 return data
111}
112
113function renderNamed(name, data) {
114 const fn = components[name]
115 if (typeof fn !== 'function') return ''
116 try {
117 // validator constraint: $ is never forwarded into a name lookup; components take data only
118 const r = fn(data)
119 return typeof r === 'string' ? r : r == null ? '' : String(r)
120 } catch {
121 return ''
122 }
123}
124
125function formatValue(raw, fmt) {
126 if (fmt === 'percent') return Number.isFinite(raw) ? Math.round(raw) + '%' : ''
127 if (fmt === 'number') return Number.isFinite(raw) ? String(raw) : ''
128 if (fmt === 'account') {
129 return raw && raw.provider ? (raw.alias ? raw.provider + '/' + raw.alias : raw.provider) : ''
130 }
131 return typeof raw === 'string' ? raw : raw == null ? '' : String(raw)
132}
133
134function entryText(entry, data) {
135 let text = ''
136 if (entry.from) {
137 const v = published.get(entry.from.id)
138 if (v === undefined) {
139 if (entry.name) text = renderNamed(entry.name, data)
140 } else if (typeof entry.from.fmt === 'string' && entry.from.fmt.includes('${')) {
141 text = entry.from.fmt.replace(/\$\{([^}]*)\}/g, (_, k) => String(v?.[k] ?? ''))
142 } else {
143 text = formatValue(v?.[entry.from.field], entry.from.fmt)
144 }
145 } else if (entry.name) {
146 text = renderNamed(entry.name, data)
147 } else if (entry.exec) {
148 text = execCache.get(entry.exec.command)?.text ?? ''
149 }
150 if (text === '' || text == null) return ''
151 text = String(text)
152 if (entry.icon) text = entry.icon + ' ' + text
153 if (Number.isFinite(entry.padding)) {
154 const p = ' '.repeat(Math.max(0, entry.padding))
155 text = p + text + p
156 }
157 return text
158}
159
160function roughWidth(sections) {
161 let n = 8
162 for (const key of Object.keys(sections)) {
163 const list = sections[key]
164 if (!Array.isArray(list)) continue
165 for (const entry of list) {
166 if (!entry) continue
167 n +=
168 (entry.icon ? entry.icon.length + 1 : 0) +
169 (entry.name?.length ?? entry.from?.field?.length ?? entry.exec?.command?.length ?? 4) +
170 4
171 }
172 }
173 return n
174}
175
176function fit(columns, sections) {
177 const out = {}
178 for (const k of Object.keys(sections)) out[k] = sections[k]
179 for (const k of DROP_ORDER) {
180 if (roughWidth(out) <= columns) break
181 out[k] = []
182 }
183 return out
184}
185
186async function refreshExec($) {
187 if (!cfg) return
188 const now = Date.now()
189 for (const key of Object.keys(cfg.sections)) {
190 const list = Array.isArray(cfg.sections[key]) ? cfg.sections[key] : []
191 for (const entry of list) {
192 const ex = entry && entry.exec
193 if (!ex || typeof ex.command !== 'string') continue
194 const intervalMs = Number.isFinite(ex.intervalMs) ? ex.intervalMs : 5000
195 const cur = execCache.get(ex.command)
196 if (cur && now - cur.at < intervalMs) continue
197 let text = ''
198 try {
199 const r = unwrap(await $.process.run(['sh', '-c', ex.command]))
200 text = String(r?.stdout ?? '').trim().split(/\r?\n/)[0]?.trim() ?? ''
201 } catch {
202 text = ''
203 }
204 execCache.set(ex.command, { text, at: Date.now(), intervalMs })
205 }
206 }
207}
208
209async function draw($, e) {
210 const { Box, Text } = $.ui.resolve(e)
211 const data = await collect($)
212 const columns = typeof e.props?.bodyColumns === 'number' ? e.props.bodyColumns : 80
213 const sections = fit(columns, cfg.sections)
214 const palette = mergeTheme(
215 themeFor(cfg.theme, working ? 'working' : 'idle'),
216 cfg.themes?.[cfg.theme]
217 )
218
219 const render = (keys, sep) => {
220 const out = []
221 for (const key of keys) {
222 const list = Array.isArray(sections[key]) ? sections[key] : []
223 for (const entry of list) {
224 const text = entryText(entry, data)
225 if (text === '') continue
226 if (out.length > 0 && sep) out.push(Text({ children: [sep], dimColor: true }))
227 const raw = entry.color ?? palette[key]
228 out.push(Text({ children: [text], color: typeof raw === 'string' && raw !== '' ? raw : undefined }))
229 }
230 }
231 return out
232 }
233
234 return Box({
235 flexDirection: 'row',
236 children: [
237 ...render(LEFT_KEYS, cfg.componentSeparators.left),
238 Box({ flexGrow: 1 }),
239 ...render(RIGHT_KEYS, cfg.componentSeparators.right),
240 ],
241 })
242}
243
244export function register(on, options) {
245 on('engine.create', async ($, e, next) => {
246 const built = await next(e)
247 return { ...built, statusline: { publish } }
248 })
249
250 on('session.start', async ($, e, next) => {
251 cfg = await readConfig($, options)
252 $.clock.every(1000, () => {
253 refreshExec($)
254 $.ui.invalidate('ui.render')
255 })
256 return next(e)
257 })
258
259 on('turn.start', async ($, e, next) => {
260 working = true
261 dataCache.at = 0
262 return next(e)
263 })
264
265 on('turn.complete', async ($, e, next) => {
266 working = false
267 dataCache.at = 0
268 return next(e)
269 })
270
271 on('turn.step', async function* ($, e, next) {
272 if (e?.effort) {
273 effort = String(e.effort)
274 dataCache.at = 0
275 }
276 return yield* next(e)
277 })
278
279 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) =>
280 cfg?.position === 'below' ? next(e) : draw($, e)
281 )
282
283 on('ui.render', { component: 'PromptHint' }, async ($, e, next) =>
284 cfg?.position === 'below' ? draw($, e) : next(e)
285 )
286}
287components.js 61 lines1const safe = (fn) => (data) => {
2 try {
3 const v = fn(data)
4 return typeof v === 'string' ? v : ''
5 } catch {
6 return ''
7 }
8}
9
10const fmtTokens = (n) =>
11 n >= 1000 ? parseFloat((n / 1000).toFixed(1)) + 'k' : String(n)
12
13const fmtPercent = (n) => Math.round(n) + '%'
14
15const findRate = (data, kinds) => {
16 const list = Array.isArray(data?.rateLimits) ? data.rateLimits : []
17 const hit = list.find(
18 (e) => e && typeof e.kind === 'string' && kinds.includes(e.kind.toLowerCase().replace(/-/g, ''))
19 )
20 return hit && typeof hit.percentUsed === 'number' ? fmtPercent(hit.percentUsed) : ''
21}
22
23const mode = safe((data) => (data?.working ? 'WORK' : 'IDLE'))
24const model = safe((data) => (typeof data?.model === 'string' ? data.model : ''))
25const effort = safe((data) => (typeof data?.effort === 'string' ? data.effort : ''))
26const context = safe((data) => {
27 const c = data?.context
28 const hasT = typeof c?.tokens === 'number'
29 const hasP = typeof c?.percent === 'number'
30 if (!hasT && !hasP) return ''
31 return `${hasT ? fmtTokens(c.tokens) : '-'} (${hasP ? fmtPercent(c.percent) : '-'})`
32})
33const contextBar = safe((data) => {
34 const pct = data?.context?.percent
35 if (typeof pct !== 'number') return ''
36 const filled = Math.max(0, Math.min(10, Math.round(pct / 10)))
37 return '▰'.repeat(filled) + '▱'.repeat(10 - filled)
38})
39// kind is matched lowercase with '-' stripped, so 'five-hour' hits 'fivehour';
40// underscore forms like 'five_hour' are listed literally
41const rate5h = safe((data) => findRate(data, ['five_hour', 'fivehour', '5h']))
42const rateWeek = safe((data) => findRate(data, ['seven_day', 'sevenday', '7d', 'week']))
43const cost = safe((data) => {
44 const c = data?.cost
45 if (typeof c === 'number') return '$' + c.toFixed(2)
46 if (c && typeof c === 'object') {
47 for (const k of ['usd', 'total', 'amount', 'cost']) {
48 if (typeof c[k] === 'number') return '$' + c[k].toFixed(2)
49 }
50 }
51 return ''
52})
53const cwd = safe((data) => {
54 const p = data?.cwd
55 if (typeof p !== 'string' || p === '') return ''
56 const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'))
57 return i === -1 ? p : p.slice(i + 1)
58})
59
60export const components = { mode, model, effort, context, contextBar, rate5h, rateWeek, cost, cwd }
61theme.js 50 lines1export const DEFAULT_THEME = 'auto'
2
3export const themes = {
4 auto: {
5 working: { a: '#7d93c4', b: '#5f6b7d', c: '#8a8f98', x: '#8a8f98', y: '#5f6b7d', z: '#7d93c4' },
6 idle: { a: '#6b778c', b: '#565e6b', c: '#757a83', x: '#757a83', y: '#565e6b', z: '#6b778c' },
7 alert: { a: '#c46b6b', b: '#6e5656', c: '#8a7f7f', x: '#8a7f7f', y: '#6e5656', z: '#c46b6b' },
8 },
9 gruvbox: {
10 working: { a: '#fe8019', b: '#b8bb26', c: '#ebdbb2', x: '#fabd2f', y: '#fb4934', z: '#d65d0e' },
11 idle: { a: '#d65d0e', b: '#98971a', c: '#a89984', x: '#d79921', y: '#cc241d', z: '#af3a03' },
12 alert: { a: '#fb4934', b: '#cc241d', c: '#fe8019', x: '#d79921', y: '#9d0006', z: '#cc241d' },
13 },
14 nord: {
15 working: { a: '#88c0d0', b: '#81a1c1', c: '#8fbcbb', x: '#8fbcbb', y: '#81a1c1', z: '#5e81ac' },
16 idle: { a: '#5e81ac', b: '#4c566a', c: '#81a1c1', x: '#81a1c1', y: '#4c566a', z: '#5e81ac' },
17 alert: { a: '#bf616a', b: '#d08770', c: '#ebcb8b', x: '#d08770', y: '#bf616a', z: '#bf616a' },
18 },
19 'tokyo-night': {
20 working: { a: '#bb9af7', b: '#7aa2f7', c: '#73daca', x: '#7dcfff', y: '#f7768e', z: '#7aa2f7' },
21 idle: { a: '#565f89', b: '#787c99', c: '#a9b1d6', x: '#787c99', y: '#565f89', z: '#565f89' },
22 alert: { a: '#f7768e', b: '#ff9e64', c: '#e0af68', x: '#f7768e', y: '#ff9e64', z: '#f7768e' },
23 },
24 mono: {
25 working: { a: '#d0d0d0', b: '#a8a8a8', c: '#e4e4e4', x: '#bcbcbc', y: '#8a8a8a', z: '#d0d0d0' },
26 idle: { a: '#8a8a8a', b: '#707070', c: '#9e9e9e', x: '#707070', y: '#626262', z: '#8a8a8a' },
27 alert: { a: '#ffffff', b: '#e0e0e0', c: '#bdbdbd', x: '#e0e0e0', y: '#9e9e9e', z: '#ffffff' },
28 },
29}
30
31export const THEME_NAMES = ['auto', 'gruvbox', 'nord', 'tokyo-night', 'mono']
32
33const KEYS = ['a', 'b', 'c', 'x', 'y', 'z']
34
35const str = (v) => (typeof v === 'string' && v !== '' ? v : undefined)
36
37export function themeFor(name, state) {
38 const theme = Object.hasOwn(themes, name) ? themes[name] : themes[DEFAULT_THEME]
39 const src = Object.hasOwn(theme, state) ? theme[state] : theme.idle
40 const out = {}
41 for (const k of KEYS) out[k] = str(src?.[k])
42 return out
43}
44
45export function mergeTheme(base, override) {
46 const out = {}
47 for (const k of KEYS) out[k] = str(override?.[k]) ?? str(base?.[k])
48 return out
49}
50types/index.d.ts 76 lines1/** Session snapshot a component renders. */
2export type StatuslineData = {
3 model: string | null
4 effort: string | null
5 working: boolean
6 cwd: string | null
7 context: { tokens: number | null; window: number | null; percent: number | null }
8 rateLimits: Array<{ kind: string; percentUsed: number | null; resetsAt: string | null }>
9 cost: unknown
10 startedAt: string | number | null
11}
12
13/** Values a publisher drops under its id. */
14export type StatuslineValues = Record<string, unknown>
15
16/** How a sourced value becomes display text, or a `${field}` template over the values. */
17export type StatuslineFormat = 'string' | 'percent' | 'number' | 'account' | string
18
19/** Points an entry at data a publisher published under `id`. */
20export type StatuslineSource = {
21 id: string
22 field?: string
23 fmt?: StatuslineFormat
24}
25
26/** A component whose text comes from a shell command. */
27export type StatuslineExec = {
28 command: string
29 intervalMs?: number
30}
31
32/** One component in a section. Exactly one of `name`, `from` alone, or `exec` supplies it. */
33export type StatuslineEntry = {
34 name?: string
35 from?: StatuslineSource
36 exec?: StatuslineExec
37 icon?: string
38 color?: string
39 padding?: number
40}
41
42export type StatuslineSectionName = 'a' | 'b' | 'c' | 'x' | 'y' | 'z'
43
44export type StatuslineSections = Record<StatuslineSectionName, StatuslineEntry[]>
45
46export type StatuslineSeparators = { left?: string; right?: string }
47
48export type StatuslineState = 'working' | 'idle' | 'alert'
49
50/** Colours for the six sections in one state. */
51export type StatuslineThemeState = Record<StatuslineSectionName, string>
52
53export type StatuslineTheme = Record<StatuslineState, StatuslineThemeState>
54
55/** Layout and appearance. Nested parts live in pluginConfigs, not userConfig. */
56export type StatuslineConfig = {
57 theme?: string
58 position?: 'above' | 'below'
59 componentSeparators?: StatuslineSeparators
60 sectionSeparators?: StatuslineSeparators
61 sections?: Partial<StatuslineSections>
62 themes?: Record<string, Partial<StatuslineTheme>>
63}
64
65/** The noun this plugin adds to `$` in engine.create. */
66export type Statusline = {
67 /** Any mod becomes a publisher. `values` are plain data the config maps to entries. */
68 publish: (args: { id: string; values: StatuslineValues }) => void
69}
70
71declare module 'claude-code' {
72 interface EngineInterface {
73 statusline: Statusline
74 }
75}
76