SLOPSHOPPER

statusline-band

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…

newpanebandtoastpromptprocess
v0.22.0no licenseupdated 2026-10-13fcdiass/claude-mods/statusline-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · statusline-band
│ ┃ folder-menu ✕ › fix the failing auth test and add an audit log call │ ┃ /work/app │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ▣ client module ./swap-button.tsx ▣ client module ./command-button.tsx │ ▣ client module ./swap-button.tsx ▣ client module ./command-button.tsx ▣ client module ./swap-button.tsx │ ▣ client module ./swap-button.tsx ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▣ client module ./swap-button.tsx ▣ client module ./command-button.tsx │ ▣ client module ./swap-butt ▣ client module ./command-button.tsx ▣ client module ./swap-button.tsx │ ▣ client module ./swap-butt
Pane · folder-menu
/work/app
Pane · band-settings
[ Git full ] [ Usage auto ] [ Prompt cache ask ] [ Toasts all ]
Pane · git-changes
Reading the changes…
Source 12 files
hooks/register.tsx 799 lines
1import { 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}
799
hooks/alerts.ts 131 lines
1// 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}
131
hooks/cache.ts 114 lines
1// 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}
114
hooks/format.ts 367 lines
1// 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}
367
hooks/effort.ts 26 lines
1// 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}
26
hooks/git.ts 67 lines
1// 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}
67
hooks/changes.ts 58 lines
1// 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}
58
hooks/host.ts 44 lines
1// 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}
44
hooks/settings.ts 62 lines
1// 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}
62
hooks/command-button.tsx 42 lines
1// 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
42
hooks/swap-button.tsx 87 lines
1// 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
87
types/index.d.ts 84 lines
1/**
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