SLOPSHOPPER

statusline

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

newbandspinnerprocesstimer
v0.1.0MITupdated 2026-10-12gsporto226/claude-mods/statusline
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · statusline
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM IDLE claude-opus-5-5 97.4k (49%) ▰▰▰▰▰▱▱▱▱▱ app$0.42 31% ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
IDLE claude-opus-5-5 97.4k (49%) ▰▰▰▰▰▱▱▱▱▱ app$0.42 31%
Source 4 files
hooks/register.js 287 lines
1import { 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}
287
components.js 61 lines
1const 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 }
61
theme.js 50 lines
1export 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}
50
types/index.d.ts 76 lines
1/** 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