Colored band above the prompt: model and effort, git (with its diff and an operation under way), a folder menu, a settings menu, context, 5h/7d usage windows…

hooks/register.tsx 799 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderSurface, SessionRateLimit, SessionUsage } from 'claude-code'
3
4import type { ChangedFile, GitChanges, GitInfo, HostTools } from '../types'
5import {
6 COLD_SEND,
7 HANDOFF_SKILL,
8 cacheDetailsMessage,
9 cacheExpiryMessage,
10 climb,
11 coldChoices,
12 coldCommands,
13 coldPromptMessage,
14 coldQuestion,
15 gauges,
16 heldMessage,
17} from './alerts'
18import { CACHE_ALERT_TOKENS, cacheStatus, cacheTtl, isExpiryDue, recordStep, ttlOverride } from './cache'
19import type { CacheStatus } from './cache'
20import {
21 C,
22 FIVE_HOUR_MS,
23 SEVEN_DAY_MS,
24 accountHasWindows,
25 buildLines,
26 contextAltSegs,
27 projectByPace,
28} from './format'
29import type { Press, RateLimit, Seg } from './format'
30import { effortFromCommand, effortFromModelCommand } from './effort'
31import { parseGitStatus, readOperation } from './git'
32import { parseDiff } from './changes'
33import { actionArgv, menuItems } from './host'
34import type { MenuItem } from './host'
35import { CHOICES, SETTING_ROWS, nextValue, readSettings, toastLevel } from './settings'
36import type { SettingKey, Settings } from './settings'
37
38const git = atom({ plugin: 'statusline-band', key: 'git' } as const, null)
39const alerts = atom({ plugin: 'statusline-band', key: 'alerts' } as const, {})
40const effort = atom({ plugin: 'statusline-band', key: 'effort' } as const, null)
41const hasWindows = atom({ plugin: 'statusline-band', key: 'hasWindows' } as const, false)
42const compactAt = atom({ plugin: 'statusline-band', key: 'compactAt' } as const, null)
43const showCost = atom({ plugin: 'statusline-band', key: 'showCost' } as const, false)
44const showResetAt = atom({ plugin: 'statusline-band', key: 'showResetAt' } as const, false)
45const cache = atom({ plugin: 'statusline-band', key: 'cache' } as const, null)
46const forcedTtl = atom({ plugin: 'statusline-band', key: 'ttlOverride' } as const, null)
47const hasHandoff = atom({ plugin: 'statusline-band', key: 'hasHandoff' } as const, false)
48const heldPrompt = atom({ plugin: 'statusline-band', key: 'heldPrompt' } as const, null)
49const changes = atom({ plugin: 'statusline-band', key: 'changes' } as const, null)
50const host = atom({ plugin: 'statusline-band', key: 'host' } as const, null)
51
52// Kept across sessions: an account once seen with 5h/7d windows shows them from the first draw,
53// before this session's first API response brings them.
54const HAS_WINDOWS_KEY = 'hasWindows'
55
56// The effort the settings give the model: its own modelSettings entry, else the global one.
57async function settingsEffort($: EngineInterface): Promise<string | null> {
58 const [settings, model] = await Promise.all([$.settings.read(), $.session.model()])
59 const perModel = settings.modelSettings as Record<string, { effortLevel?: unknown }> | undefined
60 const level = perModel?.[model]?.effortLevel ?? settings.effortLevel
61 return typeof level === 'string' ? level : null
62}
63
64const WINDOW_MS = { five_hour: FIVE_HOUR_MS, seven_day: SEVEN_DAY_MS } as const
65type WindowKind = keyof typeof WINDOW_MS
66
67// --no-optional-locks: the status every 5 s never holds the index lock a git of the person's needs.
68async function readGit($: EngineInterface, cwd: string): Promise<GitInfo | null> {
69 const run = (...args: string[]) =>
70 $.process.run(['git', '--no-optional-locks', '-C', cwd, ...args], { timeoutMs: 5000 })
71 try {
72 const root = await run('rev-parse', '--show-toplevel', '--absolute-git-dir')
73 if (root.exitCode !== 0) return null
74 const [top = '', gitDir = ''] = root.stdout.trim().split(/\r?\n/)
75 const status = await run('status', '--porcelain=v2', '--branch', '--show-stash')
76 if (status.exitCode !== 0) return null
77 const repo = top.replace(/\\/g, '/').split('/').pop() ?? ''
78 const reader = { exists: (path: string) => $.fs.exists(path), read: (path: string) => $.fs.read(path) as Promise<string> }
79 const under = gitDir ? await readOperation(gitDir, reader).catch(() => null) : null
80 return { repo, ...parseGitStatus(status.stdout), ...(under && { operation: under.operation }), ...(under?.branch && { branch: under.branch }) }
81 } catch {
82 return null
83 }
84}
85
86// The token count at which auto-compaction runs, as /context reckons it (estimated locally, no
87// request sent); null when it is off. It changes with the model, so a /model reads it again.
88async function refreshCompactAt($: EngineInterface): Promise<void> {
89 try {
90 const { breakdown } = (await $.session.usage({ breakdown: 'summary' })).context
91 const threshold = breakdown?.isAutoCompactEnabled ? (breakdown.autoCompactThreshold ?? null) : null
92 if ((await read($, compactAt)) !== threshold) await update($, compactAt, () => threshold)
93 } catch {
94 // The band goes on without the mark.
95 }
96}
97
98// What may change the cache's TTL or the toasts' advice: the settings, the environment, and
99// whether ai-memory's handoff skill is installed. Read with the compaction mark, every minute.
100async function refreshCacheConfig($: EngineInterface): Promise<void> {
101 try {
102 const [settings, force5m, envTtl, enable1h, commands] = await Promise.all([
103 $.settings.read(),
104 $.env.get('FORCE_PROMPT_CACHING_5M'),
105 $.env.get('CLAUDE_CODE_PROMPT_CACHE_TTL'),
106 $.env.get('ENABLE_PROMPT_CACHING_1H'),
107 $.command.list().catch(() => []),
108 ])
109 const ttl = ttlOverride({ force5m, envTtl, settingTtl: settings.promptCacheTtl, enable1h })
110 if ((await read($, forcedTtl)) !== ttl) await update($, forcedTtl, () => ttl)
111 const isHandoff = commands.some(c => c.name === HANDOFF_SKILL)
112 if ((await read($, hasHandoff)) !== isHandoff) await update($, hasHandoff, () => isHandoff)
113 } catch {
114 // The account's default TTL and the plain advice stand.
115 }
116}
117
118// The cache as of now: its TTL from the settings, else from the account (a window used up means
119// usage credits, on the shorter TTL).
120async function readCache($: EngineInterface, usage: SessionUsage, nowMs: number): Promise<CacheStatus | undefined> {
121 const [mark, override, isKnown, model] = await Promise.all([read($, cache), read($, forcedTtl), read($, hasWindows), $.session.model()])
122 const isPastWindow = usage.rateLimits.some(l => (l.percentUsed ?? 0) >= 100)
123 const ttl = cacheTtl(override, accountHasWindows(isKnown, usage.rateLimits.length, mark !== null), isPastWindow)
124 return cacheStatus(mark, ttl, model, nowMs)
125}
126
127// After a held prompt's choice: its commands, then the prompt back in the box. A compaction or a
128// new conversation leaves no cache to count down, so the mark goes with it.
129async function afterHold($: EngineInterface, text: string, commands: readonly string[]): Promise<void> {
130 try {
131 for (const command of commands) await $.command.run({ command })
132 if (commands.length > 0) await update($, cache, () => null)
133 } catch {
134 // The prompt still goes back to the box.
135 }
136 await $.prompt.fill({ text }).catch(() => undefined)
137}
138
139// A click on the cache's countdown toasts its details.
140async function showCacheDetails($: EngineInterface): Promise<void> {
141 try {
142 const [usage, nowMs] = await Promise.all([$.session.usage(), $.clock.now()])
143 const status = await readCache($, usage, nowMs)
144 if (status) $.ui.toast(cacheDetailsMessage(status, nowMs), { timeoutMs: 15_000 })
145 } catch {
146 // Nothing to show.
147 }
148}
149
150function rateLimit(kind: WindowKind, limits: readonly SessionRateLimit[], nowMs: number): RateLimit | undefined {
151 const limit = limits.find(l => l.kind === kind)
152 if (!limit) return undefined
153 const resetsAtMs = limit.resetsAt ? Date.parse(limit.resetsAt) : undefined
154 const etaMs = projectByPace(limit.percentUsed, Number.isNaN(resetsAtMs) ? undefined : resetsAtMs, WINDOW_MS[kind], nowMs)
155 return { percentUsed: limit.percentUsed, resetsAt: limit.resetsAt, etaMs }
156}
157
158// After a /model: the effort its output names; none named means no effort only when the model
159// changed (the same model picked again keeps its level); no output reaching the mod means the
160// settings' effort, since /model saves its pick as the default. The model name redraws too.
161async function afterModelCommand($: EngineInterface, output: string | undefined, before: string): Promise<void> {
162 const named = effortFromModelCommand(output)
163 const isSameModel = (await $.session.model()) === before
164 const level =
165 output === undefined ? await settingsEffort($) : named === null && isSameModel ? undefined : named
166 if (level !== undefined && (await read($, effort)) !== level) await update($, effort, () => level)
167 await refreshCompactAt($)
168 $.ui.invalidate('ui.render')
169}
170
171// The folder's menu and the changes pane: what a click on the folder's name and on a
172// repository's files changed opens.
173
174const MENU_PANE = 'folder-menu'
175const CHANGES_PANE = 'git-changes'
176const UNTRACKED_SHOWN = 50
177
178const STATUS_COLOR: Record<ChangedFile['status'], string> = { A: C.GR, D: C.RD, M: C.YL, R: C.CY }
179
180// Copies the session's folder to the clipboard of the surface clicked, and says so in a toast.
181async function copyPath($: EngineInterface, surface?: RenderSurface): Promise<void> {
182 const path = await $.session.cwd()
183 const copied = await $.ui.copy({ text: path, ...(surface && { surface }) })
184 $.ui.toast(copied.isCopied ? `Path copied: ${path}` : `Could not copy the path (${copied.reason})`, {
185 timeoutMs: 5000,
186 })
187}
188
189// The machine's platform and which of the menu's programs it has, looked up once.
190async function findHost($: EngineInterface): Promise<HostTools> {
191 const known = await read($, host)
192 if (known) return known
193 const isWindows = (await $.env.get('OS')) === 'Windows_NT'
194 const has = async (name: string) => {
195 const argv = isWindows ? ['where.exe', name] : ['sh', '-c', `command -v ${name}`]
196 const found = await $.process.run(argv, { timeoutMs: 5000 }).catch(() => undefined)
197 return found?.exitCode === 0
198 }
199 const uname = isWindows ? '' : await $.process.run(['uname', '-s'], { timeoutMs: 5000 }).then(r => r.stdout.trim(), () => '')
200 const platform: HostTools['platform'] = isWindows ? 'windows' : uname === 'Darwin' ? 'mac' : 'linux'
201 const [hasCode, hasWt] = await Promise.all([has('code'), isWindows ? has('wt') : Promise.resolve(false)])
202 const found: HostTools = { platform, hasCode, hasWt }
203 await update($, host, () => found)
204 return found
205}
206
207async function runAction($: EngineInterface, item: MenuItem, surface?: RenderSurface): Promise<void> {
208 await $.ui.close({ id: MENU_PANE }).catch(() => undefined)
209 if (item.action === 'copy') return copyPath($, surface)
210 try {
211 const [tools, cwd] = await Promise.all([findHost($), $.session.cwd()])
212 const run = actionArgv(item.action, tools, cwd)
213 if (!run) return
214 const result = await $.process.run(run.argv, { timeoutMs: 15_000, ...(run.cwd && { cwd: run.cwd }) })
215 // Explorer exits 1 even when it opened the folder.
216 const isExplorer = item.action === 'files' && tools.platform === 'windows'
217 if (result.exitCode !== 0 && !isExplorer) {
218 $.ui.toast(`${item.label} failed: ${result.stderr.trim() || `exit code ${result.exitCode}`}`, { timeoutMs: 8000 })
219 }
220 } catch (error) {
221 $.ui.toast(`${item.label} failed: ${String(error)}`, { timeoutMs: 8000 })
222 }
223}
224
225// A click on the folder's name: its menu in a pane, keyed 1-4; where the surface places no pane,
226// the same choices in a dialog.
227async function openFolderMenu($: EngineInterface): Promise<void> {
228 try {
229 const items = menuItems(await findHost($))
230 const opened = await $.ui.open({ id: MENU_PANE, title: 'Folder', focus: true, closeOnEscape: true, rows: items.length + 1 })
231 if (opened.isPlaced) return
232 await $.ui.close({ id: MENU_PANE }).catch(() => undefined)
233 const choice = await $.ui.ask(`What to do with ${await $.session.cwd()}?`, {
234 header: 'Folder',
235 options: items.map(item => item.label),
236 })
237 const item = items.find(one => one.label === choice)
238 if (item) await runAction($, item)
239 } catch {
240 // Dismissed, or nothing could open: the band stays as it was.
241 }
242}
243
244// The working tree against HEAD (against nothing before the first commit), and the untracked files.
245async function refreshChanges($: EngineInterface): Promise<void> {
246 const cwd = await $.session.cwd()
247 const git = (...args: string[]) =>
248 $.process.run(['git', '-c', 'core.quotePath=false', '--no-optional-locks', '-C', cwd, ...args], { timeoutMs: 10_000 })
249 let diff = await git('diff', 'HEAD', '--no-color', '--no-ext-diff')
250 if (diff.exitCode !== 0) diff = await git('diff', '--no-color', '--no-ext-diff')
251 const others = await git('ls-files', '--others', '--exclude-standard')
252 const { files, isCut } = parseDiff(diff.exitCode === 0 ? diff.stdout : '')
253 const untracked = others.exitCode === 0 ? others.stdout.split(/\r?\n/).filter(Boolean) : []
254 const next: GitChanges = { files, untracked, isCut }
255 if (JSON.stringify(next) !== JSON.stringify(await read($, changes))) await update($, changes, () => next)
256}
257
258// A click on the files changed: their diff in a pane.
259async function openChanges($: EngineInterface): Promise<void> {
260 try {
261 await refreshChanges($)
262 const opened = await $.ui.open({ id: CHANGES_PANE, title: 'Changes', focus: true, closeOnEscape: true })
263 if (!opened.isPlaced) $.ui.toast('The changes pane cannot open on this surface; git diff shows them', { timeoutMs: 8000 })
264 } catch (error) {
265 $.ui.toast(`Could not read the changes: ${String(error)}`, { timeoutMs: 8000 })
266 }
267}
268
269// While the changes pane is open it follows the working tree, on the band's git refresh.
270async function refreshOpenChanges($: EngineInterface): Promise<void> {
271 const panes = await $.ui.panes().catch(() => [])
272 if (panes.some(pane => pane.id === CHANGES_PANE)) await refreshChanges($).catch(() => undefined)
273}
274
275// The band's settings: a click on the diamond opens them in a pane, one row a setting, keyed
276// 1-4; a press moves the row to its next value. The change goes through /config, which reloads
277// the module with it, so the band and the pane redraw from the new options.
278
279const SETTINGS_PANE = 'band-settings'
280
281// The /config row of a field: its key names the plugin as the loader does (`statusline-band@<marketplace>`
282// installed, `statusline-band` from a folder), so it is looked up, never spelled.
283async function settingRow($: EngineInterface, key: SettingKey): Promise<string | undefined> {
284 const rows = await $.config.list()
285 const ours = rows.filter(row => row.provider.plugin === 'statusline-band' || row.key.startsWith('statusline-band'))
286 return ours.find(row => row.key.endsWith(`.${key}`))?.key
287}
288
289async function changeSetting($: EngineInterface, key: SettingKey, value: string): Promise<void> {
290 try {
291 const row = await settingRow($, key)
292 if (row === undefined) {
293 $.ui.toast(`Could not change the setting: /config has no row for ${key}`, { timeoutMs: 8000 })
294 return
295 }
296 const result = await $.config.set({ key: row, value })
297 if (result.deny !== undefined) $.ui.toast(`Could not change the setting: ${result.deny}`, { timeoutMs: 8000 })
298 } catch (error) {
299 $.ui.toast(`Could not change the setting: ${String(error)}`, { timeoutMs: 8000 })
300 }
301}
302
303// Where the surface places no pane, the same in two dialogs: which setting, then its value.
304async function openSettings($: EngineInterface, settings: Settings): Promise<void> {
305 try {
306 const opened = await $.ui.open({ id: SETTINGS_PANE, title: 'Band settings', focus: true, closeOnEscape: true, rows: SETTING_ROWS.length })
307 if (opened.isPlaced) return
308 await $.ui.close({ id: SETTINGS_PANE }).catch(() => undefined)
309 const rowLabel = (row: (typeof SETTING_ROWS)[number]) => `${row.label}: ${settings[row.key]}`
310 const picked = await $.ui.ask('Which band setting to change?', { header: 'Band', options: SETTING_ROWS.map(rowLabel) })
311 const row = SETTING_ROWS.find(one => rowLabel(one) === picked)
312 if (!row) return
313 const value = await $.ui.ask(`${row.label}: which value?`, { header: 'Band', options: [...CHOICES[row.key]] })
314 if (value && value !== settings[row.key]) await changeSetting($, row.key, value)
315 } catch {
316 // Dismissed, or nothing could open: the settings stay.
317 }
318}
319
320// Shows Claude Code's own /context breakdown, as typing /context does.
321async function openContext($: EngineInterface): Promise<void> {
322 try {
323 await $.command.run({ command: 'context' })
324 } catch (error) {
325 $.ui.toast(`/context failed: ${String(error)}`, { timeoutMs: 8000 })
326 }
327}
328
329// Opens Claude Code's own /model picker, as typing /model does; it runs once the session is idle.
330async function openModelPicker($: EngineInterface): Promise<void> {
331 try {
332 const before = await $.session.model()
333 const result = await $.command.run({ command: 'model' })
334 // This plugin's own command.run hooks skip its own runs, so the band is updated here.
335 await afterModelCommand($, result.text, before)
336 } catch (error) {
337 $.ui.toast(`/model failed: ${String(error)}`, { timeoutMs: 8000 })
338 }
339}
340
341export const register: Register = (on, options) => {
342 // A change in /config reloads the module with the new options.
343 const settings = readSettings(options)
344
345 on('session.start', async ($, e, next) => {
346 const result = await next(e)
347
348 // Until the first model request says which effort it carries.
349 if ((await read($, effort)) === null) {
350 const fromSettings = await settingsEffort($).catch(() => null)
351 await update($, effort, () => fromSettings)
352 }
353
354 if ((await $.store.get(HAS_WINDOWS_KEY).catch(() => undefined)) === true) await update($, hasWindows, () => true)
355
356 // git runs here on a timer, never while drawing; with git off, never.
357 const refreshGit = async () => {
358 const info = settings.git === 'off' ? null : await readGit($, await $.session.cwd())
359 const current = await read($, git)
360 if (JSON.stringify(info) !== JSON.stringify(current)) await update($, git, () => info)
361 if (settings.git === 'full') await refreshOpenChanges($)
362 }
363
364 // Toasts once per climb past a threshold (context, 5h/7d pace, projection).
365 const checkAlerts = async () => {
366 const [usage, nowMs, previous, isKnown, mark] = await Promise.all([
367 $.session.usage(),
368 $.clock.now(),
369 read($, alerts),
370 read($, hasWindows),
371 read($, cache),
372 ])
373 // A main-thread response marks the cache: the sign one came back this session.
374 const isSubscription = accountHasWindows(isKnown, usage.rateLimits.length, mark !== null)
375 if (isSubscription !== isKnown) {
376 await update($, hasWindows, () => isSubscription)
377 await $.store.set(HAS_WINDOWS_KEY, isSubscription)
378 }
379 const threshold = await read($, compactAt)
380 const { toasts, levels } = climb(
381 previous,
382 gauges({
383 contextPercent: usage.context.percent,
384 compactAtPercent: threshold === null ? undefined : (threshold / usage.context.window) * 100,
385 fiveHour: rateLimit('five_hour', usage.rateLimits, nowMs),
386 sevenDay: rateLimit('seven_day', usage.rateLimits, nowMs),
387 nowMs,
388 }),
389 toastLevel(settings.toasts),
390 )
391 for (const text of toasts) $.ui.toast(text, { timeoutMs: 8000 })
392 if (JSON.stringify(levels) !== JSON.stringify(previous)) await update($, alerts, () => levels)
393
394 // Once per response, a large prefix toasts a few minutes before its cache lapses.
395 const status = await readCache($, usage, nowMs)
396 if (settings.cache !== 'off' && status && isExpiryDue(status) && (await read($, cache))?.isExpiryWarned === false) {
397 await update($, cache, mark => (mark ? { ...mark, isExpiryWarned: true } : mark))
398 $.ui.toast(cacheExpiryMessage(status.remainingMs, status.tokens, await read($, hasHandoff)), { timeoutMs: 60_000 })
399 }
400 }
401
402 await refreshGit()
403 await refreshCompactAt($)
404 await refreshCacheConfig($)
405 $.clock.every(60_000, () => void Promise.all([refreshCompactAt($), refreshCacheConfig($)]))
406 $.clock.every(5000, () => void Promise.all([refreshGit(), checkAlerts()]).catch(() => {}))
407
408 // The reset and cache countdowns move with the clock.
409 $.clock.every(1000, () => $.ui.invalidate('ui.render'))
410
411 return result
412 })
413
414 // Reads the effort each main-thread model request carries (none for a model without effort),
415 // and what its response tells of the prompt cache; the request goes on unchanged.
416 on('turn.step', async function* ($, e, next) {
417 if (e.agentId !== undefined) return yield* next(e)
418 try {
419 const level = e.effort === undefined ? null : String(e.effort)
420 if ((await read($, effort)) !== level) await update($, effort, () => level)
421 } catch {
422 // The request is sent whatever happened to the band.
423 }
424 const result = yield* next(e)
425 try {
426 const usage = result.usage
427 if (usage) {
428 const nowMs = await $.clock.now()
429 await update($, cache, mark => recordStep(mark, usage, nowMs))
430 }
431 } catch {
432 // The response is returned whatever happened to the band.
433 }
434 return result
435 })
436
437 // A prompt typed while idle over a cold cache of a large prefix is held: the person picks
438 // sending it, compacting first or a new conversation; any but sending hands it back to the
439 // box. A prompt typed mid-turn rides a warm cache. Images cannot go back to the box, and one
440 // handed back unsent goes the second time (so a dialog that cannot open never traps it): those,
441 // and prompts not typed by the person, go out with a toast of what they re-cache.
442 // With the cache set to warn, every such prompt goes with the toast; set off, nothing is said.
443 on('prompt.submit', async ($, e, next) => {
444 if (e.turnId !== undefined || settings.cache === 'off') return next(e)
445 let choice: string | undefined
446 try {
447 const status = await readCache($, await $.session.usage(), await $.clock.now())
448 if (!status || status.remainingMs > 0 || status.tokens < CACHE_ALERT_TOKENS) return next(e)
449 const isHandoff = await read($, hasHandoff)
450 if (settings.cache === 'warn') {
451 $.ui.toast(coldPromptMessage(status.tokens, isHandoff), { timeoutMs: 15_000 })
452 return next(e)
453 }
454 const held = await read($, heldPrompt)
455 if (held !== null) await update($, heldPrompt, () => null)
456 if (e.origin.kind !== 'composer' || (e.attachments?.length ?? 0) > 0 || held === e.text) {
457 $.ui.toast(coldPromptMessage(status.tokens, isHandoff), { timeoutMs: 15_000 })
458 return next(e)
459 }
460 choice = await $.ui
461 .ask(coldQuestion(status.tokens), { header: 'Cold cache', options: coldChoices(isHandoff) })
462 .catch(() => undefined)
463 if (choice === COLD_SEND) return next(e)
464 } catch {
465 // The prompt is sent whatever happened to the band.
466 return next(e)
467 }
468 const commands = choice === undefined ? undefined : coldCommands(choice)
469 if (commands === undefined) await update($, heldPrompt, () => e.text).catch(() => undefined)
470 // A command run from inside the prompt's own hook is refused: they run once it returns.
471 const text = e.text
472 $.clock.after(0, () => void afterHold($, text, commands ?? []))
473 return { drop: heldMessage(commands === undefined ? undefined : choice) }
474 })
475
476 // A compaction or a /clear leaves the next prompt a small prefix to cache: the old mark would
477 // count it cold at its old size.
478 on('session.compact', async ($, e, next) => {
479 const result = await next(e)
480 if (e.agentId === undefined && e.trigger !== 'precompute' && 'messages' in result) {
481 await update($, cache, () => null).catch(() => undefined)
482 }
483 return result
484 })
485 on('command.run', { command: 'clear' }, async ($, e, next) => {
486 const result = await next(e)
487 await update($, cache, () => null).catch(() => undefined)
488 return result
489 })
490
491 // A typed /effort shows on the band as soon as it has run.
492 on('command.run', { command: 'effort' }, async ($, e, next) => {
493 const result = await next(e)
494 try {
495 const level = effortFromCommand(e.args, result.text)
496 if (level && (await read($, effort)) !== level) await update($, effort, () => level)
497 } catch {
498 // The command's output is returned whatever happened to the band.
499 }
500 return result
501 })
502
503 // Clicks on the band's Clients: the model text opens the /model picker, ctx opens /context,
504 // the folder copies its path, the windows swap for the cost and a reset for its clock time.
505 on('ui.message', ($, e, next) => {
506 const data = e.data as { open?: unknown; action?: unknown } | null
507 if (e.element === 'settings-button' && data?.action === 'band-settings') void openSettings($, settings)
508 if (e.element === 'model-button' && data?.open === 'model') void openModelPicker($)
509 if (e.element === 'context-label' && data?.open === 'context') void openContext($)
510 if (e.element === 'folder-button' && data?.action === 'copy-path') void copyPath($, e.surface)
511 if (e.element === 'folder-button' && data?.action === 'folder-menu') void openFolderMenu($)
512 if (e.element === 'changes-button' && data?.action === 'git-changes') void openChanges($)
513 if (data?.action === 'toggle-cost') void update($, showCost, isShown => !isShown)
514 if (data?.action === 'toggle-reset') void update($, showResetAt, isShown => !isShown)
515 if (data?.action === 'cache-details') void showCacheDetails($)
516 return next(e)
517 })
518
519 // A typed /model shows its model and effort on the band as soon as it has run.
520 on('command.run', { command: 'model' }, async ($, e, next) => {
521 const before = await $.session.model().catch(() => '')
522 const result = await next(e)
523 try {
524 await afterModelCommand($, result.text, before)
525 } catch {
526 // The command's output is returned whatever happened to the band.
527 }
528 return result
529 })
530
531 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
532 if (e.props.hasSurvey) return next(e)
533
534 const [model, cwd, usage, nowMs, gitInfo, effortLevel, isKnownSubscription, threshold, isCostShown, isResetAtShown, cacheMark] =
535 await Promise.all([
536 $.session.model(),
537 $.session.cwd(),
538 $.session.usage(),
539 $.clock.now(),
540 read($, git),
541 read($, effort),
542 read($, hasWindows),
543 read($, compactAt),
544 read($, showCost),
545 read($, showResetAt),
546 read($, cache),
547 ])
548
549 const cacheNow = await readCache($, usage, nowMs).catch(() => undefined)
550 const lines = buildLines({
551 model,
552 effort: effortLevel,
553 cwd,
554 git: gitInfo,
555 contextPercent: usage.context.percent,
556 compactAtPercent: threshold === null ? undefined : (threshold / usage.context.window) * 100,
557 costUsd: usage.cost?.usd,
558 hasWindows: accountHasWindows(isKnownSubscription, usage.rateLimits.length, cacheMark !== null),
559 fiveHour: rateLimit('five_hour', usage.rateLimits, nowMs),
560 sevenDay: rateLimit('seven_day', usage.rateLimits, nowMs),
561 showCost: isCostShown,
562 showResetAt: isResetAtShown,
563 cache: cacheNow,
564 settings,
565 nowMs,
566 })
567
568 const elements = $.ui.resolve(e)
569 const { Box, Button, Text } = elements
570
571 const styled = (segs: Seg[]) =>
572 segs.map(s => (
573 <Text color={s.color} bold={s.bold} dimColor={s.dim}>
574 {s.text}
575 </Text>
576 ))
577
578 // The first element of a kind takes its name as key, later ones a count: usage-button, usage-button-2.
579 const keyCounts: Record<string, number> = {}
580 const keyOf = (name: string) => {
581 const n = (keyCounts[name] = (keyCounts[name] ?? 0) + 1)
582 return n === 1 ? name : `${name}-${n}`
583 }
584
585 // Plain runs of a line are Text; a pressable segment is a Button with no chrome.
586 const row = (segs: Seg[]) => {
587 const parts: JSX.Element[] = []
588 let run: Seg[] = []
589 const flush = () => {
590 if (run.length > 0) parts.push(<Text wrap="truncate-end">{styled(run)}</Text>)
591 run = []
592 }
593 // A Client's props are plain JSON: a style a segment lacks is left out, not undefined.
594 const toParts = (group: Seg[]) =>
595 group.map(({ text, color, bold, dim, underline }) => ({
596 text,
597 ...(color !== undefined && { color }),
598 ...(bold !== undefined && { bold }),
599 ...(dim !== undefined && { dim }),
600 ...(underline !== undefined && { underline }),
601 }))
602
603 // Consecutive segments pressed alike make one element. Terminal and desktop draw it in its
604 // colors through a Client; elsewhere the model and folder are plain Buttons, the windows Text.
605 const pressable = (press: Press, group: Seg[]) => {
606 const label = group.map(seg => seg.text).join('')
607 // Only the terminal's /model opens a picker; elsewhere it prints its usage, and the
608 // desktop app has a model picker of its own, so the model stays plain colored text.
609 if (press === 'model' && e.surface !== 'terminal') return <Text wrap="truncate-end">{styled(group)}</Text>
610 if ('Client' in elements) {
611 const { Client } = elements
612 if (press === 'model') {
613 return (
614 <Client
615 key="model-button"
616 module="./command-button.tsx"
617 props={{ label, command: 'model', color: group[0]?.color ?? 'cyan', bold: true }}
618 />
619 )
620 }
621 if (press === 'context-label') {
622 return <Client key="context-label" module="./command-button.tsx" props={{ label, command: 'context' }} />
623 }
624 // The windows and their resets are split by the resets into several elements, so they
625 // swap through the mod's state, all at once; each element only posts its click.
626 if (press === 'settings' || press === 'usage' || press === 'reset' || press === 'cache' || press === 'changes') {
627 const names = {
628 settings: ['settings-button', 'band-settings'],
629 usage: ['usage-button', 'toggle-cost'],
630 reset: ['reset-button', 'toggle-reset'],
631 cache: ['cache-button', 'cache-details'],
632 changes: ['changes-button', 'git-changes'],
633 } as const
634 const [name, action] = names[press]
635 const key = keyOf(name)
636 return (
637 <Client
638 key={key}
639 module="./swap-button.tsx"
640 props={{ parts: toParts(group), altParts: toParts(group), trigger: 'none', action, isHoverInverse: true }}
641 />
642 )
643 }
644 // Only the folder's icon (its first two columns) shows the path and copies it. On the
645 // desktop the longer path reflows the band under the pointer, which then leaves and
646 // re-enters the folder in a loop; there a click copies and the toast shows the path.
647 const folderTrigger: 'hover' | 'none' = e.surface === 'terminal' ? 'hover' : 'none'
648 const swap =
649 press === 'folder'
650 ? { key: 'folder-button', trigger: folderTrigger, alt: [{ text: `📂 ${cwd}`, color: 'white', underline: true }], action: 'copy-path' }
651 : {
652 key: 'context-button',
653 trigger: 'click' as const,
654 alt: contextAltSegs(usage.context.percent, usage.context.tokens, usage.context.window),
655 }
656 return (
657 <Client
658 key={swap.key}
659 module="./swap-button.tsx"
660 props={{
661 parts: toParts(group),
662 altParts: toParts(swap.alt),
663 trigger: swap.trigger,
664 ...('action' in swap && { action: swap.action }),
665 ...(press === 'context' && { isHoverInverse: true }),
666 ...(press === 'folder' && { hotColumns: 2, restAction: 'folder-menu' }),
667 }}
668 />
669 )
670 }
671 if (press === 'settings') return <Button key="band-settings" plain label={label} onPress={() => void openSettings($, settings)} />
672 if (press === 'model') return <Button key="open-model" plain label={label} onPress={() => void openModelPicker($)} />
673 if (press === 'context-label') return <Button key="open-context" plain label={label} onPress={() => void openContext($)} />
674 if (press === 'cache') return <Button key="cache-details" plain label={label} onPress={() => void showCacheDetails($)} />
675 if (press === 'changes') return <Button key="git-changes" plain label={label} onPress={() => void openChanges($)} />
676 // One element for the whole folder: its menu, which copies the path too.
677 if (press === 'folder') return <Button key="folder-menu" plain label={label} onPress={() => void openFolderMenu($)} />
678 return <Text wrap="truncate-end">{styled(group)}</Text>
679 }
680
681 for (let i = 0; i < segs.length; ) {
682 const press = segs[i]?.press
683 if (press === undefined) {
684 run.push(segs[i] as Seg)
685 i += 1
686 continue
687 }
688 flush()
689 let j = i
690 while (j < segs.length && segs[j]?.press === press) j += 1
691 parts.push(pressable(press, segs.slice(i, j)))
692 i = j
693 }
694 flush()
695 return <Box flexDirection="row">{parts}</Box>
696 }
697
698 return (
699 <Box flexDirection="column" marginTop={1}>
700 {lines.map(row)}
701 </Box>
702 )
703 })
704
705
706 on('ui.render', { component: 'Pane', requestId: MENU_PANE }, async ($, e) => {
707 const { Box, Button, Text } = $.ui.resolve(e)
708 const [cwd, tools] = await Promise.all([$.session.cwd(), read($, host)])
709 const items = tools ? menuItems(tools) : []
710 return (
711 <Box flexDirection="column">
712 <Text dimColor wrap="truncate-middle">
713 {cwd}
714 </Text>
715 {items.map((item, i) => (
716 <Button key={item.action} hotkey={String(i + 1)} label={item.label} onPress={press => void runAction($, item, press.surface)} />
717 ))}
718 </Box>
719 )
720 })
721
722 on('ui.render', { component: 'Pane', requestId: SETTINGS_PANE }, async ($, e) => {
723 const { Box, Button } = $.ui.resolve(e)
724 const width = Math.max(...SETTING_ROWS.map(row => row.label.length)) + 2
725 // The terminal lines the values up in a column; the desktop collapses the spaces, so a colon parts them.
726 const label = (row: (typeof SETTING_ROWS)[number]) =>
727 e.surface === 'terminal' ? `${row.label.padEnd(width)}${settings[row.key]}` : `${row.label}: ${settings[row.key]}`
728 return (
729 <Box flexDirection="column">
730 {SETTING_ROWS.map((row, i) => (
731 <Button
732 key={row.key}
733 hotkey={String(i + 1)}
734 label={label(row)}
735 onPress={() => void changeSetting($, row.key, nextValue(row.key, settings[row.key]))}
736 />
737 ))}
738 </Box>
739 )
740 })
741
742 on('ui.render', { component: 'Pane', requestId: CHANGES_PANE }, async ($, e) => {
743 const elements = $.ui.resolve(e)
744 const { Box, Text } = elements
745 const data = await read($, changes)
746 if (data === null) return <Text dimColor>Reading the changes…</Text>
747 const { files, untracked, isCut } = data
748 if (files.length === 0 && untracked.length === 0) return <Text dimColor>No changes against HEAD.</Text>
749
750 const added = files.reduce((sum, file) => sum + file.added, 0)
751 const removed = files.reduce((sum, file) => sum + file.removed, 0)
752 const hunks = (file: ChangedFile) => {
753 if (file.isBinary) return <Text dimColor>binary file</Text>
754 if (!file.hunks) return isCut && file.added + file.removed > 0 ? <Text dimColor>past what the pane shows</Text> : undefined
755 if (!('Code' in elements)) return <Text>{file.hunks}</Text>
756 const { Code } = elements
757 return <Code source={file.hunks} format="diff" path={file.path} />
758 }
759 return (
760 <Box flexDirection="column">
761 <Text>
762 <Text bold>{`${files.length} file${files.length === 1 ? '' : 's'} changed`}</Text>
763 <Text color={C.GR}>{` +${added}`}</Text>
764 <Text color={C.RD}>{` −${removed}`}</Text>
765 {untracked.length > 0 && <Text dimColor>{` · ${untracked.length} untracked`}</Text>}
766 </Text>
767 {files.map(file => (
768 <Box key={`file:${file.path}`} flexDirection="column" marginTop={1}>
769 <Text>
770 <Text color={STATUS_COLOR[file.status]} bold>
771 {`${file.status} `}
772 </Text>
773 <Text bold>{file.path}</Text>
774 <Text color={C.GR}>{` +${file.added}`}</Text>
775 <Text color={C.RD}>{` −${file.removed}`}</Text>
776 </Text>
777 {hunks(file)}
778 </Box>
779 ))}
780 {untracked.length > 0 && (
781 <Box flexDirection="column" marginTop={1}>
782 <Text bold>Untracked</Text>
783 {untracked.slice(0, UNTRACKED_SHOWN).map(path => (
784 <Text color={C.OR}>{path}</Text>
785 ))}
786 {untracked.length > UNTRACKED_SHOWN && <Text dimColor>{`… ${untracked.length - UNTRACKED_SHOWN} more`}</Text>}
787 </Box>
788 )}
789 {isCut && (
790 <Box marginTop={1}>
791 <Text dimColor>The diff is cut short here; git diff shows the rest.</Text>
792 </Box>
793 )}
794 </Box>
795 )
796 })
797
798}
799hooks/alerts.ts 131 lines1// Which gauges are past a threshold, and the toast each raises when it climbs to that level.
2
3import { C, FIVE_HOUR_MS, SEVEN_DAY_MS, fmtSpan, fmtTokens, parseReset, windowColor } from './format'
4import type { RateLimit } from './format'
5import type { CacheStatus } from './cache'
6
7export type Gauge = { level: number; message: string }
8
9export type AlertInput = {
10 contextPercent?: number
11 compactAtPercent?: number
12 fiveHour?: RateLimit
13 sevenDay?: RateLimit
14 nowMs: number
15}
16
17function windowGauges(key: string, label: string, rl: RateLimit | undefined, windowMs: number, nowMs: number): Record<string, Gauge> {
18 if (rl?.percentUsed === undefined) return {}
19 const pct = rl.percentUsed.toFixed(0)
20 const resetsAtMs = parseReset(rl.resetsAt)
21 const reset = resetsAtMs !== undefined && resetsAtMs > nowMs ? `, resets in ${fmtSpan(resetsAtMs - nowMs)}` : ''
22 const color = windowColor(rl, windowMs, nowMs)
23 const level = color === C.RD ? 2 : color === C.OR ? 1 : 0
24 const gauges: Record<string, Gauge> = {
25 [key]: {
26 level,
27 message: level === 2 ? `${label} window at ${pct}%${reset}` : `${label} window ahead of pace: ${pct}% used${reset}`,
28 },
29 }
30 if (rl.etaMs !== undefined) {
31 gauges[`${key}.eta`] = { level: 1, message: `${label} window: at this pace it hits 100% in ${fmtSpan(rl.etaMs)}${reset}` }
32 }
33 return gauges
34}
35
36export function gauges(s: AlertInput): Record<string, Gauge> {
37 const ctx = Math.round(s.contextPercent ?? 0)
38 const compactAt = s.compactAtPercent === undefined ? '' : ` (auto-compacts at ${Math.round(s.compactAtPercent)}%)`
39 return {
40 context: {
41 level: ctx >= 85 ? 2 : ctx >= 70 ? 1 : 0,
42 message: `Context at ${ctx}%${compactAt}`,
43 },
44 ...windowGauges('five_hour', '5h', s.fiveHour, FIVE_HOUR_MS, s.nowMs),
45 ...windowGauges('seven_day', '7d', s.sevenDay, SEVEN_DAY_MS, s.nowMs),
46 }
47}
48
49// The skill ai-memory installs; with it, a session can wrap up into a handoff and /clear.
50export const HANDOFF_SKILL = 'ai-memory-handoff'
51
52// Before the cache lapses: wrapping up now costs a turn over a warm cache, later a rebuild.
53export function cacheExpiryMessage(remainingMs: number, tokens: number, hasHandoff: boolean): string {
54 const wrapUp = hasHandoff
55 ? `Done for now? /${HANDOFF_SKILL} while it is warm, then /clear`
56 : 'Done for now? /compact while it is warm, or /clear for a new task'
57 const minutes = Math.max(1, Math.ceil(remainingMs / 60_000))
58 return `Prompt cache lapses in ${minutes}m: the next prompt after that re-caches ${fmtTokens(tokens)} tokens. ${wrapUp}`
59}
60
61// A prompt that went out over a cold cache: what it cost, and how to spare the next one.
62export function coldPromptMessage(tokens: number, hasHandoff: boolean): string {
63 const next = hasHandoff
64 ? `Next time, /${HANDOFF_SKILL} before it lapses lets you /clear and resume`
65 : 'Next time, /compact before it lapses, or /clear for a new task'
66 return `Prompt cache was cold: this prompt re-caches ${fmtTokens(tokens)} tokens. ${next}`
67}
68
69// A prompt typed over a cold cache is held and this asked: send it, or shrink or leave the
70// conversation first. The choices are the dialog's labels; Esc or a typed answer sends nothing.
71export const COLD_SEND = 'Send anyway'
72export const COLD_COMPACT = 'Compact first'
73export const COLD_HANDOFF = 'Handoff, then new'
74export const COLD_NEW = 'New conversation'
75
76export function coldQuestion(tokens: number): string {
77 return `Prompt cache is cold: sending this prompt re-caches ${fmtTokens(tokens)} tokens. What now?`
78}
79
80export function coldChoices(hasHandoff: boolean): string[] {
81 return hasHandoff ? [COLD_SEND, COLD_COMPACT, COLD_HANDOFF, COLD_NEW] : [COLD_SEND, COLD_COMPACT, COLD_NEW]
82}
83
84// The commands a choice runs before the prompt goes back to the box; undefined for an answer
85// that runs none (Esc, a typed answer).
86export function coldCommands(choice: string): string[] | undefined {
87 if (choice === COLD_COMPACT) return ['compact']
88 if (choice === COLD_HANDOFF) return [HANDOFF_SKILL, 'clear']
89 if (choice === COLD_NEW) return ['clear']
90 return undefined
91}
92
93// The line the held prompt leaves in place of the turn.
94export function heldMessage(choice: string | undefined): string {
95 if (choice === COLD_COMPACT) return 'Prompt held while the conversation compacts; it goes back to the prompt box'
96 if (choice === COLD_HANDOFF) return 'Prompt held for a handoff and a new conversation; it goes back to the prompt box'
97 if (choice === COLD_NEW) return 'Prompt held for a new conversation; it goes back to the prompt box'
98 return 'Prompt held over a cold cache and put back in the prompt box; Enter sends it as is'
99}
100
101// A click on the cache's countdown: its state, when it lapses, its size and how well it served.
102export function cacheDetailsMessage(c: CacheStatus, nowMs: number): string {
103 const at = new Date(nowMs + c.remainingMs)
104 const clock = `${String(at.getHours()).padStart(2, '0')}:${String(at.getMinutes()).padStart(2, '0')}`
105 const state =
106 c.remainingMs > 0
107 ? `warm, lapses in ${fmtSpan(c.remainingMs)} (${clock}); ${fmtTokens(c.tokens)} tokens cached`
108 : `cold; the next prompt re-caches ${fmtTokens(c.tokens)} tokens`
109 const rebuilds = `${c.rebuilds} rebuild${c.rebuilds === 1 ? '' : 's'}`
110 return `Prompt cache ${state} · TTL ${c.ttlMs >= 3_600_000 ? '1h' : '5m'} · ${Math.round(c.hitPercent)}% hit · ${rebuilds}`
111}
112
113/**
114 * The toasts owed now and the levels to remember: a gauge toasts when it climbs
115 * above the level last remembered; a drop is remembered silently, re-arming it.
116 */
117export function climb(
118 previous: Readonly<Record<string, number>>,
119 current: Readonly<Record<string, Gauge>>,
120 minLevel = 1,
121): { toasts: string[]; levels: Record<string, number> } {
122 const toasts: string[] = []
123 const levels: Record<string, number> = {}
124 for (const [key, gauge] of Object.entries(current)) {
125 // Below `minLevel` a climb is remembered without a toast.
126 if (gauge.level > (previous[key] ?? 0) && gauge.level >= minLevel) toasts.push(gauge.message)
127 levels[key] = gauge.level
128 }
129 return { toasts, levels }
130}
131hooks/cache.ts 114 lines1// The main conversation's prompt cache, as the mod reckons it from the usage of each model request:
2// when the last one was, how big the cached prefix is, and when the entry lapses.
3
4import type { CacheMark, CacheTtl } from '../types'
5
6export type { CacheMark, CacheTtl }
7
8export const TTL_MS: Record<CacheTtl, number> = { '5m': 5 * 60_000, '1h': 60 * 60_000 }
9
10// A prefix this big toasts before it lapses and when a prompt goes out over it cold.
11export const CACHE_ALERT_TOKENS = 100_000
12
13// A request that wrote at least this share of a prefix this big rebuilt the cache.
14const REBUILD_SHARE = 0.5
15const REBUILD_MIN_TOKENS = 50_000
16
17export type StepUsage = {
18 input_tokens: number
19 cache_read_input_tokens: number
20 cache_creation_input_tokens: number
21 model: string
22}
23
24export function recordStep(previous: CacheMark | null, usage: StepUsage, nowMs: number): CacheMark {
25 const read = usage.cache_read_input_tokens
26 const written = usage.cache_creation_input_tokens
27 const tokens = read + written + usage.input_tokens
28 const isRebuild = tokens >= REBUILD_MIN_TOKENS && written >= REBUILD_SHARE * tokens
29 return {
30 at: nowMs,
31 tokens,
32 model: usage.model,
33 readTokens: (previous?.readTokens ?? 0) + read,
34 inputTokens: (previous?.inputTokens ?? 0) + tokens,
35 rebuilds: (previous?.rebuilds ?? 0) + (isRebuild ? 1 : 0),
36 isExpiryWarned: false,
37 }
38}
39
40function isOn(value: string | undefined): boolean {
41 return value !== undefined && /^(1|true|yes|on)$/i.test(value.trim())
42}
43
44function asTtl(value: unknown): CacheTtl | undefined {
45 return value === '5m' || value === '1h' ? value : undefined
46}
47
48/**
49 * The TTL the settings force on the main conversation, by Claude Code's precedence:
50 * FORCE_PROMPT_CACHING_5M, CLAUDE_CODE_PROMPT_CACHE_TTL, the promptCacheTtl setting,
51 * ENABLE_PROMPT_CACHING_1H; null when none does.
52 */
53export function ttlOverride(s: { force5m?: string; envTtl?: string; settingTtl?: unknown; enable1h?: string }): CacheTtl | null {
54 if (isOn(s.force5m)) return '5m'
55 const chosen = asTtl(s.envTtl?.trim()) ?? asTtl(s.settingTtl)
56 if (chosen) return chosen
57 return isOn(s.enable1h) ? '1h' : null
58}
59
60// Unforced: 1h on a subscription within its plan, 5m otherwise (an API key, or a subscription
61// past a window, now on usage credits).
62export function cacheTtl(override: CacheTtl | null, hasWindows: boolean, isPastWindow: boolean): CacheTtl {
63 return override ?? (hasWindows && !isPastWindow ? '1h' : '5m')
64}
65
66// claude-opus-5-5[1m] and claude-opus-5-5-20260101 name the same model as claude-opus-5-5.
67function sameModel(a: string, b: string): boolean {
68 const norm = (id: string) => id.toLowerCase().replace(/\[[^\]]*\]$/, '')
69 const [x, y] = [norm(a), norm(b)]
70 return x === y || x.startsWith(y + '-') || y.startsWith(x + '-')
71}
72
73export type CacheStatus = {
74 /** Time left before the entry lapses; zero or less when it is cold. */
75 remainingMs: number
76 ttlMs: number
77 /** The prefix the next request reads, or writes again when cold. */
78 tokens: number
79 /** Share of all input tokens the cache served, 0-100. */
80 hitPercent: number
81 rebuilds: number
82}
83
84// A model other than the one the cache was made for starts cold.
85export function cacheStatus(mark: CacheMark | null, ttl: CacheTtl, model: string, nowMs: number): CacheStatus | undefined {
86 if (!mark) return undefined
87 const ttlMs = TTL_MS[ttl]
88 const remainingMs = model && !sameModel(model, mark.model) ? 0 : mark.at + ttlMs - nowMs
89 return {
90 remainingMs,
91 ttlMs,
92 tokens: mark.tokens,
93 hitPercent: mark.inputTokens > 0 ? (mark.readTokens / mark.inputTokens) * 100 : 0,
94 rebuilds: mark.rebuilds,
95 }
96}
97
98// How long before the entry lapses the band turns yellow and red, and the toast shows.
99export function warnLeadMs(ttlMs: number): number {
100 return Math.min(10 * 60_000, ttlMs * 0.4)
101}
102export function urgentLeadMs(ttlMs: number): number {
103 return Math.min(5 * 60_000, ttlMs * 0.2)
104}
105
106export function isExpiryDue(status: CacheStatus | undefined): boolean {
107 return (
108 status !== undefined &&
109 status.remainingMs > 0 &&
110 status.remainingMs <= urgentLeadMs(status.ttlMs) &&
111 status.tokens >= CACHE_ALERT_TOKENS
112 )
113}
114hooks/format.ts 367 lines1// Port of ~/.claude/statusline.py, plus the mod-only figures: builds the two lines as colored segments.
2
3import { CACHE_ALERT_TOKENS, urgentLeadMs, warnLeadMs } from './cache'
4import type { CacheStatus } from './cache'
5import { DEFAULT_SETTINGS } from './settings'
6import type { Settings } from './settings'
7
8// `press` makes the segment interactive: `model` and `context-label` open /model and /context, `folder` shows and
9// copies the path, `usage` and `context` swap to the cost and the token count on a click, `reset` swaps the
10// reset countdowns for the clock time of the reset, `cache` toasts the prompt cache's details.
11export type Press = 'settings' | 'model' | 'folder' | 'usage' | 'context' | 'context-label' | 'reset' | 'cache' | 'changes'
12export type Seg = { text: string; color?: string; bold?: boolean; dim?: boolean; underline?: boolean; press?: Press }
13
14export const C = {
15 CY: 'cyan',
16 GR: 'green',
17 YL: 'yellow',
18 OR: '#d77800', // orange (warning)
19 RD: 'red',
20 BL: '#0891b2', // custom blue
21 WH: 'white',
22} as const
23
24export const FIVE_HOUR_MS = 5 * 60 * 60 * 1000
25export const SEVEN_DAY_MS = 7 * 24 * 60 * 60 * 1000
26
27export type RateLimit = { percentUsed?: number; resetsAt?: string; etaMs?: number }
28
29export type StatusInput = {
30 model: string
31 effort?: string | null
32 cwd: string
33 git: {
34 repo: string
35 branch: string
36 changes?: number
37 ahead?: number
38 behind?: number
39 stashes?: number
40 conflicts?: number
41 operation?: string
42 } | null
43 contextPercent?: number
44 /** Where auto-compaction runs, as a percentage of the context window; absent when it is off. */
45 compactAtPercent?: number
46 costUsd?: number
47 /** A subscription's account: the 5h/7d windows are what limits it, so they stand in for the cost. */
48 hasWindows: boolean
49 fiveHour?: RateLimit
50 sevenDay?: RateLimit
51 /** The windows swapped for the API-equivalent cost. */
52 showCost?: boolean
53 /** The reset countdowns swapped for the clock time of the reset. */
54 showResetAt?: boolean
55 /** The main conversation's prompt cache; absent before its first model request. */
56 cache?: CacheStatus
57 /** What /config shows of the band; the defaults when absent. */
58 settings?: Settings
59 nowMs: number
60}
61
62const SEP: Seg = { text: ' │ ', dim: true }
63
64export function fmtDuration(ms: number): string {
65 if (!ms) return '--'
66 let s = Math.floor(ms / 1000)
67 if (s < 60) return `${s}s`
68 let m = Math.floor(s / 60)
69 s %= 60
70 if (m < 60) return `${m}m${String(s).padStart(2, '0')}s`
71 const h = Math.floor(m / 60)
72 m %= 60
73 return `${h}h${String(m).padStart(2, '0')}m`
74}
75
76// claude-opus-5-5 -> Opus 5.5, claude-haiku-4-5-20251001 -> Haiku 4.5, a [1m] suffix -> (1M); anything else as given.
77export function modelName(id: string): string {
78 const m = /^claude-(opus|sonnet|haiku|fable)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?(\[1m\])?$/i.exec(id)
79 if (!m) return id
80 const [, family = '', major, minor, isLong] = m
81 const name = family.charAt(0).toUpperCase() + family.slice(1).toLowerCase()
82 return `${name} ${major}${minor ? '.' + minor : ''}${isLong ? ' (1M)' : ''}`
83}
84
85// The band's one button: `Opus 5.5 · medium`, or the model alone before the effort is known.
86export function modelEffortLabel(model: string, effort: string | null | undefined): string {
87 const name = model ? modelName(model) : '--'
88 return effort ? `${name} · ${effort}` : name
89}
90
91// Every bar on the band (context, 5h, 7d) has this many cells, so they line up alike.
92export const BAR_WIDTH = 10
93
94export function bar(pct: number, width = BAR_WIDTH): string {
95 const filled = Math.max(0, Math.min(width, Math.floor((pct * width) / 100)))
96 return '█'.repeat(filled) + '░'.repeat(width - filled)
97}
98
99// The context bar, with a white │ where auto-compaction runs, as the 5h/7d bars mark the time elapsed.
100export function contextBar(pct: number, compactAtPercent: number | undefined, color: string, width = BAR_WIDTH): Seg[] {
101 const cells = bar(pct, width)
102 if (compactAtPercent === undefined) return [{ text: cells, color }]
103 const marker = Math.min(width - 1, Math.max(0, Math.floor((compactAtPercent * width) / 100)))
104 return [
105 ...(marker > 0 ? [{ text: cells.slice(0, marker), color }] : []),
106 { text: '│', color: C.WH },
107 ...(marker < width - 1 ? [{ text: cells.slice(marker + 1), color }] : []),
108 ]
109}
110
111function fmtPct(val: number | undefined): string {
112 return val === undefined ? '--' : `${val.toFixed(0)}%`
113}
114
115// Compact span: 1d02h, 1h30m, 12m.
116export function fmtSpan(ms: number): string {
117 let m = Math.floor(ms / 60_000)
118 let h = Math.floor(m / 60)
119 m %= 60
120 const d = Math.floor(h / 24)
121 h %= 24
122 if (d) return `${d}d${String(h).padStart(2, '0')}h`
123 if (h) return `${h}h${String(m).padStart(2, '0')}m`
124 return `${m}m`
125}
126
127export function fmtReset(resetsAtMs: number | undefined, nowMs: number): string {
128 if (resetsAtMs === undefined) return ''
129 const remaining = Math.floor(resetsAtMs / 1000) - Math.floor(nowMs / 1000)
130 if (remaining <= 0) return '↺ now'
131 return `↺ ${fmtSpan(remaining * 1000)}`
132}
133
134const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as const
135
136// The reset as a local clock time: 14:00 today, Sat 14:00 within the week, next Wed 14:00 a week on.
137export function fmtResetAt(resetsAtMs: number | undefined, nowMs: number): string {
138 if (resetsAtMs === undefined) return ''
139 if (resetsAtMs <= nowMs) return '↺ now'
140 const at = new Date(resetsAtMs)
141 const now = new Date(nowMs)
142 const time = `${String(at.getHours()).padStart(2, '0')}:${String(at.getMinutes()).padStart(2, '0')}`
143 const days = Math.round(
144 (new Date(at.getFullYear(), at.getMonth(), at.getDate()).getTime() -
145 new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()) /
146 86_400_000,
147 )
148 if (days === 0) return `↺ ${time}`
149 return `↺ ${days >= 7 ? 'next ' : ''}${WEEKDAYS[at.getDay()]} ${time}`
150}
151
152export function parseReset(resetsAt: string | undefined): number | undefined {
153 const ms = resetsAt ? Date.parse(resetsAt) : NaN
154 return Number.isNaN(ms) ? undefined : ms
155}
156
157export function windowFraction(resetsAtMs: number | undefined, windowMs: number, nowMs: number): number | undefined {
158 if (resetsAtMs === undefined) return undefined
159 const start = resetsAtMs - windowMs
160 if (nowMs <= start) return 0
161 if (nowMs >= resetsAtMs) return 1
162 return (nowMs - start) / windowMs
163}
164
165// Blue: at or below the time-elapsed pace; orange: ahead of pace; red: >= 80% consumed.
166export function rateLimitColor(usedPct: number | undefined, elapsed: number | undefined): string {
167 if (usedPct === undefined) return C.BL
168 if (usedPct >= 80) return C.RD
169 if (elapsed !== undefined && usedPct > elapsed * 100) return C.OR
170 return C.BL
171}
172
173// The projection warns only when 100% comes this soon, as a fraction of the time left to the reset.
174export const PROJECTION_WARN_FRACTION = 0.6
175
176/**
177 * Time from now until the window reaches 100% at its average rate so far (a rule of three:
178 * used% over the time elapsed since the window opened), or undefined when at that pace it
179 * would not get there within PROJECTION_WARN_FRACTION of the time left to the reset, or
180 * nothing is used yet.
181 */
182export function projectByPace(
183 usedPct: number | undefined,
184 resetsAtMs: number | undefined,
185 windowMs: number,
186 nowMs: number,
187): number | undefined {
188 if (usedPct === undefined || usedPct <= 0 || usedPct >= 100 || resetsAtMs === undefined) return undefined
189 const elapsedMs = nowMs - (resetsAtMs - windowMs)
190 if (elapsedMs <= 0 || nowMs >= resetsAtMs) return undefined
191 const etaMs = (elapsedMs * (100 - usedPct)) / usedPct
192 return etaMs < PROJECTION_WARN_FRACTION * (resetsAtMs - nowMs) ? etaMs : undefined
193}
194
195export function rateLimitBar(usedPct: number | undefined, elapsed: number | undefined, color: string, width = BAR_WIDTH): Seg[] {
196 const used = usedPct === undefined ? 0 : Math.max(0, Math.min(100, Math.round(usedPct)))
197 const filled = Math.max(0, Math.min(width, Math.floor((used * width) / 100)))
198 const marker = elapsed === undefined ? undefined : Math.min(width - 1, Math.max(0, Math.floor(elapsed * width)))
199 const segs: Seg[] = []
200 for (let i = 0; i < width; i++) {
201 if (i === marker) segs.push({ text: '│', color: C.WH })
202 else if (i < filled) segs.push({ text: '█', color })
203 else segs.push({ text: '░', dim: true })
204 }
205 return segs
206}
207
208// The window's color as statusline.py picks it, the reset-now case included.
209export function windowColor(rl: RateLimit | undefined, windowMs: number, nowMs: number): string {
210 const resetsAtMs = parseReset(rl?.resetsAt)
211 const isResetNow = resetsAtMs !== undefined && resetsAtMs <= nowMs
212 if (isResetNow) return rateLimitColor(undefined, undefined)
213 return rateLimitColor(rl?.percentUsed, windowFraction(resetsAtMs, windowMs, nowMs))
214}
215
216function rateLimitSegs(label: string, rl: RateLimit | undefined, windowMs: number, nowMs: number, showResetAt = false): Seg[] {
217 const used = rl?.percentUsed
218 const resetsAtMs = parseReset(rl?.resetsAt)
219 // When the window has fully elapsed, zero out both fill and marker.
220 const isResetNow = resetsAtMs !== undefined && resetsAtMs <= nowMs
221 const elapsed = windowFraction(resetsAtMs, windowMs, nowMs)
222 const color = windowColor(rl, windowMs, nowMs)
223 const reset = showResetAt ? fmtResetAt(resetsAtMs, nowMs) : fmtReset(resetsAtMs, nowMs)
224 const eta = isResetNow ? undefined : rl?.etaMs
225 // The spaces between the clickable stretches stay out of them, so the pointer lights the text alone.
226 const usage = (segs: Seg[]) => segs.map(seg => ({ ...seg, press: 'usage' as const }))
227 return [
228 ...usage([
229 { text: `${label} ` },
230 ...rateLimitBar(isResetNow ? 0 : used, isResetNow ? undefined : elapsed, color),
231 { text: ' ' },
232 { text: fmtPct(used), color },
233 ]),
234 ...(reset ? [{ text: ' ' }, { text: reset, dim: true, press: 'reset' as const }] : []),
235 ...(eta !== undefined ? [{ text: ' ' }, ...usage([{ text: `⚠ 100% in ${fmtSpan(eta)}`, color: C.RD, bold: true }])] : []),
236 ]
237}
238
239// What the session has cost: what an API account pays.
240export function costSegs(costUsd: number | undefined): Seg[] {
241 return [{ text: 'cost ' }, { text: `$${(costUsd ?? 0).toFixed(2)}`, color: C.YL }]
242}
243
244// On a subscription, a click on the windows swaps them for what the session would cost on the API.
245function contextColor(pct: number): string {
246 return pct >= 85 ? C.RD : pct >= 70 ? C.YL : C.GR
247}
248
249// 950 -> 950, 358700 -> 358.7k, 200000 -> 200k, 1000000 -> 1M.
250export function fmtTokens(n: number): string {
251 const trim = (x: number) => x.toFixed(1).replace(/\.0$/, '')
252 if (n >= 1_000_000) return `${trim(n / 1_000_000)}M`
253 if (n >= 1_000) return `${trim(n / 1_000)}k`
254 return String(Math.round(n))
255}
256
257// A click on the context bar swaps it for the tokens in use out of the window: 358.7k / 1M.
258export function contextAltSegs(percent: number | undefined, tokens: number | undefined, window: number): Seg[] {
259 const color = contextColor(Math.round(percent ?? 0))
260 return [{ text: ' ' }, { text: `${fmtTokens(tokens ?? 0)} / ${fmtTokens(window)}`, color }]
261}
262
263export function costAltSegs(costUsd: number | undefined): Seg[] {
264 return [{ text: 'cost ≈ ' }, { text: `$${(costUsd ?? 0).toFixed(2)}`, color: C.YL }, { text: ' API-equivalent', dim: true }]
265}
266
267/**
268 * Whether the account is one with 5h/7d windows, from what was known before and what the
269 * engine reports now: windows mark it as one; a model response of this session that came back
270 * without them marks it as not, as when an API key replaces the subscription; before any
271 * response nothing changes (the context's tokens are no sign: the desktop counts them at once).
272 */
273export function accountHasWindows(wasKnown: boolean, rateLimitCount: number, hasResponded: boolean): boolean {
274 if (rateLimitCount > 0) return true
275 if (hasResponded) return false
276 return wasKnown
277}
278
279// Minutes while there are ten or more, then minutes and seconds: 42m, 4m30s, 45s.
280function fmtLeft(ms: number): string {
281 return ms >= 10 * 60_000 ? `${Math.floor(ms / 60_000)}m` : fmtDuration(Math.max(1000, ms))
282}
283
284// The prompt cache beside the context it holds: its countdown, green, yellow near the end and red
285// when the expiry toast is due; once it lapsed, cold, orange when the prefix is large.
286export function cacheSegs(c: CacheStatus): Seg {
287 if (c.remainingMs <= 0) {
288 return c.tokens >= CACHE_ALERT_TOKENS ? { text: '○ cold', color: C.OR } : { text: '○ cold', dim: true }
289 }
290 const color = c.remainingMs <= urgentLeadMs(c.ttlMs) ? C.RD : c.remainingMs <= warnLeadMs(c.ttlMs) ? C.YL : C.GR
291 return { text: `● ${fmtLeft(c.remainingMs)}`, color }
292}
293
294// The windows the account reports: some plans have a 5h window and no weekly one. Before the
295// first response tells which, both stand as placeholders.
296function windowsSegs(s: StatusInput): Seg[] {
297 const isKnown = s.fiveHour !== undefined || s.sevenDay !== undefined
298 const shown = [
299 ...(!isKnown || s.fiveHour ? [rateLimitSegs('5h', s.fiveHour, FIVE_HOUR_MS, s.nowMs, s.showResetAt)] : []),
300 ...(!isKnown || s.sevenDay ? [rateLimitSegs('7d', s.sevenDay, SEVEN_DAY_MS, s.nowMs, s.showResetAt)] : []),
301 ]
302 return shown.flatMap((segs, i) => (i === 0 ? segs : [SEP, ...segs]))
303}
304
305// The windows, which a click swaps for the cost, or the cost alone; `auto` picks by the account.
306function usageSegs(s: StatusInput, usage: Settings['usage']): Seg[] {
307 if (usage === 'off') return []
308 const hasWindows = usage === 'auto' ? s.hasWindows : usage === 'rate-limits'
309 if (!hasWindows) return [SEP, ...costSegs(s.costUsd)]
310 if (s.showCost) return [SEP, ...costAltSegs(s.costUsd).map(seg => ({ ...seg, press: 'usage' as const }))]
311 return [SEP, ...windowsSegs(s)]
312}
313
314export function buildLines(s: StatusInput): [Seg[], Seg[]] {
315 const settings = s.settings ?? DEFAULT_SETTINGS
316 const isFull = settings.git === 'full'
317 const cwdN = s.cwd.replace(/\\/g, '/')
318 const folder = cwdN.replace(/\/+$/, '').split('/').pop() || cwdN
319
320 const gitSegs: Seg[] = s.git && settings.git !== 'off'
321 ? [
322 { text: '⎇ ', color: C.GR, press: 'folder' },
323 { text: s.git.repo, color: C.GR, bold: true, press: 'folder' },
324 { text: ':', dim: true, press: 'folder' },
325 { text: s.git.branch, color: C.YL, press: 'folder' },
326 // An operation git left under way, and the files it left in conflict.
327 ...(s.git.operation ? [{ text: ` ${s.git.operation}`, color: C.RD, bold: true }] : []),
328 ...(s.git.conflicts ? [{ text: ` ✖${s.git.conflicts}`, color: C.RD }] : []),
329 // Files changed (a click shows their diff), commits not yet pushed and not yet pulled, then
330 // the stashes kept, quiet: a reminder, not a state of the branch.
331 ...(isFull && s.git.changes ? [{ text: ' ' }, { text: `●${s.git.changes}`, color: C.OR, press: 'changes' as const }] : []),
332 ...(isFull && s.git.ahead ? [{ text: ` ↑${s.git.ahead}`, color: C.CY }] : []),
333 ...(isFull && s.git.behind ? [{ text: ` ↓${s.git.behind}`, color: C.CY }] : []),
334 ...(isFull && s.git.stashes ? [{ text: ` ≡${s.git.stashes}`, dim: true }] : []),
335 ]
336 : // The icon apart from the name: the name inverts under the pointer, the icon shows the path.
337 [
338 { text: '📁 ', dim: true, press: 'folder' },
339 { text: folder, dim: true, press: 'folder' },
340 ]
341
342 const line1: Seg[] = [
343 // The diamond opens the band's settings.
344 { text: '◆', color: C.CY, bold: true, press: 'settings' },
345 { text: ' ' },
346 { text: modelEffortLabel(s.model, s.effort), color: C.CY, bold: true, press: 'model' },
347 SEP,
348 ...gitSegs,
349 ]
350
351 // Rounded, as /context rounds it, so both read the same figure.
352 const pct = Math.round(s.contextPercent ?? 0)
353 const ctxColor = contextColor(pct)
354
355 const line2: Seg[] = [
356 { text: 'ctx', press: 'context-label' },
357 { text: ' ' },
358 ...contextBar(pct, s.compactAtPercent, ctxColor).map(seg => ({ ...seg, press: 'context' as const })),
359 { text: ' ', press: 'context' },
360 { text: `${pct}%`, color: ctxColor, press: 'context' },
361 ...(s.cache && settings.cache !== 'off' ? [{ text: ' ' }, { ...cacheSegs(s.cache), press: 'cache' as const }] : []),
362 ...usageSegs(s, settings.usage),
363 ]
364
365 return [line1, line2]
366}
367hooks/effort.ts 26 lines1// Reading the level a /effort run set.
2
3// What /effort accepts ("Valid options are: low, medium, high, xhigh, max, auto").
4export const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max', 'auto'] as const
5
6/**
7 * The level a finished /effort set: what its output reports ("Set effort level to high ..."),
8 * else the level its args named (the output may not reach hooks); undefined for an unknown level or none.
9 */
10export function effortFromCommand(args: string, output: string | undefined): string | undefined {
11 const reported = /^Set effort level to (\w+)/.exec(output ?? '')?.[1]
12 if (reported) return reported
13 const asked = args.trim().split(/\s+/)[0] ?? ''
14 return (EFFORTS as readonly string[]).includes(asked) ? asked : undefined
15}
16
17/**
18 * The effort a finished /model left the session on: `null` when it set a model with no effort
19 * ("Set model to `Haiku 4.5` ..."), the level it names ("... with `medium` effort"), and
20 * undefined when it changed nothing ("Kept model as ...", "Cancelled") or the output is unknown.
21 */
22export function effortFromModelCommand(output: string | undefined): string | null | undefined {
23 if (!output || !/^Set model to /.test(output)) return undefined
24 return /with `(\w+)` effort/.exec(output)?.[1] ?? null
25}
26hooks/git.ts 67 lines1// Reading `git status --porcelain=v2 --branch --show-stash`: the branch, the files changed, the
2// commits ahead of and behind its upstream, and the stashes kept.
3
4export type GitStatus = { branch: string; changes: number; ahead: number; behind: number; stashes: number; conflicts: number }
5
6export function parseGitStatus(stdout: string): GitStatus {
7 let head = ''
8 let oid = ''
9 let ahead = 0
10 let behind = 0
11 let stashes = 0
12 let changes = 0
13 let conflicts = 0
14 for (const line of stdout.split(/\r?\n/)) {
15 if (!line) continue
16 if (line.startsWith('# branch.head ')) head = line.slice('# branch.head '.length)
17 else if (line.startsWith('# branch.oid ')) oid = line.slice('# branch.oid '.length)
18 else if (line.startsWith('# branch.ab ')) {
19 const ab = /\+(\d+) -(\d+)/.exec(line)
20 ahead = Number(ab?.[1] ?? 0)
21 behind = Number(ab?.[2] ?? 0)
22 } else if (line.startsWith('# stash ')) {
23 // Only when there is one: `# stash 2`.
24 stashes = Number(line.slice('# stash '.length)) || 0
25 } else if (!line.startsWith('#')) {
26 changes += 1
27 // `u`: a file a merge, rebase or cherry-pick left in conflict.
28 if (line.startsWith('u ')) conflicts += 1
29 }
30 }
31 // A detached HEAD reads as its short sha, as `rev-parse --short` gives it.
32 const branch = head === '(detached)' ? `#${oid.slice(0, 7)}` : head
33 return { branch, changes, ahead, behind, stashes, conflicts }
34}
35
36export type GitOperation = { operation: string; branch?: string }
37export type GitDirReader = { exists: (path: string) => Promise<boolean>; read: (path: string) => Promise<string> }
38
39/**
40 * An operation git left under way, named as git's own prompt names it, from the files it keeps
41 * in the git folder; a rebase also says the branch it rebases, since HEAD is detached meanwhile.
42 */
43export async function readOperation(gitDir: string, dir: GitDirReader): Promise<GitOperation | null> {
44 const at = (name: string) => `${gitDir}/${name}`
45 const text = (name: string) => dir.read(at(name)).then(t => t.trim(), () => '')
46 const rebases = [
47 ['rebase-merge', 'msgnum', 'end'],
48 ['rebase-apply', 'next', 'last'],
49 ] as const
50 for (const [folder, step, total] of rebases) {
51 if (!(await dir.exists(at(folder)))) continue
52 const isAm = folder === 'rebase-apply' && (await dir.exists(at(`${folder}/applying`)))
53 const [n, m, head] = await Promise.all([text(`${folder}/${step}`), text(`${folder}/${total}`), text(`${folder}/head-name`)])
54 const progress = n && m ? ` ${n}/${m}` : ''
55 const branch = head.startsWith('refs/heads/') ? head.slice('refs/heads/'.length) : undefined
56 return { operation: `${isAm ? 'AM' : 'REBASING'}${progress}`, ...(branch && { branch }) }
57 }
58 const marks = [
59 ['MERGE_HEAD', 'MERGING'],
60 ['CHERRY_PICK_HEAD', 'CHERRY-PICKING'],
61 ['REVERT_HEAD', 'REVERTING'],
62 ['BISECT_LOG', 'BISECTING'],
63 ] as const
64 for (const [file, operation] of marks) if (await dir.exists(at(file))) return { operation }
65 return null
66}
67hooks/changes.ts 58 lines1// Reading `git diff HEAD` into the files the changes pane shows: each one's status, the lines
2// added and removed, and its hunks, whole hunks only (a hunk cut short draws as no diff).
3
4import type { ChangedFile } from '../types'
5
6// What the pane draws of the hunks, in all; past it the rest of the files list without them.
7export const DIFF_LIMIT = 80_000
8
9export function parseDiff(stdout: string, limit = DIFF_LIMIT): { files: ChangedFile[]; isCut: boolean } {
10 const files: ChangedFile[] = []
11 let used = 0
12 let isCut = false
13 const chunks = stdout.split(/^(?=diff --git )/m).filter(chunk => chunk.startsWith('diff --git '))
14 for (const chunk of chunks) {
15 const lines = chunk.replace(/\r\n/g, '\n').split('\n')
16 const firstHunk = lines.findIndex(line => line.startsWith('@@'))
17 const header = firstHunk === -1 ? lines : lines.slice(0, firstHunk)
18 const body = firstHunk === -1 ? [] : lines.slice(firstHunk)
19
20 // The new path from `+++ b/…`, the old one from `--- a/…` for a deletion; else the header's.
21 const after = header.find(line => line.startsWith('+++ b/'))?.slice('+++ b/'.length)
22 const before = header.find(line => line.startsWith('--- a/'))?.slice('--- a/'.length)
23 const renamed = header.find(line => line.startsWith('rename to '))?.slice('rename to '.length)
24 const fromHeader = / b\/(.*)$/.exec(lines[0] ?? '')?.[1] ?? ''
25 const path = after ?? renamed ?? before ?? fromHeader
26
27 const status: ChangedFile['status'] = header.some(line => line.startsWith('new file mode'))
28 ? 'A'
29 : header.some(line => line.startsWith('deleted file mode'))
30 ? 'D'
31 : renamed !== undefined
32 ? 'R'
33 : 'M'
34 const isBinary = header.some(line => line.startsWith('Binary files '))
35
36 let added = 0
37 let removed = 0
38 for (const line of body) {
39 if (line.startsWith('+')) added += 1
40 else if (line.startsWith('-')) removed += 1
41 }
42
43 // Whole hunks while they fit what is left; once one does not, none after it.
44 const hunks: string[] = []
45 for (const hunk of body.join('\n').split(/^(?=@@)/m)) {
46 if (!hunk.startsWith('@@')) continue
47 if (isCut || used + hunk.length > limit) {
48 isCut = true
49 break
50 }
51 hunks.push(hunk)
52 used += hunk.length
53 }
54 files.push({ path, status, added, removed, hunks: hunks.join('').replace(/\n+$/, ''), isBinary })
55 }
56 return { files, isCut }
57}
58hooks/host.ts 44 lines1// The folder menu's actions as programs of the machine: its file manager, VS Code and a terminal
2// opened in the folder. Each returns at once, so `$.process.run` never waits on the window.
3
4import type { HostTools } from '../types'
5
6export type FolderAction = 'files' | 'code' | 'terminal' | 'copy'
7export type MenuItem = { action: FolderAction; label: string }
8
9export function fileManager(platform: HostTools['platform']): string {
10 return platform === 'windows' ? 'Explorer' : platform === 'mac' ? 'Finder' : 'file manager'
11}
12
13// The menu, in order; VS Code only where its command is found.
14export function menuItems(host: HostTools): MenuItem[] {
15 return [
16 { action: 'files', label: `Open in ${fileManager(host.platform)}` },
17 ...(host.hasCode ? [{ action: 'code' as const, label: 'Open in VS Code' }] : []),
18 { action: 'terminal', label: 'Open a terminal here' },
19 { action: 'copy', label: 'Copy path' },
20 ]
21}
22
23// The program an action runs, and the folder it runs in; undefined for the copy, done in place.
24export function actionArgv(action: FolderAction, host: HostTools, folder: string): { argv: string[]; cwd?: string } | undefined {
25 // Explorer reads a path with forward slashes as none and opens Documents.
26 const path = host.platform === 'windows' ? folder.replace(/\//g, '\\') : folder
27 if (action === 'files') {
28 return { argv: host.platform === 'windows' ? ['explorer.exe', path] : [host.platform === 'mac' ? 'open' : 'xdg-open', path] }
29 }
30 if (action === 'code') {
31 // `code` is a .cmd on Windows, which only cmd starts.
32 return { argv: host.platform === 'windows' ? ['cmd.exe', '/d', '/c', 'code', path] : ['code', path] }
33 }
34 if (action === 'terminal') {
35 if (host.platform === 'windows') {
36 return { argv: host.hasWt ? ['wt.exe', '-d', path] : ['cmd.exe', '/d', '/c', 'start', '', '/D', path, 'cmd.exe'] }
37 }
38 if (host.platform === 'mac') return { argv: ['open', '-a', 'Terminal', path] }
39 // Detached, so the call returns while the terminal stays open.
40 return { argv: ['sh', '-c', 'nohup x-terminal-emulator >/dev/null 2>&1 &'], cwd: path }
41 }
42 return undefined
43}
44hooks/settings.ts 62 lines1// The person's choices from /config (the manifest's userConfig): which of the band's parts show,
2// and how the prompt cache and the thresholds speak up. The defaults are the band as it always was.
3
4export type Settings = {
5 /** `full`: branch, files changed (with their diff), ahead/behind, stashes, an operation under way and its
6 * conflicts; `branch`: the branch and an operation under way with its conflicts; `off`: the folder alone. */
7 git: 'full' | 'branch' | 'off'
8 /** `auto`: the 5h/7d rate limits on a subscription, the cost on an API account. */
9 usage: 'auto' | 'rate-limits' | 'cost' | 'off'
10 /** `ask`: the countdown, and a prompt over a cold cache held with a question; `warn`: the
11 * countdown and a toast; `off`: neither. */
12 cache: 'ask' | 'warn' | 'off'
13 /** The context and rate-limit thresholds: `critical` only the red ones. */
14 toasts: 'all' | 'critical' | 'off'
15}
16
17export const DEFAULT_SETTINGS: Settings = { git: 'full', usage: 'auto', cache: 'ask', toasts: 'all' }
18
19export const CHOICES = {
20 git: ['full', 'branch', 'off'],
21 usage: ['auto', 'rate-limits', 'cost', 'off'],
22 cache: ['ask', 'warn', 'off'],
23 toasts: ['all', 'critical', 'off'],
24} as const
25
26function pick<K extends keyof typeof CHOICES>(options: Readonly<Record<string, unknown>>, key: K): Settings[K] {
27 const value = options[key]
28 return (CHOICES[key] as readonly unknown[]).includes(value) ? (value as Settings[K]) : DEFAULT_SETTINGS[key]
29}
30
31// The engine fills the defaults and drops a value outside a field's options; this keeps the band
32// whole even so.
33export function readSettings(options: Readonly<Record<string, unknown>> = {}): Settings {
34 return {
35 git: pick(options, 'git'),
36 usage: pick(options, 'usage'),
37 cache: pick(options, 'cache'),
38 toasts: pick(options, 'toasts'),
39 }
40}
41
42export type SettingKey = keyof typeof CHOICES
43
44// The settings menu's rows, in order, by the name the person reads.
45export const SETTING_ROWS: readonly { key: SettingKey; label: string }[] = [
46 { key: 'git', label: 'Git' },
47 { key: 'usage', label: 'Usage' },
48 { key: 'cache', label: 'Prompt cache' },
49 { key: 'toasts', label: 'Toasts' },
50]
51
52// A press on a row moves it to its next value, the last one back to the first.
53export function nextValue(key: SettingKey, current: string): string {
54 const choices: readonly string[] = CHOICES[key]
55 return choices[(choices.indexOf(current) + 1) % choices.length] ?? choices[0] ?? current
56}
57
58// The lowest level of a threshold that toasts: 1 orange and red, 2 red alone.
59export function toastLevel(toasts: Settings['toasts']): number {
60 return toasts === 'all' ? 1 : toasts === 'critical' ? 2 : Infinity
61}
62hooks/command-button.tsx 42 lines1// A label on the band that runs a slash command on click: "Opus 5.5 · medium" opens /model,
2// "ctx" opens /context. A Button's label takes no color, so this surface module draws the text
3// and posts the click to the hooks module, which runs the command.
4
5import type { ClientModule } from 'claude-code'
6
7export type CommandButtonProps = { label: string; command: string; color?: string; bold?: boolean }
8type State = { isHovered: boolean; isPressed: boolean }
9
10const CommandButton: ClientModule<CommandButtonProps, State> = (props, surface) => {
11 const { Text } = surface.elements
12 const state = surface.state ?? { isHovered: false, isPressed: false }
13
14 surface.onPointer(event => {
15 if (event.type === 'enter' || event.type === 'leave') {
16 surface.setState({ ...state, isHovered: event.type === 'enter' })
17 return
18 }
19 if ((event.button ?? 'left') !== 'left') return
20 if (event.type === 'down') {
21 surface.setState({ ...state, isPressed: true })
22 return
23 }
24 // A press released over the text acts, as a button does; released elsewhere it is dropped.
25 if (event.type === 'up' && state.isPressed) {
26 const isInside = event.x >= 0 && event.y >= 0 && (surface.columns === 0 || (event.x < surface.columns && event.y < surface.rows))
27 surface.setState({ ...state, isPressed: false })
28 if (isInside) surface.post({ open: props.command })
29 }
30 })
31
32 // Under the pointer the colors invert: the label's color fills the cells, and the letters take
33 // the terminal's own background, as if cut out of it.
34 return (
35 <Text color={props.color} bold={props.bold} inverse={state.isHovered}>
36 {props.label}
37 </Text>
38 )
39}
40
41export default CommandButton
42hooks/swap-button.tsx 87 lines1// A stretch of the band that shows other text: `hover` swaps it while the pointer is over it
2// (the folder to its full path, and a click posts `action`, which the hooks module copies);
3// `click` swaps it on each click (the context bar to its tokens and back); `none` never swaps.
4// `isHoverInverse` inverts its colors under the pointer, as the model text does, to show it is clickable.
5// `hotColumns` narrows the pointer and the click to the first columns of the first row (the
6// folder's icon); a click on the rest posts `restAction` (the folder's menu), the rest inverted
7// under the pointer, or nothing without one.
8
9import type { ClientModule, JsonValue } from 'claude-code'
10
11export type Part = { text: string; color?: string; bold?: boolean; dim?: boolean; underline?: boolean }
12export type SwapButtonProps = {
13 parts: Part[]
14 altParts: Part[]
15 trigger: 'hover' | 'click' | 'none'
16 action?: string
17 isHoverInverse?: boolean
18 hotColumns?: number
19 restAction?: string
20}
21type Zone = 'hot' | 'rest'
22type State = { isHovered: boolean; isPressed: boolean; isToggled: boolean; restHovered?: boolean; pressedZone?: Zone }
23
24const SwapButton: ClientModule<SwapButtonProps, State> = (props, surface) => {
25 const { Text } = surface.elements
26 const state = surface.state ?? { isHovered: false, isPressed: false, isToggled: false }
27
28 const isWithin = (x: number, y: number) =>
29 x >= 0 && y >= 0 && (surface.columns === 0 || (x < surface.columns && y < surface.rows))
30 // Where a point falls: the hot columns, the rest (only with a rest action), or nowhere.
31 const zoneAt = (x: number, y: number): Zone | undefined => {
32 if (!isWithin(x, y)) return undefined
33 if (props.hotColumns === undefined || (x < props.hotColumns && y === 0)) return 'hot'
34 return props.restAction === undefined ? undefined : 'rest'
35 }
36
37 surface.onPointer(event => {
38 if (event.type === 'enter' || event.type === 'leave' || (event.type === 'move' && event.button === undefined)) {
39 const zone = event.type === 'leave' ? undefined : zoneAt(event.x, event.y)
40 const isHovered = zone === 'hot'
41 const restHovered = zone === 'rest'
42 if (isHovered !== state.isHovered || restHovered !== (state.restHovered ?? false)) {
43 surface.setState({ ...state, isHovered, restHovered })
44 }
45 return
46 }
47 if ((event.button ?? 'left') !== 'left') return
48 if (event.type === 'down') {
49 const zone = zoneAt(event.x, event.y)
50 if (zone !== undefined) surface.setState({ ...state, isPressed: true, pressedZone: zone })
51 return
52 }
53 // A press released where it began acts, as a button does; released elsewhere it is dropped.
54 if (event.type === 'up' && state.isPressed) {
55 const zone = zoneAt(event.x, event.y)
56 const isInside = zone !== undefined && zone === (state.pressedZone ?? 'hot')
57 const isToggled = isInside && zone === 'hot' && props.trigger === 'click' ? !state.isToggled : state.isToggled
58 surface.setState({ ...state, isPressed: false, isToggled })
59 const action = zone === 'rest' ? props.restAction : props.action
60 if (isInside && action !== undefined) surface.post({ action } as JsonValue)
61 }
62 })
63
64 const isAlt = props.trigger === 'hover' ? state.isHovered : state.isToggled
65 const parts = isAlt ? props.altParts : props.parts
66 const isInverse = props.isHoverInverse === true && state.isHovered
67 // Under the pointer the rest inverts past its first part, the hot one.
68 const isRestInverse = !isAlt && state.restHovered === true
69 return (
70 <Text>
71 {parts.map((part, i) => (
72 <Text
73 color={part.color}
74 bold={part.bold}
75 dimColor={part.dim}
76 underline={part.underline}
77 inverse={isInverse || (isRestInverse && i > 0)}
78 >
79 {part.text}
80 </Text>
81 ))}
82 </Text>
83 )
84}
85
86export default SwapButton
87types/index.d.ts 84 lines1/**
2 * The repository the session is in: its name, branch, files changed and commits ahead of/behind
3 * upstream, the stashes kept; an operation git left under way (`REBASING 2/5`, `MERGING`) and the files it left in
4 * conflict, when there is one.
5 */
6export type GitInfo = {
7 repo: string
8 branch: string
9 changes: number
10 ahead: number
11 behind: number
12 stashes?: number
13 conflicts?: number
14 operation?: string
15}
16
17/** One file of the working tree's diff against HEAD: its hunks, cut to what the pane draws. */
18export type ChangedFile = {
19 path: string
20 status: 'A' | 'D' | 'M' | 'R'
21 added: number
22 removed: number
23 hunks: string
24 isBinary: boolean
25}
26
27/** The working tree's changes, as the changes pane shows them. */
28export type GitChanges = { files: ChangedFile[]; untracked: string[]; isCut: boolean }
29
30/** What the folder's menu can run on this machine. */
31export type HostTools = { platform: 'windows' | 'mac' | 'linux'; hasCode: boolean; hasWt: boolean }
32
33/** The prompt cache's time to live, as Claude Code names it. */
34export type CacheTtl = '5m' | '1h'
35
36/** What the main thread's requests have told so far. */
37export type CacheMark = {
38 /** When the last response arrived, in `$.clock.now()`'s milliseconds. */
39 at: number
40 /** The prompt the last request sent: read from the cache, written to it, and uncached. */
41 tokens: number
42 /** The model that answered it. */
43 model: string
44 /** Over all requests: tokens read from the cache, and all the input tokens. */
45 readTokens: number
46 inputTokens: number
47 /** Requests that wrote most of a large prefix again. */
48 rebuilds: number
49 /** The expiry toast for this mark has shown. */
50 isExpiryWarned: boolean
51}
52
53declare module 'claude-code' {
54 interface PluginState {
55 'statusline-band': {
56 git: GitInfo | null
57 /** Reasoning effort of the main thread's last model request, or from settings before one. */
58 effort: string | null
59 /** A subscription's account, whose 5h/7d windows stand in for the cost. */
60 hasWindows: boolean
61 /** Last alert level raised per gauge, so each crossing toasts once. */
62 alerts: Record<string, number>
63 /** The token count at which auto-compaction runs; null when it is off or not yet read. */
64 compactAt: number | null
65 /** A click on the windows swapped them for the API-equivalent cost. */
66 showCost: boolean
67 /** A click on a reset countdown swapped both for the clock time of the reset. */
68 showResetAt: boolean
69 /** What the main thread's model requests told of the prompt cache; null before the first. */
70 cache: CacheMark | null
71 /** The cache TTL the settings or environment force; null when the account decides. */
72 ttlOverride: CacheTtl | null
73 /** ai-memory's handoff skill is installed, so the cache toasts suggest it. */
74 hasHandoff: boolean
75 /** A prompt held over a cold cache and handed back unsent: the same text sent again goes. */
76 heldPrompt: string | null
77 /** The changes pane's diff; null until it first opens. */
78 changes: GitChanges | null
79 /** The folder menu's programs, found the first time it opens. */
80 host: HostTools | null
81 }
82 }
83}
84