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

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 | |
|---|---|
| tools | niche tools denied |
| skills | bundled skills off; /code-review and /simplify on demand |
| connectors | claude.ai MCP connectors off |
| status line | statusbar mod on the mode row |
WebSearch | Exa via the exa-search mod |
curl -fsSL https://raw.githubusercontent.com/enzofrasca/dotclaude/main/setup | bash
Put EXA_API_KEY in ~/.claude/.env
hooks/register.ts 299 lines1// 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