SLOPSHOPPER

statusbar

Status line on the permission-mode row: model + effort, folder + branch, context, cost and plan limits

newspinnerprocesstimer
v0.1.0no licenseupdated 2026-10-25enzofrasca/dotclaude/mods/statusbar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · statusbar
› 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 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › Opus 5.5 › app › 97k/200k · 49% › $0.42 › 31% · NaNm

Draws

Prompt hint
Opus 5.5 › app › 97k/200k · 49% › $0.42 › 31% · NaNm
README

dotclaude

Minimal Claude Code setup.

The point is a small always-on context, so the window goes to the task instead of the harness.

vs stock Claude Code
toolsniche tools denied
skillsbundled skills off; /code-review and /simplify on demand
connectorsclaude.ai MCP connectors off
status linestatusbar mod on the mode row
WebSearchExa via the exa-search mod

Setup & update

curl -fsSL https://raw.githubusercontent.com/enzofrasca/dotclaude/main/setup | bash

Put EXA_API_KEY in ~/.claude/.env

Source 1 files
hooks/register.ts 299 lines
1// statusbar — status line on the permission-mode row, modeled on the pi footer
2// (~/.pi/agent/extensions/ui/footer.ts).
3//
4//   ⏵⏵ auto mode on · Opus 5.5 medium › agent main* › 31k/1M · 3% › $1.23 › 62% · 2h12m · 7d 12% · 3d3h
5//
6// Draws at the PromptHint site, right after the permission mode.
7// Palette built in OKLCH: one accent hue for identity, neutrals for info,
8// warm colors only for warnings.
9
10const C = {
11  accent: '#bcabf1', text: '#c1c4cc', muted: '#9598a0', dim: '#666970',
12  sep: '#404249', warning: '#e9b452', error: '#f2716a',
13}
14// one violet→magenta ramp: more effort = more chroma
15const EFFORT: Record<string, string> = {
16  low: '#7b778b', medium: '#958ab9', high: '#bb99e3', xhigh: '#e38fdb', max: '#f471aa',
17}
18
19// Read once per load
20let home = ''
21// Columns the permission mode takes before the bar ("  ⏵⏵ auto mode on · ");
22// STATUSBAR_INDICATOR_COLUMNS overrides it, e.g. 29 for bypass permissions
23let indicatorColumns = 23
24// Cached between redraws; refreshed by events and a slow timer, never per frame
25let model = ''
26let effort = ''
27let dir = ''
28let branch = ''
29let dirty = false
30let gitDir = ''
31let usage: any = null
32let syncing: Promise<void> | null = null
33let syncAgain = false
34let wantConfig = false
35let syncingGit: Promise<void> | null = null
36let configChecked = false
37// After /model or /effort the picker may close later than the command returns,
38// so for a while the render itself re-reads what those commands change
39let watchUntil = 0
40let lastPeek = 0
41
42// pct used → default color / amber (≥50) / red (≥80)
43function level(pct: number, base: string) {
44  return pct >= 80 ? C.error : pct >= 50 ? C.warning : base
45}
46
47function k(n: number) {
48  if (n >= 1e6) return `${Math.round(n / 1e5) / 10}M`
49  if (n >= 1e3) return `${Math.round(n / 1e3)}k`
50  return String(n)
51}
52
53// ISO timestamp → time left: 42m / 2h05m / 3d4h
54function left(iso: string) {
55  const s = Math.max(0, Math.floor((Date.parse(iso) - Date.now()) / 1000))
56  const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60)
57  if (d) return `${d}d${h}h`
58  if (h) return `${h}h${String(m).padStart(2, '0')}m`
59  return `${m}m`
60}
61
62// "claude-opus-5-5[1m]" → "Opus 5.5"; names that are already pretty pass through
63function prettyModel(id: string) {
64  const m = /^claude-([a-z]+)-(\d+)-(\d+)/.exec(id)
65  if (!m) return id
66  return `${m[1][0].toUpperCase()}${m[1].slice(1)} ${m[2]}.${m[3]}`
67}
68
69// A colored run of text
70type Seg = [color: string, text: string]
71
72// Terminal cells; every character the bar draws is one cell wide
73function width(segs: Seg[]) {
74  return segs.reduce((n, [, text]) => n + [...text].length, 0)
75}
76
77// The first `room` cells of segs
78function cut(segs: Seg[], room: number): Seg[] {
79  const out: Seg[] = []
80  for (const [color, text] of segs) {
81    if (room <= 0) break
82    const chars = [...text].slice(0, room)
83    out.push([color, chars.join('')])
84    room -= chars.length
85  }
86  return out
87}
88
89async function loadEnv($) {
90  const [h, profile, indicator] = await Promise.all([
91    $.env.get('HOME'), $.env.get('USERPROFILE'), $.env.get('STATUSBAR_INDICATOR_COLUMNS'),
92  ])
93  if (indicator && Number.isFinite(Number(indicator))) indicatorColumns = Math.max(0, Math.floor(Number(indicator)))
94  home = h ?? profile ?? ''
95}
96
97// The cheap, in-process half: model, folder, usage, and effort when asked.
98// Draws as soon as it has them; git comes separately and never holds this up
99async function readSession($) {
100  const withConfig = wantConfig || (!effort && !configChecked)
101  wantConfig = false
102  const [m, cwd, u, rows] = await Promise.all([
103    $.session.model(),
104    $.session.cwd(),
105    $.session.usage(),
106    // /config knows the effort before the first request reports the live one
107    withConfig ? $.config.list().catch(() => null) : null,
108  ])
109  configChecked ||= withConfig
110  model = prettyModel(m)
111  dir = cwd
112  usage = u
113  const row = rows?.find((r) => /effort/i.test(r.key))
114  if (typeof row?.value === 'string' && row.value in EFFORT) effort = row.value
115  $.ui.invalidate('ui.render')
116  if (dir !== gitDir) void syncGit($)
117}
118
119// Coalesces overlapping reads into one; a request that lands mid-read runs
120// again right after, so the last change is never lost. A failed read keeps
121// the last values on screen
122function sync($, withConfig = false) {
123  wantConfig ||= withConfig
124  if (syncing) {
125    syncAgain = true
126    return syncing
127  }
128  syncing = readSession($).catch(() => {}).finally(() => {
129    syncing = null
130    if (syncAgain) {
131      syncAgain = false
132      void sync($)
133    }
134  })
135  return syncing
136}
137
138// One git call for branch + dirty state; redraws only when they changed
139async function readGit($) {
140  const where = dir
141  const git = await $.process.run(['git', '--no-optional-locks', 'status', '--porcelain=v2', '--branch'], { cwd: where, timeoutMs: 2000 })
142    .catch(() => null)
143  const ok = git?.exitCode === 0
144  const nextBranch = ok ? /^# branch\.head (.+)$/m.exec(git.stdout)?.[1] ?? '' : ''
145  const nextDirty = ok && git.stdout.split('\n').some((l) => l && !l.startsWith('#'))
146  gitDir = where
147  if (nextBranch !== branch || nextDirty !== dirty) {
148    branch = nextBranch
149    dirty = nextDirty
150    $.ui.invalidate('ui.render')
151  }
152}
153
154function syncGit($) {
155  syncingGit ??= readGit($).catch(() => {}).finally(() => { syncingGit = null })
156  return syncingGit
157}
158
159// Called from the render: at most every half second, re-reads the model (one
160// in-process call), or all of /config's side while a /model or /effort
161// command may still be settling. Redraws only on a difference
162function peek($) {
163  const now = Date.now()
164  if (now - lastPeek < 500) return
165  lastPeek = now
166  if (now < watchUntil) {
167    void sync($, true)
168    return
169  }
170  void Promise.resolve($.session.model()).then((m) => {
171    const next = prettyModel(m)
172    if (next !== model) {
173      model = next
174      $.ui.invalidate('ui.render')
175    }
176  }).catch(() => {})
177}
178
179export function register(on) {
180  on('session.start', async ($, e, next) => {
181    await loadEnv($)
182    // In the background, so nothing slow holds up the session start
183    void sync($)
184    void syncGit($)
185    // Keeps the reset countdown and the branch current while idle; the
186    // countdown has minute resolution, so once a minute is enough
187    $.clock.every(60_000, () => {
188      $.ui.invalidate('ui.render')
189      void syncGit($)
190      return sync($)
191    })
192    return next(e)
193  })
194
195  // The live effort of a request: also drops it for a model that takes none.
196  // A subagent's request says nothing about the session's own
197  on('turn.step', async function* ($, e, next) {
198    if (e.agentId === undefined) {
199      const live = e.effort === undefined ? '' : String(e.effort)
200      if (live !== effort) {
201        effort = live
202        $.ui.invalidate('ui.render')
203      }
204    }
205    yield* next(e)
206    // The response is in: context and cost moved before the turn ends
207    if (e.agentId === undefined) void sync($)
208  })
209
210  on('turn.complete', async ($, e, next) => {
211    void sync($)
212    void syncGit($)
213    return next(e)
214  })
215
216  on('session.measure', async ($, e, next) => {
217    usage = { context: e.context, rateLimits: e.rateLimits, cost: e.cost }
218    $.ui.invalidate('ui.render')
219    return next(e)
220  })
221
222  // The /config menu's Model and Effort rows: draw the new value once written
223  on('config.set', { key: /model|effort/i }, async ($, e, next) => {
224    const r = await next(e)
225    if (!r.deny) {
226      if (/effort/i.test(e.key) && typeof r.value === 'string' && r.value in EFFORT) effort = r.value
227      void sync($, true)
228    }
229    return r
230  })
231
232  // /model and /effort, typed or picked
233  on('command.run', { command: /^(model|effort|fast)$/ }, async ($, e, next) => {
234    const r = await next(e)
235    watchUntil = Date.now() + 30_000
236    void sync($, true)
237    return r
238  })
239
240  on('ui.render', { component: 'PromptHint' }, async ($, e) => {
241    peek($)
242    const { Text } = $.ui.resolve(e)
243    const dot: Seg = [C.sep, ' · ']
244    const parts: Seg[][] = []
245
246    if (model) parts.push([[C.accent, model], ...(effort ? [[EFFORT[effort] ?? C.dim, ' ' + effort] as Seg] : [])])
247
248    if (dir) {
249      const name = dir === home ? '~' : dir.split(/[\\/]/).filter(Boolean).pop() ?? dir
250      parts.push([
251        [C.muted, name],
252        ...(branch ? [[C.dim, ' ' + branch] as Seg] : []),
253        ...(dirty ? [[C.warning, '*'] as Seg] : []),
254      ])
255    }
256
257    const ctx = usage?.context
258    if (ctx?.window) {
259      if (ctx.percent !== undefined) {
260        const c = level(ctx.percent, C.text)
261        parts.push([[c, `${k(ctx.tokens ?? 0)}/${k(ctx.window)}`], dot, [c, `${ctx.percent}%`]])
262      } else {
263        parts.push([[C.dim, `0/${k(ctx.window)}`]])
264      }
265    }
266
267    if (usage?.cost) parts.push([[C.text, `$${usage.cost.usd.toFixed(2)}`]])
268
269    const limits: Seg[] = []
270    for (const r of usage?.rateLimits ?? []) {
271      if (r.kind !== 'five_hour' && r.kind !== 'seven_day') continue
272      const pct = Math.round(r.percentUsed)
273      if (limits.length) limits.push(dot)
274      limits.push([level(pct, C.muted), `${r.kind === 'seven_day' ? '7d ' : ''}${pct}%`])
275      if (r.resetsAt) limits.push(dot, [C.dim, left(r.resetsAt)])
276    }
277    if (limits.length) parts.push(limits)
278
279    // The permission mode draws first on this row, and the engine neither cuts
280    // nor wraps what follows, so fit the bar to the columns left after it:
281    // whole items drop from the right, and only a first item too wide is cut
282    const room = Math.max(1, (e.viewport?.columns ?? 80) - indicatorColumns)
283    const sep: Seg = [C.sep, ' › ']
284    const row: Seg[] = []
285    let used = 0
286    for (const p of parts) {
287      const item = row.length ? [sep, ...p] : p
288      const w = width(item)
289      if (used + w > room) {
290        if (!row.length) row.push(...cut(item, room))
291        break
292      }
293      row.push(...item)
294      used += w
295    }
296    return Text({ children: row.map(([color, text]) => Text({ color, children: [text] })) })
297  })
298}
299