A status band above the prompt that looks like it shipped with Claude Code: model and effort, session, git, a live context gauge, 5h/7d usage with pace marks…

A status band for Claude Code that sits right above the prompt and looks like part of Claude Code itself.

It is a Claude Code mod, so instead of being re-run with a JSON blob it gets told when something changes. It updates while a turn is still running, follows every tool and subagent as it starts and ends, redraws for whatever width you have, and shows more when you point at it. The gauges use the same colours Claude Code's /usage draws its bars in, and they follow your theme.
/plugin install byj-statusline --marketplace BitYoungjae/byj-cc-statusline
Type that at the Claude Code prompt, answer y to add the marketplace, then pick a scope. Mods need Claude Code 2.1.287 or newer.
Coming from the old bash version? Delete the statusLine entry in ~/.claude/settings.json, or you will see both.
Opus 5.5 ▁▂▄▆█ xhigh pantry-k2 feat/receipt-scan +2 ~3 ?1 ↑1 fuel ██████▋ 66% 104K left 5h ███▊ │ 47% 1h54m 7d ██▊ ┃ 36% 4d2h cache 4:12
▝ Edit receipt.ts ■ Explore find the parsers 0:34 tasks ███▌ 3/5 Writing the tests Read ×4 Grep ×2
Model and effort. The model sits on the same background your prompts have in the transcript. The bars light up from low (one) to max (all five, in the violet Claude Code uses for its highest effort). It shows the level the last request was actually sent with, and follows /effort without waiting for the next request.
Session name. Other sessions message this one by the name ListAgents prints. A name Claude Code picked is just the folder plus a short suffix, so it takes the folder's place. After /rename the folder comes back and the name sits next to it as @name.
Git. + staged, ~ modified, ? untracked, ! conflicted, ↑ ↓ ahead and behind. It refreshes after any tool that can touch files.
fuel. Room left before auto-compact, measured against the point where Claude Code says it will compact. It moves during a turn, not only at the end. Yellow under 70%, red under 30%. In the red the number lights up and a /compact button appears; clicking it types the command into an empty prompt and leaves the Enter to you.
5h and 7d. Plan usage and the time until each window resets. The tick on the gauge is where you would be if you used the window evenly. Fill past the tick means you are going faster than that, and when that pace would hit the limit before the reset, the tick turns amber. A window that has reset since the last reply keeps its old value, dimmed, like ~64%, until the next reading comes in.
cache. Between turns: how long until the prompt cache lapses. Each request refreshes it; once it lapses, the next message pays to write the whole context to the cache again. It counts down by the second in its last ten minutes and reads cold when it has run out (in amber when there is a lot to rewrite). Whether entries live five minutes or an hour is read from your session's own transcript and remembered.
All the figures keep their width as they change, so the band holds still. While everything is fine the colours stay quiet. Yellow and red only show up when something needs a look.
A second line comes in under the band while a turn runs, and goes again when it ends:
Edit receipt.ts, Bash npm test, Grep parseTotal✓ (or ! when it failed)! where one failedBetween turns the line stays only for something that goes on without you: a subagent still running in the background, or a task still marked in progress.
The clocks and the spinner tick on the terminal's own frame clock, so the band itself never redraws for a second hand.

Rest the pointer on a part and it lights up, and a card opens above the band:
/context's colours with the compaction reserve hatched at the end; how full it was over the last turns; how much the last turn added, and about how many more turns like it fitClicking the model, the session name, the folder or the branch copies it. If clicks don't reach the band in your terminal, ctrl+x tab focuses it and Enter presses the focused item.

Everything goes on one line when it fits, otherwise on two. Each line then trims itself on its own: first the reset times go and the gauges get shorter, then the gauges and the effort meter go. If that is still not enough, whole parts drop out in this order: cost, cache, 7d, the @name tag, 5h, git. The model, the session name and fuel always stay. The activity line does the same on its own: finished subagents go first, then the tally, then tasks; the tool running now and the subagents still running stay.
In /config, under byj-statusline:
| Option | Default | |
|---|---|---|
layout | auto | two-line keeps the two lines apart even when one would fit |
icons | symbols | emoji brings back the old statusline's look |
details | on | the hover cards |
activity | on | the line of tools, subagents and tasks while Claude works |
cache | on | the prompt cache countdown between turns |
showCost | off | adds the session's cost |
alerts | on | a toast when fuel drops under 30%, when a window passes 90%, or when a window past half is on pace to run out before it resets |
Changing an option reloads the band without losing what it has measured.
The numbers come from Claude Code itself: usage and rate limits from the session, the effort from each request, the compaction point from the same breakdown /context uses, and the tools, subagents and tasks from the events Claude Code raises as they happen. Besides that it reads ~/.claude/sessions/*.json for the session name and the effort from your settings, runs git status, and once a session reads the last 256 KB of its own transcript with tail to learn the prompt cache's TTL. The last rate-limit reading and the TTL are kept in the plugin's storage so a new session has them before its first reply. It makes no network calls.
git clone https://github.com/BitYoungjae/byj-cc-statusline
claude --plugin-dir ./byj-cc-statusline # load it for one session; edits reload live
claude plugin test ./byj-cc-statusline # unit and UI tests
claude plugin validate ./byj-cc-statusline
hooks/register.tsx collects the numbers. How they look is decided in hooks/lib/, which never talks to the engine, so most of it is tested as plain functions. hooks/live/ holds the two small modules that tick on the terminal: the clocks and the spinner.
MIT
hooks/register.tsx 1206 lines1// byj-statusline: a status band above the prompt.
2//
3// This file gathers the figures and hands them to the drawing; everything it
4// draws is decided in `lib/`, which never touches `$`. The engine is asked
5// (`$.session.usage()`, `$.session.model()`) and says when a figure moves
6// (`session.measure`, `turn.step`, `session.compact`), and what the session
7// is doing (`turn.start`, `tool.call`, `agent.spawn`, `turn.complete`), so
8// nothing is parsed and no transcript is read. `$.store` keeps the rate
9// limits and the cache's TTL across sessions.
10//
11// `$` is only ever passed to functions declared at the top of this file, which
12// is why the helpers live out here and not inside `register`.
13
14import { atom, read, update } from 'claude-code'
15import type {
16 ClientProps,
17 ElementConstructor,
18 EngineInterface,
19 PluginOptions,
20 Register,
21 RenderElement,
22 RenderSurface,
23 SessionContextBreakdown,
24 SessionContextUsage,
25 SessionCost,
26 SessionRateLimit,
27 Timer,
28 TurnUsage,
29} from 'claude-code'
30
31import type { Activity, CacheState, ContextPart, GitInfo, Identity, TurnReceipt, Usage, WindowReading } from '../types'
32import type { ClockProps } from './live/clock'
33import {
34 agentStatusOf,
35 agentTool,
36 createTask,
37 describeTool,
38 EDIT_TOOLS,
39 EMPTY_ACTIVITY,
40 endTurn,
41 finishAgent,
42 finishTool,
43 phrase,
44 pruneAgents,
45 receiptOf,
46 spawnAgent,
47 startTool,
48 startTurn,
49 todoList,
50 updateTask,
51 AGENT_LINGER_MS,
52} from './lib/activity'
53import { arrange } from './lib/arrange'
54import {
55 EMPTY_CACHE,
56 hitRatioOf,
57 learnTtl,
58 toTtl,
59 TRANSCRIPT_TAIL_BYTES,
60 TTL_1H,
61 TTL_5M,
62 ttlOfTranscript,
63} from './lib/cache'
64import { WINDOW_SPAN_MS } from './lib/cards'
65import { baseName, countdown, fuel, pace } from './lib/format'
66import { GIT_STATUS_ARGV, parseGitStatus } from './lib/git'
67import { parseEntry, pickName, registryFiles } from './lib/session-name'
68import { effortSetting } from './lib/settings'
69import { paletteOf, separatorPart, type Live, type Settings } from './lib/style'
70import { view } from './lib/view'
71
72const TICK_MS = 30_000
73/** Tool calls come in bursts; git is asked at most this often for them. */
74const GIT_QUIET_MS = 1_500
75/** A burst of tool events draws once: the activity is written at most this often. */
76const ACTIVITY_COALESCE_MS = 50
77/** What a subagent's own tools change shows only in its card: written at most once a second. */
78const AGENT_COALESCE_MS = 1_000
79const STORE_KEY = 'rate-limits'
80const TTL_KEY = 'cache-ttl'
81/** The tools that can change what `git status` says. */
82const MAY_TOUCH_FILES = new Set(['Bash', ...EDIT_TOOLS])
83/** Turns of fill kept for the growth figures. */
84const HISTORY = 12
85
86const identity = atom(
87 { plugin: 'byj-statusline', key: 'identity' } as const,
88 { model: '', effort: null, dir: '', cwd: '', sessionName: null },
89)
90const git = atom({ plugin: 'byj-statusline', key: 'git' } as const, null)
91const usage = atom(
92 { plugin: 'byj-statusline', key: 'usage' } as const,
93 {
94 tokens: null,
95 window: 0,
96 isAutoCompact: true,
97 compactAt: null,
98 parts: [],
99 history: [],
100 costUsd: null,
101 windows: { five_hour: null, seven_day: null },
102 },
103)
104const doing = atom({ plugin: 'byj-statusline', key: 'activity' } as const, EMPTY_ACTIVITY)
105const cached = atom({ plugin: 'byj-statusline', key: 'cache' } as const, EMPTY_CACHE)
106const clock = atom({ plugin: 'byj-statusline', key: 'now' } as const, 0)
107
108type Options = Settings & { hasCards: boolean }
109
110// What the module knows between events. A reload starts these over, and
111// `session.start` fires again to fill them.
112let settings: Options = {
113 layout: 'auto',
114 icons: 'symbols',
115 showCost: false,
116 hasActivity: true,
117 hasCache: true,
118 hasCards: true,
119}
120let hasAlerts = true
121
122// The last reading of each window. Merged per window, never replaced: Claude
123// Code drops a window the moment it resets, and the gauge should keep showing
124// the last value, dimmed, until the next response brings the new one.
125let windows: Usage['windows'] = { five_hour: null, seven_day: null }
126let isAutoCompact = true
127
128// The context as last measured. `session.measure` waits for a turn to end,
129// so a long turn's own requests (`turn.step`) move the fill in between, and a
130// compaction sets it at once.
131let figures: Usage | null = null
132let compactAt: number | null = null
133let parts: ContextPart[] = []
134let history: number[] = []
135
136// How hard the model is asked to think. The truth is what the last request
137// carried (`turn.step`); until there is one, and when the person changes it
138// between requests, it is what the settings say.
139let effort: string | null = null
140let savedEffort: string | null | undefined
141let isEffortRestored = false
142let lastIdentity: Identity | null = null
143
144// What the session is doing, and where its running turn started from, for
145// the receipt when it ends.
146let activity: Activity = EMPTY_ACTIVITY
147let turnFrom: { cost: number | null; fill: number | null } = { cost: null, fill: null }
148let turnEdits = 0
149const scheduled = new Set<number>()
150
151// The main thread's prompt cache: when its last request went out, and how
152// long an entry lives (read off the transcript, or learnt, and kept in the
153// store). The transcript is read a few times a session at most.
154let cache: CacheState = EMPTY_CACHE
155let ttlReads = 0
156const TTL_READS = 3
157
158// How sure the band is of the TTL. Seen this session (in the transcript, or
159// in what the engine says of a resumed cache) outranks learnt from a gap,
160// which outranks assumed (the default, or what an earlier session saw).
161type TtlSource = 'assumed' | 'learnt' | 'seen'
162const TTL_RANK: Record<TtlSource, number> = { assumed: 0, learnt: 1, seen: 2 }
163let ttlSource: TtlSource = 'assumed'
164
165let timer: Timer | null = null
166let beginning: Promise<void> | null = null
167let isGitBusy = false
168let isGitQueued = false
169let isGitScheduled = false
170let lastFuelLevel: string | null = null
171const warned = new Set<string>()
172const written = new Map<string, string>()
173
174function settingsOf(options: PluginOptions): Options {
175 return {
176 layout: options.layout === 'two-line' ? 'two-line' : 'auto',
177 icons: options.icons === 'emoji' ? 'emoji' : 'symbols',
178 showCost: options.showCost === true,
179 hasActivity: options.activity !== false,
180 hasCache: options.cache !== false,
181 hasCards: options.details !== false,
182 }
183}
184
185function toReading(raw: unknown): WindowReading | null {
186 if (typeof raw !== 'object' || raw === null) return null
187
188 const { percentUsed, resetsAt } = raw as Record<string, unknown>
189
190 if (typeof percentUsed !== 'number') return null
191
192 return {
193 percentUsed,
194 resetsAt: typeof resetsAt === 'string' ? resetsAt : null,
195 }
196}
197
198/** What `$.store` kept from an earlier session, or nothing when it is not the shape written. */
199function toWindows(raw: unknown): Usage['windows'] {
200 const stored = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<
201 string,
202 unknown
203 >
204
205 return {
206 five_hour: toReading(stored.five_hour),
207 seven_day: toReading(stored.seven_day),
208 }
209}
210
211/**
212 * Writes a value only when it differs from the last one written: a redraw per
213 * no-op would add up. A write that failed is forgotten, so the next one tries
214 * again.
215 */
216async function sync<T>(key: string, value: T, write: (value: T) => Promise<unknown>) {
217 const json = JSON.stringify(value)
218
219 if (written.get(key) === json) return
220
221 written.set(key, json)
222
223 try {
224 await write(value)
225 } catch (error) {
226 if (written.get(key) === json) written.delete(key)
227
228 throw error
229 }
230}
231
232// ── identity: model, directory, messaging name ──
233
234async function findSessionName($: EngineInterface, sessionId: string) {
235 try {
236 const configDir =
237 (await $.env.get('CLAUDE_CONFIG_DIR')) ??
238 `${(await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? ''}/.claude`
239 const dir = `${configDir}/sessions`
240 const files = registryFiles(await $.fs.list(dir))
241
242 // One file at a time, so one cut off mid-write costs only itself.
243 const entries = await Promise.all(
244 files.map(async file => {
245 try {
246 return parseEntry(await $.fs.read(`${dir}/${file}`))
247 } catch {
248 return null
249 }
250 }),
251 )
252
253 return pickName(entries, sessionId)
254 } catch {
255 return null
256 }
257}
258
259async function publishIdentity($: EngineInterface, next: Identity) {
260 lastIdentity = next
261
262 try {
263 await sync('identity', next, value => update($, identity, () => value))
264 } catch {
265 // The band shows the identity it had.
266 }
267}
268
269/**
270 * The effort the settings hold for the model. A change since the last read is
271 * the person switching it (`/effort`, `/model`), so it takes over at once
272 * instead of waiting for the next request to confirm it. Settings that name
273 * no level leave the model's default, which only a request can tell.
274 */
275function noteEffortSetting(value: string | null) {
276 if (value === savedEffort) return
277
278 savedEffort = value
279
280 // After a reload the effort shown is the last request's; the first read of the settings changes nothing.
281 if (isEffortRestored) {
282 isEffortRestored = false
283
284 return
285 }
286
287 if (value !== null) effort = value
288}
289
290/** What a request of the main thread carried: the level it truly ran at, or none for a model without effort. */
291async function noteEffort($: EngineInterface, level: string | number | undefined) {
292 const next = level === undefined ? null : String(level)
293
294 if (next === effort) return
295
296 effort = next
297
298 // Before the first identity is read there is nothing to amend: the read picks it up.
299 if (lastIdentity !== null) {
300 await publishIdentity($, { ...lastIdentity, effort })
301 }
302}
303
304async function refreshIdentity($: EngineInterface) {
305 try {
306 const [model, cwd, sessionId, saved] = await Promise.all([
307 $.session.model(),
308 $.session.cwd(),
309 $.session.id(),
310 $.settings.read().catch(() => null),
311 ])
312 const sessionName = await findSessionName($, sessionId)
313
314 // Unreadable settings keep what was known.
315 if (saved !== null) noteEffortSetting(effortSetting(saved, model))
316
317 await publishIdentity($, { model, effort, dir: baseName(cwd), cwd, sessionName })
318 } catch {
319 // The band shows what it has.
320 }
321}
322
323// ── what the band's buttons do ──
324
325/** Puts a value on the clipboard and says so: a copy nobody sees land feels like none. */
326async function copyText($: EngineInterface, surface: RenderSurface, text: string) {
327 const shown = text.length > 48 ? `${text.slice(0, 47)}…` : text
328
329 try {
330 const result = await $.ui.copy({ text, surface })
331
332 $.ui.toast(
333 result.isCopied ? `Copied ${shown}` : `Could not copy (${result.reason})`,
334 { timeoutMs: 2_000 },
335 )
336 } catch {
337 $.ui.toast('Could not copy.', { timeoutMs: 2_000 })
338 }
339}
340
341/**
342 * Types /compact into the prompt, and stops there. Compacting rewrites the
343 * conversation, so it is the person's Enter; and a draft in progress is never
344 * written over.
345 */
346async function offerCompact($: EngineInterface) {
347 try {
348 const box = await $.prompt.read()
349
350 if (box.text.trim() !== '') {
351 $.ui.toast('Finish or clear the prompt, then press again.', { timeoutMs: 3_000 })
352
353 return
354 }
355
356 await $.prompt.fill({ text: '/compact', mode: 'replace' })
357 } catch {
358 // Nothing to fall back on: the button is a shortcut, the command still works typed.
359 }
360}
361
362// ── git ──
363
364async function refreshGit($: EngineInterface) {
365 // One status at a time; a request that arrives meanwhile runs once more after.
366 if (isGitBusy) {
367 isGitQueued = true
368
369 return
370 }
371
372 isGitBusy = true
373
374 try {
375 do {
376 isGitQueued = false
377
378 let info: GitInfo | null = null
379
380 try {
381 const cwd = await $.session.cwd()
382 const ran = await $.process.run(GIT_STATUS_ARGV, { cwd, timeoutMs: 5_000 })
383
384 info = ran.exitCode === 0 ? parseGitStatus(ran.stdout) : null
385 } catch {
386 // Not a repository, or no git: no segment.
387 }
388
389 await sync('git', info, value => update($, git, () => value)).catch(() => {})
390 } while (isGitQueued)
391 } finally {
392 isGitBusy = false
393 }
394}
395
396/** A burst of tool calls asks once, after it has gone quiet for a moment. */
397function scheduleGit($: EngineInterface) {
398 if (isGitScheduled) return
399
400 isGitScheduled = true
401 $.clock.after(GIT_QUIET_MS, () => {
402 isGitScheduled = false
403 void refreshGit($)
404 })
405}
406
407// ── activity: the turn, its tools, subagents, tasks ──
408
409async function publishActivity($: EngineInterface) {
410 try {
411 await sync('activity', activity, value => update($, doing, () => value))
412 } catch {
413 // The band keeps what it last drew.
414 }
415}
416
417/**
418 * Tools come and go in bursts (a dozen reads at once): the line is written
419 * once the burst has landed. What only a card shows waits longer.
420 */
421function scheduleActivity($: EngineInterface, ms = ACTIVITY_COALESCE_MS) {
422 if (scheduled.has(ms)) return
423
424 scheduled.add(ms)
425 $.clock.after(ms, () => {
426 scheduled.delete(ms)
427 void publishActivity($)
428 })
429}
430
431/** What the session has spent so far, as /cost has it; the plain call costs nothing. */
432async function spentSoFar($: EngineInterface): Promise<number | null> {
433 try {
434 return (await $.session.usage()).cost?.usd ?? null
435 } catch {
436 return null
437 }
438}
439
440async function beginTurn($: EngineInterface) {
441 try {
442 const [now, cost] = await Promise.all([$.clock.now(), spentSoFar($)])
443
444 activity = startTurn(activity, now)
445 turnFrom = { cost, fill: figures?.tokens ?? null }
446 turnEdits = 0
447 scheduleActivity($)
448 } catch {
449 // The line starts with the first tool instead.
450 }
451}
452
453async function finishTurn($: EngineInterface, durationMs: number, reason: TurnReceipt['reason']) {
454 try {
455 const [now, cost] = await Promise.all([$.clock.now(), spentSoFar($)])
456 const receipt = receiptOf({
457 startedAt: now - durationMs,
458 endedAt: now,
459 tally: activity.tally,
460 edits: turnEdits,
461 reason,
462 cost: { before: turnFrom.cost, after: cost },
463 fill: { before: turnFrom.fill, after: figures?.tokens ?? null },
464 })
465
466 activity = endTurn(activity, receipt)
467 await publishActivity($)
468 } catch {
469 // The receipt is a nicety; the turn is over either way.
470 }
471}
472
473type ToolEvent = { tool: unknown; tool_use_id: string; agentId?: string }
474
475/** A tool is about to run: the main loop's joins the running ones, an agent's says what the agent does. */
476async function noteToolStart($: EngineInterface, e: ToolEvent) {
477 try {
478 const tool = String(e.tool)
479 const target = describeTool(tool, e as unknown as Record<string, unknown>)
480
481 if (e.agentId === undefined) {
482 // The clock first, then the change: a change made across an await would
483 // write back over what another hook wrote meanwhile.
484 const startedAt = await $.clock.now()
485
486 activity = startTool(activity, { id: e.tool_use_id, tool, target, startedAt })
487 scheduleActivity($)
488 } else {
489 activity = agentTool(activity, e.agentId, phrase(tool, target))
490 scheduleActivity($, AGENT_COALESCE_MS)
491 }
492 } catch {
493 // The line misses one tool.
494 }
495}
496
497/** The task list the main loop keeps, as its tools leave it. */
498function noteTasks(tool: string, input: Record<string, unknown>, result: unknown) {
499 if (tool === 'TodoWrite') {
500 activity = { ...activity, tasks: todoList(input.todos) }
501 } else if (tool === 'TaskCreate') {
502 const task = (result as { task?: { id?: unknown } } | undefined)?.task
503 const id = typeof task?.id === 'string' ? task.id : null
504 const subject = typeof input.subject === 'string' ? input.subject : ''
505
506 if (id !== null && subject !== '') {
507 activity = {
508 ...activity,
509 tasks: createTask(activity.tasks, id, subject, typeof input.activeForm === 'string' ? input.activeForm : null),
510 }
511 }
512 } else if (tool === 'TaskUpdate' && typeof input.taskId === 'string') {
513 activity = { ...activity, tasks: updateTask(activity.tasks, { ...input, taskId: input.taskId }) }
514 }
515}
516
517async function noteToolEnd($: EngineInterface, e: ToolEvent, ran: { isError?: boolean; deny?: string; result?: unknown }) {
518 try {
519 const tool = String(e.tool)
520 const isError = ran.isError === true || ran.deny !== undefined
521
522 if (e.agentId === undefined) {
523 activity = finishTool(activity, e.tool_use_id, tool, isError)
524
525 if (!isError) {
526 if (EDIT_TOOLS.has(tool)) turnEdits += 1
527 noteTasks(tool, e as unknown as Record<string, unknown>, ran.result)
528 }
529
530 scheduleActivity($)
531 } else {
532 activity = agentTool(activity, e.agentId, null)
533 scheduleActivity($, AGENT_COALESCE_MS)
534 }
535 } catch {
536 // As above.
537 }
538}
539
540/** Finished agents leave the line once they have lingered. */
541async function pruneActivity($: EngineInterface) {
542 try {
543 const now = await $.clock.now()
544 const pruned = pruneAgents(activity, now)
545
546 if (pruned !== null) {
547 activity = pruned
548 await publishActivity($)
549 }
550 } catch {
551 // The next tick tries again.
552 }
553}
554
555// ── the prompt cache ──
556
557async function publishCache($: EngineInterface) {
558 try {
559 await sync('cache', cache, value => update($, cached, () => value))
560 } catch {
561 // As above.
562 }
563}
564
565/**
566 * A TTL the session showed: it holds from now on, and for the sessions after,
567 * unless something surer has said otherwise. A guess never overrides a sight.
568 */
569function adoptTtl($: EngineInterface, ttlMs: number, source: Exclude<TtlSource, 'assumed'>) {
570 if (TTL_RANK[source] < TTL_RANK[ttlSource]) return
571
572 ttlSource = source
573
574 if (cache.isTtlKnown && cache.ttlMs === ttlMs) return
575
576 cache = { ...cache, ttlMs, isTtlKnown: true }
577 $.store.set(TTL_KEY, ttlMs).catch(() => {})
578}
579
580/**
581 * Reads the TTL off the end of the transcript, where each response's cache
582 * writes are split by TTL. Once a session it is found, it is not read again;
583 * where there is no `tail` (or nothing written yet) it gives up after a few.
584 */
585async function readTtl($: EngineInterface, transcript: string) {
586 if (ttlReads >= TTL_READS || transcript === '') return
587
588 ttlReads += 1
589
590 try {
591 const ran = await $.process.run(['tail', '-c', String(TRANSCRIPT_TAIL_BYTES), transcript], { timeoutMs: 3_000 })
592 const ttlMs = ran.exitCode === 0 ? ttlOfTranscript(ran.stdout) : null
593
594 if (ttlMs === null) return
595
596 ttlReads = TTL_READS
597 adoptTtl($, ttlMs, 'seen')
598 await publishCache($)
599 } catch {
600 // The gap between requests still teaches it, more slowly.
601 }
602}
603
604/** A main-loop request came back: the entry is fresh from when it went out, for as long as its TTL. */
605async function noteRequest($: EngineInterface, sentAt: number, spent: TurnUsage) {
606 const hitRatio = hitRatioOf(spent)
607 const gap = cache.lastRequestAt === null ? null : sentAt - cache.lastRequestAt
608 const learnt = gap !== null && hitRatio !== null ? learnTtl(gap, hitRatio) : null
609
610 if (learnt !== null) adoptTtl($, learnt, 'learnt')
611
612 cache = {
613 ...cache,
614 lastRequestAt: sentAt,
615 hitRatio,
616 tokens: spent.input_tokens + spent.cache_read_input_tokens + spent.cache_creation_input_tokens,
617 }
618
619 await publishCache($)
620}
621
622// ── usage: context, rate limits, cost ──
623
624async function raiseAlerts($: EngineInterface, current: Usage) {
625 const reading = fuel(current.tokens, current.window, current.isAutoCompact, current.compactAt)
626
627 if (reading !== null) {
628 // On the way down only: the first reading is a state, not an event.
629 if (reading.level === 'bad' && lastFuelLevel !== null && lastFuelLevel !== 'bad') {
630 $.ui.toast(`Fuel is down to ${reading.percent}%: /compact or /clear soon.`)
631 }
632
633 lastFuelLevel = reading.level
634 }
635
636 const now = await $.clock.now()
637
638 for (const [kind, label] of [
639 ['five_hour', '5h'],
640 ['seven_day', '7d'],
641 ] as const) {
642 const window = current.windows[kind]
643
644 if (window === null) continue
645
646 const resetsAt = window.resetsAt === null ? NaN : Date.parse(window.resetsAt)
647
648 // A remembered reading already past its reset is history, not news.
649 if (resetsAt <= now) continue
650
651 const until = Number.isFinite(resetsAt) ? `, resets in ${countdown(resetsAt - now)}` : ''
652
653 if (window.percentUsed >= 90) {
654 const key = `${kind}:high:${window.resetsAt}`
655
656 if (!warned.has(key)) {
657 warned.add(key)
658 $.ui.toast(`${label} usage is at ${Math.round(window.percentUsed)}%${until}.`)
659 }
660
661 continue
662 }
663
664 // Before the line, the pace: half used and burning fast enough to run
665 // out before the reset is worth saying once, while there is time to act.
666 const step = pace(window.percentUsed, resetsAt, now, WINDOW_SPAN_MS[kind])
667 const key = `${kind}:pace:${window.resetsAt}`
668
669 if (window.percentUsed >= 50 && step !== null && step.runsOutInMs !== null && !warned.has(key)) {
670 warned.add(key)
671 $.ui.toast(
672 `${label} usage is at ${Math.round(window.percentUsed)}%: at this pace it runs out in ${countdown(step.runsOutInMs)}${until}.`,
673 )
674 }
675 }
676}
677
678/**
679 * Draws what the module knows: the last measurement with the live fill,
680 * breakdown and history over it. Never throws: it runs inside hooks that sit
681 * on a compaction's or a request's way, and a status must not stand in it.
682 */
683async function publishUsage($: EngineInterface) {
684 if (figures === null) return
685
686 try {
687 const current: Usage = { ...figures, isAutoCompact, compactAt, parts, history, windows }
688
689 await sync('usage', current, value => update($, usage, () => value))
690
691 if (hasAlerts) await raiseAlerts($, current)
692 } catch {
693 // The band keeps what it last drew.
694 }
695}
696
697async function applyUsage(
698 $: EngineInterface,
699 context: SessionContextUsage,
700 rateLimits: readonly SessionRateLimit[],
701 cost: SessionCost | undefined,
702) {
703 try {
704 // The windows the last session remembered must be loaded before this one merges into them.
705 await ensureStarted($)
706
707 const next = { ...windows }
708
709 for (const limit of rateLimits) {
710 if (limit.kind !== 'five_hour' && limit.kind !== 'seven_day') continue
711
712 next[limit.kind] = {
713 percentUsed: limit.percentUsed,
714 resetsAt: limit.resetsAt ?? null,
715 }
716 }
717
718 if (JSON.stringify(next) !== JSON.stringify(windows)) {
719 windows = next
720 // So the next session has something to show before its first response.
721 $.store.set(STORE_KEY, next).catch(() => {})
722 }
723
724 figures = {
725 tokens: context.tokens ?? figures?.tokens ?? null,
726 window: context.window,
727 isAutoCompact,
728 compactAt,
729 parts,
730 history,
731 costUsd: cost?.usd ?? null,
732 windows,
733 }
734
735 await publishUsage($)
736 } catch {
737 // A figure that cannot be read leaves the last one on screen.
738 }
739}
740
741/** The rows of the context, largest first, and the point the engine compacts at. */
742function readBreakdown(breakdown: SessionContextBreakdown | undefined) {
743 if (breakdown === undefined) return
744
745 isAutoCompact = breakdown.isAutoCompactEnabled
746 compactAt = breakdown.isAutoCompactEnabled ? (breakdown.autoCompactThreshold ?? null) : null
747 parts = breakdown.categories
748 .filter(row => row.kind === 'used' && row.tokens > 0)
749 .sort((a, b) => b.tokens - a.tokens)
750 .map(row => ({ name: row.name, tokens: row.tokens, color: row.color }))
751}
752
753/**
754 * The breakdown /context draws, counted from the last response and local
755 * estimates (`summary`): no request is made for it.
756 */
757async function refreshBreakdown($: EngineInterface) {
758 try {
759 const { context } = await $.session.usage({ breakdown: 'summary' })
760
761 readBreakdown(context.breakdown)
762 await publishUsage($)
763 } catch {
764 // The gauge falls back on the fixed buffer.
765 }
766}
767
768async function refreshUsage($: EngineInterface) {
769 try {
770 const current = await $.session.usage({ breakdown: 'summary' })
771
772 readBreakdown(current.context.breakdown)
773 await applyUsage($, current.context, current.rateLimits, current.cost)
774 } catch {
775 // As above.
776 }
777}
778
779/** A request of the main loop came back: its input is the context's fill, now. */
780async function noteFill($: EngineInterface, tokens: number) {
781 if (figures === null || tokens <= 0) return
782
783 figures = { ...figures, tokens }
784 await publishUsage($)
785}
786
787/** A main-loop turn ended: the fill it ended at joins the history. */
788async function noteTurnEnd($: EngineInterface) {
789 const tokens = figures?.tokens ?? null
790
791 if (tokens === null || history[history.length - 1] === tokens) return
792
793 history = [...history, tokens].slice(-HISTORY)
794 await publishUsage($)
795}
796
797/**
798 * A resumed session's cache is as old as its last reply: the engine says how
799 * long ago that was, and whether the entry has outlived it, which says the
800 * TTL too: still warm past five minutes is an hour, gone within one is five
801 * minutes.
802 */
803async function noteResume($: EngineInterface, since: number | undefined, tokens: number | undefined, isExpired: boolean | undefined) {
804 if (since === undefined) return
805
806 try {
807 await ensureStarted($)
808
809 const now = await $.clock.now()
810 const sinceMs = since * 1_000
811
812 if (isExpired === false && sinceMs > TTL_5M) adoptTtl($, TTL_1H, 'seen')
813 if (isExpired === true && sinceMs < TTL_1H) adoptTtl($, TTL_5M, 'seen')
814
815 cache = { ...cache, lastRequestAt: now - sinceMs, tokens: tokens ?? cache.tokens, hitRatio: null }
816 await publishCache($)
817 } catch {
818 // The first request sets it right.
819 }
820}
821
822async function tick($: EngineInterface) {
823 try {
824 await sync('now', await $.clock.now(), value => update($, clock, () => value))
825 } catch {
826 // The countdowns move on the next tick.
827 }
828
829 void refreshGit($)
830 void pruneActivity($)
831 // Catches /config turning auto-compact off or on between replies.
832 void refreshBreakdown($)
833 await refreshIdentity($)
834}
835
836/**
837 * A reload (an edit, an option changed in /config) starts this module over,
838 * but the host keeps what it last drew: the band picks up from there instead
839 * of forgetting the fill's history, the cache's clock and the running agents.
840 * A session that ended cleared them, so a new one starts from nothing.
841 */
842async function resume($: EngineInterface) {
843 const [was, wasDoing, wasCached, who] = await Promise.all([
844 read($, usage),
845 read($, doing),
846 read($, cached),
847 read($, identity),
848 ])
849
850 // Each only where no event has set it meanwhile: what came in since is newer.
851 if (was.tokens !== null && figures === null) {
852 figures = was
853 history = was.history
854 parts = was.parts
855 compactAt = was.compactAt
856 isAutoCompact = was.isAutoCompact
857 }
858
859 // Nothing is in flight across a reload: it waits for a turn to end.
860 if (activity === EMPTY_ACTIVITY) activity = { ...wasDoing, turnStartedAt: null, running: [] }
861
862 if (cache.lastRequestAt === null && wasCached.lastRequestAt !== null) {
863 cache = wasCached
864 ttlSource = wasCached.isTtlKnown ? 'seen' : 'assumed'
865 }
866
867 if (who.model !== '' && lastIdentity === null) {
868 lastIdentity = who
869 effort = who.effort
870 // The settings are read next; they are a baseline then, not the person changing them.
871 isEffortRestored = true
872 }
873
874 if (!written.has('usage')) written.set('usage', JSON.stringify(was))
875 if (!written.has('identity')) written.set('identity', JSON.stringify(who))
876}
877
878async function begin($: EngineInterface) {
879 // The first prompt waits on `session.start`, so this waits on the store
880 // and the state alone: git and the disk are read in the background.
881 const [stored, ttl] = await Promise.all([
882 $.store.get(STORE_KEY).catch(() => undefined),
883 $.store.get(TTL_KEY).catch(() => undefined),
884 resume($).catch(() => {}),
885 ])
886
887 windows = toWindows(stored)
888
889 const knownTtl = toTtl(ttl)
890
891 // What an earlier session saw is the best guess until this one sees it too.
892 if (knownTtl !== null && !cache.isTtlKnown) cache = { ...cache, ttlMs: knownTtl }
893
894 timer?.cancel()
895 timer = $.clock.every(TICK_MS, () => {
896 void tick($)
897 })
898
899 void Promise.all([tick($), refreshUsage($), publishCache($), publishActivity($)])
900}
901
902/**
903 * What a session that ended leaves behind: nothing of its own. The windows
904 * are the account's and stay; the TTL stays the best guess.
905 */
906async function clearSession($: EngineInterface) {
907 const was = figures
908
909 figures = null
910 history = []
911 parts = []
912 lastFuelLevel = null
913 ttlReads = 0
914 ttlSource = 'assumed'
915 activity = EMPTY_ACTIVITY
916 // What the TTL was stays the best guess; the next session confirms it for itself.
917 cache = { ...EMPTY_CACHE, ttlMs: cache.ttlMs }
918
919 try {
920 await Promise.all([
921 sync('usage', { ...(was ?? (await read($, usage))), tokens: null, history: [], parts: [], costUsd: null }, value =>
922 update($, usage, () => value),
923 ),
924 publishActivity($),
925 publishCache($),
926 ])
927 } catch {
928 // The next session's first figures write over them.
929 }
930}
931
932/**
933 * Starts the band once. `session.start` is the usual way in, but a /clear ends
934 * the session without starting another, and a mod switched on mid-session never
935 * saw a start, so the first event of any kind gets it going too.
936 */
937function ensureStarted($: EngineInterface): Promise<void> {
938 beginning ??= begin($)
939
940 return beginning
941}
942
943// ── drawing ──
944
945function clockProps(live: Extract<Live, { kind: 'clock' }>): ClockProps {
946 const { kind: _, ...props } = live
947
948 return props
949}
950
951/** A live part as the surface module that keeps it ticking. The paths are read off this source, so they are literals. */
952function liveElement(Client: ElementConstructor<ClientProps>, live: Live, key: string): RenderElement {
953 return live.kind === 'clock' ? (
954 <Client key={key} module="./live/clock.ts" props={clockProps(live)} />
955 ) : (
956 <Client key={key} module="./live/spinner.ts" props={{ color: live.color }} />
957 )
958}
959
960export const register: Register = (on, options) => {
961 settings = settingsOf(options)
962 hasAlerts = options.alerts !== false
963
964 // ── events ──
965
966 on('session.start', async ($, e, next) => {
967 await ensureStarted($)
968
969 return next(e)
970 })
971
972 on('session.end', async ($, e, next) => {
973 timer?.cancel()
974 timer = null
975 beginning = null
976 // A /clear ends the session: the next one's context starts from nothing.
977 await clearSession($)
978
979 return next(e)
980 })
981
982 // The engine says when a figure moves: after each turn, and when a window
983 // moves a whole point.
984 on('session.measure', async ($, e, next) => {
985 await applyUsage($, e.context, e.rateLimits, e.cost)
986
987 // The mix and the compaction point move with the fill; counted locally.
988 if (e.changed.includes('context')) void refreshBreakdown($)
989
990 return next(e)
991 })
992
993 // A compaction sets the fill at once; the next measurement waits for a response.
994 on('session.compact', async ($, e, next) => {
995 const result = await next(e)
996
997 if (
998 e.agentId === undefined &&
999 e.trigger !== 'precompute' &&
1000 result.skip === undefined &&
1001 result.tokensAfter !== undefined
1002 ) {
1003 await noteFill($, result.tokensAfter)
1004 await noteTurnEnd($)
1005 void refreshBreakdown($)
1006 // The conversation the cache held is gone; the next request writes the new one.
1007 cache = { ...cache, lastRequestAt: null, hitRatio: null, tokens: result.tokensAfter }
1008 void publishCache($)
1009 }
1010
1011 return result
1012 }).catch(($, e, next) => next(e))
1013
1014 on('classic.SessionStart', async ($, e, next) => {
1015 if (e.source === 'resume' || e.source === 'fork') {
1016 void readTtl($, e.transcript_path)
1017 void noteResume($, e.seconds_since_last_response, e.context_tokens, e.prompt_cache_likely_expired)
1018 }
1019
1020 return next(e)
1021 }).catch(($, e, next) => next(e))
1022
1023 // The main loop's turn is written down: its responses' cache writes say the TTL.
1024 on('classic.Stop', async ($, e, next) => {
1025 void readTtl($, e.transcript_path)
1026
1027 return next(e)
1028 }).catch(($, e, next) => next(e))
1029
1030 on('turn.start', async ($, e, next) => {
1031 await ensureStarted($)
1032 await beginTurn($)
1033
1034 return next(e)
1035 }).catch(($, e, next) => next(e))
1036
1037 on('turn.complete', async ($, e, next) => {
1038 await ensureStarted($)
1039
1040 if (e.agentId === undefined) {
1041 // A subagent's files reach git through its tools; its turns ask nothing more.
1042 void refreshGit($)
1043 void refreshIdentity($)
1044 await noteTurnEnd($)
1045 await finishTurn($, e.durationMs, e.reason)
1046 } else {
1047 const now = await $.clock.now()
1048 const before = activity
1049
1050 activity = finishAgent(activity, e.agentId, agentStatusOf(e.reason), now)
1051
1052 if (activity !== before) {
1053 scheduleActivity($)
1054 $.clock.after(AGENT_LINGER_MS + 100, () => {
1055 void pruneActivity($)
1056 })
1057 }
1058 }
1059
1060 return next(e)
1061 })
1062
1063 on('prompt.submit', async ($, e, next) => {
1064 await ensureStarted($)
1065 void refreshIdentity($)
1066
1067 return next(e)
1068 }).catch(($, e, next) => next(e))
1069
1070 // A model request: the one place that says what effort it runs at, and,
1071 // once it is back, what the context holds mid-turn and how the cache took
1072 // it. Observed, forwarded untouched.
1073 on('turn.step', async function* ($, e, next) {
1074 // Only the main thread's own requests: a subagent runs at an effort and a context of its own.
1075 const isMain = e.agentId === undefined
1076 // When the request went out, read as it goes: the cache counts from there.
1077 const sent = isMain ? $.clock.now().catch(() => null) : null
1078
1079 if (isMain) void noteEffort($, e.effort)
1080
1081 const result = yield* next(e)
1082 const spent = result.usage
1083
1084 if (sent !== null && spent !== null) {
1085 await noteFill(
1086 $,
1087 spent.input_tokens + spent.cache_read_input_tokens + spent.cache_creation_input_tokens,
1088 )
1089
1090 const sentAt = await sent
1091
1092 try {
1093 if (sentAt !== null) await noteRequest($, sentAt, spent)
1094 } catch {
1095 // The cache line waits for the next request.
1096 }
1097 }
1098
1099 return result
1100 })
1101
1102 on('agent.spawn', async ($, e, next) => {
1103 const at = $.clock.now().catch(() => null)
1104 const started = await next(e)
1105 const startedAt = await at
1106
1107 // Read the clock before the change: agents spawned together must all land.
1108 if (started.agentId !== undefined && startedAt !== null) {
1109 activity = spawnAgent(activity, {
1110 id: started.agentId,
1111 type: e.subagentType,
1112 description: e.description,
1113 startedAt,
1114 endedAt: null,
1115 status: 'running',
1116 tools: 0,
1117 doing: null,
1118 isBackground: e.background,
1119 })
1120 scheduleActivity($)
1121 }
1122
1123 return started
1124 }).catch(($, e, next) => next(e))
1125
1126 on('tool.call', async ($, e, next) => {
1127 await noteToolStart($, e)
1128
1129 const ran = await next(e)
1130
1131 await noteToolEnd($, e, ran)
1132
1133 if (MAY_TOUCH_FILES.has(String(e.tool))) scheduleGit($)
1134
1135 return ran
1136 }).catch(($, e, next) => next(e))
1137
1138 // ── drawing ──
1139
1140 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1141 // Whatever other plugins drew stays, above this band.
1142 const below = await next(e)
1143
1144 // A survey has the band; the status waits.
1145 if (e.props.hasSurvey) return below
1146
1147 const [who, repo, current, doingNow, cacheNow, now, ticked] = await Promise.all([
1148 read($, identity),
1149 read($, git),
1150 read($, usage),
1151 read($, doing),
1152 read($, cached),
1153 $.clock.now(),
1154 // Read to redraw on each tick, so reset countdowns move.
1155 read($, clock),
1156 ])
1157 const isTerminal = e.surface === 'terminal'
1158 // One cell of margin on the left, so the band lines up with the prompt's text.
1159 const columns = Math.max(1, e.props.bodyColumns - 1)
1160 const lines = arrange(
1161 {
1162 identity: who,
1163 git: repo,
1164 usage: current,
1165 activity: doingNow,
1166 cache: cacheNow,
1167 now: Math.max(now, ticked),
1168 isWorking: e.props.isWorking,
1169 isTerminal,
1170 },
1171 settings,
1172 columns,
1173 )
1174
1175 if (lines.length === 0) return below
1176
1177 const els = $.ui.resolve(e)
1178 const { Box, Button, Text } = els
1179 const Client = 'Client' in els ? els.Client : null
1180 const palette = paletteOf(isTerminal)
1181 const band = view(
1182 { Box, Button, Text },
1183 lines,
1184 {
1185 copy: text => press => copyText($, press.surface, text),
1186 compact: () => offerCompact($),
1187 live: (live, key) => (Client === null ? null : liveElement(Client, live, key)),
1188 },
1189 {
1190 columns,
1191 separator: separatorPart(settings),
1192 hasCards: settings.hasCards,
1193 hover: palette.hover,
1194 border: palette.border,
1195 },
1196 )
1197
1198 return (
1199 <Box flexDirection="column">
1200 {below}hooks/live/clock.ts 93 lines1// A clock that ticks on the surface's own frame clock, so the band need not
2// redraw every second: a countdown (the prompt cache) or a running time (a
3// subagent). The hooks module hands it the time as of its drawing; between
4// drawings it counts on by itself, and each new drawing sets it right again.
5
6import type { ClientModule } from 'claude-code'
7
8import { elapsed, padEnd, timeLeft } from '../lib/format'
9
10export type ClockProps = {
11 /** The time on the clock as the band drew it, in ms: left (`down`) or gone (`up`). */
12 ms: number
13 direction: 'up' | 'down'
14 color?: string
15 soonMs?: number
16 soonColor?: string
17 doneText?: string
18 doneColor?: string
19 /** Cells it always takes, padded on the right, so the line never shifts. */
20 width: number
21}
22
23type Local = {
24 /** The props of the latest drawing: the tick reads these, never the first call's. */
25 props: ClockProps
26 seconds: number
27 stop: (() => void) | null
28}
29
30// Per instance: the surface object is the same on every call while it lives.
31const locals = new WeakMap<object, Local>()
32
33function clockFace(props: ClockProps, seconds: number): { text: string; color?: string } {
34 const ms = props.ms + (props.direction === 'up' ? 1 : -1) * seconds * 1000
35
36 if (props.direction === 'up') {
37 return { text: padEnd(elapsed(ms), props.width), ...(props.color === undefined ? {} : { color: props.color }) }
38 }
39
40 const left = timeLeft(ms)
41
42 if (left === null) {
43 const done = props.doneText ?? '0:00'
44
45 return { text: padEnd(done, props.width), ...(props.doneColor === undefined ? {} : { color: props.doneColor }) }
46 }
47
48 const color = props.soonMs !== undefined && ms <= props.soonMs ? props.soonColor : props.color
49
50 return { text: padEnd(left, props.width), ...(color === undefined ? {} : { color }) }
51}
52
53const Clock: ClientModule<ClockProps, number> = (props, surface) => {
54 let local = locals.get(surface)
55
56 if (local === undefined) {
57 local = { props, seconds: 0, stop: null }
58 locals.set(surface, local)
59 } else if (local.props.ms !== props.ms || local.props.direction !== props.direction) {
60 local.seconds = 0
61 }
62
63 local.props = props
64
65 const current = local
66 const isOver = (at: Local) => at.props.direction === 'down' && at.props.ms - at.seconds * 1000 <= 0
67
68 if (current.stop === null && !isOver(current)) {
69 current.stop = surface.every(1000, () => {
70 const before = clockFace(current.props, current.seconds)
71
72 current.seconds += 1
73
74 const after = clockFace(current.props, current.seconds)
75
76 // A countdown that has run out has nothing more to say.
77 if (isOver(current)) {
78 current.stop?.()
79 current.stop = null
80 }
81
82 // Only a face that changed is drawn again: `52m` holds for a minute.
83 if (after.text !== before.text || after.color !== before.color) surface.setState(current.seconds)
84 })
85 }
86
87 const face = clockFace(props, current.seconds)
88
89 return surface.elements.Text({ ...(face.color === undefined ? {} : { color: face.color }), children: face.text })
90}
91
92export default Clock
93hooks/lib/activity.ts 285 lines1// What the session is doing, kept as plain values: each event the engine
2// raises (a tool starting and ending, a subagent spawned and done, a task
3// list written) is one function from the activity before to the activity
4// after. The hooks module calls them; nothing here touches `$`.
5
6import type { Activity, AgentRun, AgentStatus, TaskItem, ToolCount, ToolRun, TurnReceipt } from '../../types'
7import { baseName, clip } from './format'
8
9export const EMPTY_ACTIVITY: Activity = {
10 turnStartedAt: null,
11 running: [],
12 tally: [],
13 agents: [],
14 tasks: [],
15 lastTurn: null,
16}
17
18/** How long a finished subagent stays on the line, so its end is seen. */
19export const AGENT_LINGER_MS = 30_000
20/** The most subagents kept: every running one, then the latest finished up to this many in all. */
21const MAX_AGENTS = 8
22
23/** The tools that write files: what a receipt counts as edits. */
24export const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
25
26function text(value: unknown): string {
27 return typeof value === 'string' ? value : ''
28}
29
30/** `mcp__github__create_issue` reads as `create_issue`; a built-in keeps its name. */
31export function toolName(tool: string): string {
32 const match = /^mcp__.+?__(.+)$/.exec(tool)
33
34 return match?.[1] ?? tool
35}
36
37/** The host a URL names, or the URL as it is when it names none. */
38function host(url: string): string {
39 const match = /^[a-z]+:\/\/([^/?#]+)/i.exec(url)
40
41 return match?.[1] ?? url
42}
43
44/**
45 * What a tool call works on, as short as it reads: the file's name, the
46 * command's first line, the pattern, the host. Empty when there is nothing
47 * worth naming.
48 */
49export function describeTool(tool: string, input: Readonly<Record<string, unknown>>): string {
50 switch (tool) {
51 case 'Read':
52 case 'Edit':
53 case 'Write':
54 case 'MultiEdit':
55 return baseName(text(input.file_path))
56 case 'NotebookEdit':
57 return baseName(text(input.notebook_path))
58 case 'Bash':
59 return clip(text(input.command).split('\n')[0]?.trim() ?? '', 32)
60 case 'Grep':
61 case 'Glob':
62 return clip(text(input.pattern), 24)
63 case 'WebFetch':
64 return host(text(input.url))
65 case 'WebSearch':
66 return clip(text(input.query), 24)
67 case 'Agent':
68 return text(input.subagent_type) || clip(text(input.description), 24)
69 case 'Skill':
70 return text(input.skill)
71 default:
72 return ''
73 }
74}
75
76/** A tool run as one phrase: `Edit view.ts`, `Bash npm test`, `Glob`. */
77export function phrase(tool: string, target: string): string {
78 const name = toolName(tool)
79
80 return target === '' ? name : `${name} ${target}`
81}
82
83// ── the main loop's turn ──
84
85export function startTurn(activity: Activity, at: number): Activity {
86 return { ...activity, turnStartedAt: at, running: [], tally: [] }
87}
88
89export function endTurn(activity: Activity, receipt: TurnReceipt): Activity {
90 return { ...activity, turnStartedAt: null, running: [], lastTurn: receipt }
91}
92
93export function startTool(activity: Activity, run: ToolRun): Activity {
94 return { ...activity, running: [...activity.running.filter(one => one.id !== run.id), run] }
95}
96
97/** A tool came back: it leaves the running ones and counts toward the turn's tally. */
98export function finishTool(activity: Activity, id: string, tool: string, isError: boolean): Activity {
99 const name = toolName(tool)
100 const tally: ToolCount[] = activity.tally.some(one => one.tool === name)
101 ? activity.tally.map(one =>
102 one.tool === name ? { ...one, count: one.count + 1, errors: one.errors + (isError ? 1 : 0) } : one,
103 )
104 : [...activity.tally, { tool: name, count: 1, errors: isError ? 1 : 0 }]
105
106 return {
107 ...activity,
108 running: activity.running.filter(one => one.id !== id),
109 // Most used first; a tie keeps the order the tools first ran in.
110 tally: tally
111 .map((one, index) => ({ one, index }))
112 .sort((a, b) => b.one.count - a.one.count || a.index - b.index)
113 .map(({ one }) => one),
114 }
115}
116
117export type ReceiptInput = {
118 startedAt: number
119 endedAt: number
120 tally: readonly ToolCount[]
121 edits: number
122 reason: TurnReceipt['reason']
123 cost: { before: number | null; after: number | null }
124 fill: { before: number | null; after: number | null }
125}
126
127export function receiptOf(input: ReceiptInput): TurnReceipt {
128 const { cost, fill } = input
129 const spent = cost.before !== null && cost.after !== null ? cost.after - cost.before : null
130
131 return {
132 durationMs: Math.max(0, input.endedAt - input.startedAt),
133 tools: input.tally.reduce((sum, one) => sum + one.count, 0),
134 edits: input.edits,
135 errors: input.tally.reduce((sum, one) => sum + one.errors, 0),
136 // Under half a cent is rounding, not spend.
137 costUsd: spent !== null && spent >= 0.005 ? spent : null,
138 grewBy: fill.before !== null && fill.after !== null ? fill.after - fill.before : null,
139 reason: input.reason,
140 }
141}
142
143// ── subagents ──
144
145/**
146 * Running agents first, oldest first; then the finished ones, latest first.
147 * A workflow can run dozens at once: none that runs is dropped, only the
148 * finished ones past the cap.
149 */
150function ordered(agents: readonly AgentRun[]): AgentRun[] {
151 const running = agents.filter(one => one.status === 'running').sort((a, b) => a.startedAt - b.startedAt)
152 const finished = agents
153 .filter(one => one.status !== 'running')
154 .sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))
155
156 return [...running, ...finished.slice(0, Math.max(0, MAX_AGENTS - running.length))]
157}
158
159export function spawnAgent(activity: Activity, agent: AgentRun): Activity {
160 return { ...activity, agents: ordered([...activity.agents.filter(one => one.id !== agent.id), agent]) }
161}
162
163/** A tool inside an agent's loop started (`doing` its phrase) or came back (`doing` null). */
164export function agentTool(activity: Activity, agentId: string, doing: string | null): Activity {
165 if (!activity.agents.some(one => one.id === agentId)) return activity
166
167 return {
168 ...activity,
169 agents: activity.agents.map(one =>
170 one.id === agentId ? { ...one, doing, tools: one.tools + (doing === null ? 1 : 0) } : one,
171 ),
172 }
173}
174
175export function finishAgent(activity: Activity, agentId: string, status: AgentStatus, at: number): Activity {
176 if (!activity.agents.some(one => one.id === agentId && one.status === 'running')) return activity
177
178 return {
179 ...activity,
180 agents: ordered(
181 activity.agents.map(one => (one.id === agentId ? { ...one, status, endedAt: at, doing: null } : one)),
182 ),
183 }
184}
185
186/** Finished agents leave once they have lingered; null when none did, so nothing redraws. */
187export function pruneAgents(activity: Activity, now: number): Activity | null {
188 const agents = activity.agents.filter(
189 one => one.status === 'running' || (one.endedAt ?? 0) + AGENT_LINGER_MS > now,
190 )
191
192 return agents.length === activity.agents.length ? null : { ...activity, agents }
193}
194
195export function agentStatusOf(reason: string): AgentStatus {
196 if (reason === 'aborted') return 'stopped'
197
198 return reason === 'answer' ? 'done' : 'failed'
199}
200
201// ── tasks ──
202
203type Status = TaskItem['status']
204
205function status(value: unknown): Status | null {
206 return value === 'pending' || value === 'in_progress' || value === 'completed' ? value : null
207}
208
209/** TodoWrite hands the whole list each time: it replaces what was there. */
210export function todoList(todos: unknown): TaskItem[] {
211 if (!Array.isArray(todos)) return []
212
213 return todos.flatMap((raw: unknown, index) => {
214 if (typeof raw !== 'object' || raw === null) return []
215
216 const todo = raw as Record<string, unknown>
217 const state = status(todo.status)
218 const subject = text(todo.content)
219
220 if (state === null || subject === '') return []
221
222 return [{ id: `todo-${index}`, subject, activeForm: text(todo.activeForm) || null, status: state }]
223 })
224}
225
226export function createTask(tasks: readonly TaskItem[], id: string, subject: string, activeForm: string | null): TaskItem[] {
227 return [...tasks.filter(one => one.id !== id), { id, subject, activeForm, status: 'pending' }]
228}
229
230export type TaskChange = {
231 taskId: string
232 status?: unknown
233 subject?: unknown
234 activeForm?: unknown
235}
236
237export function updateTask(tasks: readonly TaskItem[], change: TaskChange): TaskItem[] {
238 if (change.status === 'deleted') return tasks.filter(one => one.id !== change.taskId)
239
240 return tasks.map(one => {
241 if (one.id !== change.taskId) return one
242
243 return {
244 ...one,
245 status: status(change.status) ?? one.status,
246 subject: text(change.subject) || one.subject,
247 activeForm: text(change.activeForm) || one.activeForm,
248 }
249 })
250}
251
252export type Progress = {
253 done: number
254 total: number
255 /** The task in progress, else the next one waiting; null when all are done. */
256 current: TaskItem | null
257}
258
259export function progress(tasks: readonly TaskItem[]): Progress {
260 return {
261 done: tasks.filter(one => one.status === 'completed').length,
262 total: tasks.length,
263 current:
264 tasks.find(one => one.status === 'in_progress') ?? tasks.find(one => one.status === 'pending') ?? null,
265 }
266}
267
268// ── whether there is anything to show ──
269
270export function hasRunningAgents(activity: Activity): boolean {
271 return activity.agents.some(one => one.status === 'running')
272}
273
274/**
275 * Whether the activity line has anything to say. While a turn runs, always.
276 * Between turns, only for what goes on without the person: a subagent still
277 * working (or just finished), a task still in progress.
278 */
279export function isLive(activity: Activity, isWorking: boolean): boolean {
280 if (isWorking) return true
281 if (activity.agents.length > 0) return true
282
283 return activity.tasks.some(one => one.status === 'in_progress')
284}
285hooks/lib/arrange.ts 141 lines1// How the segments fit the width on hand: one line when everything fits,
2// otherwise two, each line as rich as its own room allows; and under them,
3// while anything is going on, the activity line.
4
5import { width } from './format'
6import { build } from './segments'
7import { SEPARATOR_WIDTH, type Detail, type Frame, type Live, type Part, type Row, type Seg, type Settings } from './style'
8
9export function partsWidth(parts: readonly Part[]): number {
10 let cells = 0
11
12 for (const part of parts) cells += width(part.text)
13
14 return cells
15}
16
17export function lineWidth(line: readonly Seg[]): number {
18 let cells = 0
19
20 for (const seg of line) cells += partsWidth(seg.parts)
21
22 return cells + SEPARATOR_WIDTH * Math.max(0, line.length - 1)
23}
24
25/** A line as the text it draws, segments joined by ` · `. */
26export function plain(line: readonly Seg[]): string {
27 return line.map(seg => seg.parts.map(part => part.text).join('')).join(' · ')
28}
29
30/** What the drawing makes of one run of parts: plain text, one button, or one live element. */
31export type Chunk = {
32 parts: Part[]
33 copy?: { id: string; text: string }
34 press?: 'compact'
35 live?: Live
36}
37
38/**
39 * A segment's parts as they draw: parts that share a copy target become one
40 * chunk, a part that is a press or a live element its own, the rest one each.
41 */
42export function chunkParts(parts: readonly Part[]): Chunk[] {
43 const out: Chunk[] = []
44
45 for (const part of parts) {
46 if (part.text === '') continue
47
48 const last = out[out.length - 1]
49
50 if (part.copy !== undefined && last?.copy?.id === part.copy.id) {
51 last.parts.push(part)
52 } else {
53 out.push({
54 parts: [part],
55 ...(part.copy === undefined ? {} : { copy: part.copy }),
56 ...(part.press === undefined ? {} : { press: part.press }),
57 ...(part.live === undefined ? {} : { live: part.live }),
58 })
59 }
60 }
61
62 return out
63}
64
65/** Drops segments, the least wanted first, until the line fits or only the essentials are left. */
66function shed(line: readonly Seg[], columns: number): Seg[] {
67 const kept = [...line]
68
69 while (lineWidth(kept) > columns) {
70 let worst = -1
71
72 for (const [index, seg] of kept.entries()) {
73 if (seg.priority > 0 && seg.priority > (kept[worst]?.priority ?? 0)) worst = index
74 }
75
76 if (worst === -1) break
77 kept.splice(worst, 1)
78 }
79
80 return kept
81}
82
83/** The segments at each level of detail, each level built once however many lines ask for it. */
84type Levels = (detail: Detail) => Seg[]
85
86function levelsOf(frame: Frame, settings: Settings): Levels {
87 const built = new Map<Detail, Seg[]>()
88
89 return detail => {
90 let segs = built.get(detail)
91
92 if (segs === undefined) {
93 segs = build(frame, settings, detail)
94 built.set(detail, segs)
95 }
96
97 return segs
98 }
99}
100
101/** One line's segments at the richest detail that fits; failing that, the bare minimum, shed by priority. */
102function bestRow(levels: Levels, columns: number, rows: readonly Row[]): Seg[] {
103 const of = (detail: Detail) => levels(detail).filter(seg => rows.includes(seg.row))
104
105 for (const detail of [0, 1, 2] as const) {
106 const line = of(detail)
107
108 if (lineWidth(line) <= columns) return line
109 }
110
111 return shed(of(2), columns)
112}
113
114/**
115 * The band's lines for `columns` cells.
116 *
117 * One line when everything fits at full detail (unless two-line is asked for).
118 * Otherwise two, context then metrics, and each takes the richest detail it
119 * has room for on its own: a long directory name squeezing the first line
120 * should not cost the second its bars. The activity line, when there is one,
121 * goes under them and fits itself the same way.
122 */
123export function arrange(frame: Frame, settings: Settings, columns: number): Seg[][] {
124 const levels = levelsOf(frame, settings)
125 const activity = bestRow(levels, columns, ['activity'])
126 const tail = activity.length > 0 ? [activity] : []
127
128 if (settings.layout === 'auto') {
129 const all = levels(0).filter(seg => seg.row !== 'activity')
130
131 if (lineWidth(all) <= columns) return [all, ...tail]
132 }
133
134 return [
135 ...(['context', 'metrics'] as const)
136 .map(row => bestRow(levels, columns, [row]))
137 .filter(line => line.length > 0),
138 ...tail,
139 ]
140}
141hooks/lib/cache.ts 79 lines1// The main thread's prompt cache. Each request reads the conversation so far
2// from the cache and writes what is new; an entry nobody reads for its TTL
3// (five minutes, or an hour) is gone, and the next request pays to write the
4// whole context again. So the clock that matters runs from the last request.
5
6import type { CacheState } from '../../types'
7
8export const TTL_5M = 5 * 60_000
9export const TTL_1H = 60 * 60_000
10
11/** Above this many tokens a cold cache is worth a look: the next message rewrites them all. */
12export const COLD_MATTERS_TOKENS = 20_000
13
14export const EMPTY_CACHE: CacheState = {
15 lastRequestAt: null,
16 ttlMs: TTL_5M,
17 isTtlKnown: false,
18 hitRatio: null,
19 tokens: null,
20}
21
22/** How much of a transcript's end is read for the TTL: the last few responses, never the whole file. */
23export const TRANSCRIPT_TAIL_BYTES = 262_144
24
25const CREATION = /"cache_creation":\{([^}]*)\}/g
26
27/**
28 * The TTL the session's requests write with, from the end of its transcript.
29 * The API splits each response's cache writes by TTL
30 * (`cache_creation.ephemeral_1h_input_tokens` and `_5m_`); the transcript
31 * keeps that split, the usage the engine hands a hook does not. The latest
32 * response that wrote anything says. Null when none in `text` did.
33 */
34export function ttlOfTranscript(text: string): number | null {
35 const splits = [...text.matchAll(CREATION)].map(match => match[1] ?? '').reverse()
36
37 for (const split of splits) {
38 const hour = Number(/"ephemeral_1h_input_tokens":(\d+)/.exec(split)?.[1] ?? 0)
39 const five = Number(/"ephemeral_5m_input_tokens":(\d+)/.exec(split)?.[1] ?? 0)
40
41 if (hour > 0) return TTL_1H
42 if (five > 0) return TTL_5M
43 }
44
45 return null
46}
47
48/**
49 * Where the transcript cannot be read: a request sent longer than five
50 * minutes after the last one that still read most of its input from the
51 * cache proves the entries live an hour.
52 */
53export function learnTtl(gapMs: number, hitRatio: number): number | null {
54 return gapMs > TTL_5M + 30_000 && gapMs < TTL_1H && hitRatio >= 0.5 ? TTL_1H : null
55}
56
57/** Share of a request's input the cache served. */
58export function hitRatioOf(usage: {
59 input_tokens: number
60 cache_read_input_tokens: number
61 cache_creation_input_tokens: number
62}): number | null {
63 const total = usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
64
65 return total > 0 ? usage.cache_read_input_tokens / total : null
66}
67
68/** Time left on the entry the last request refreshed; null before any request. */
69export function cacheLeft(cache: CacheState, now: number): number | null {
70 if (cache.lastRequestAt === null) return null
71
72 return cache.lastRequestAt + cache.ttlMs - now
73}
74
75/** What a stored TTL reads as: one of the two the API has, or nothing. */
76export function toTtl(raw: unknown): number | null {
77 return raw === TTL_5M || raw === TTL_1H ? raw : null
78}
79hooks/lib/cards.ts 381 lines1// The cards behind the segments: what a glance at the band leaves out. A
2// title, the one figure that sums the card up beside it, then a few rows,
3// the last saying what a click does where a click does something.
4
5import type {
6 Activity,
7 AgentRun,
8 CacheState,
9 ContextPart,
10 GitInfo,
11 Identity,
12 TaskItem,
13 TurnReceipt,
14 Usage,
15 WindowReading,
16} from '../../types'
17import { phrase, progress } from './activity'
18import { cacheLeft, COLD_MATTERS_TOKENS, TTL_1H } from './cache'
19import {
20 ago,
21 clip,
22 countdown,
23 dollars,
24 effortStyle,
25 elapsed,
26 pace,
27 prettyModel,
28 safeLimit,
29 timeLeft,
30 tokensFine,
31 turnsLeft,
32 usageLevel,
33 type Fuel,
34} from './format'
35import { gauge, mixGauge, sparkline } from './gauge'
36import { faint, fillColor, GLYPHS, levelColor, plural, quiet, type Card, type Palette, type Part } from './style'
37
38export const WINDOW_SPAN_MS = {
39 five_hour: 5 * 3_600_000,
40 seven_day: 7 * 86_400_000,
41}
42
43/** The widest a mix gauge draws inside a card. */
44const MIX_CELLS = 44
45/** A window's gauge inside its card: four times the band's, so the pace mark reads to the hour. */
46const CARD_GAUGE_CELLS = 32
47/** The most rows of a list a card shows before it says how many more. */
48const LIST_ROWS = 8
49
50const hint = (text: string): Part[] => [quiet(text)]
51const NAME_ROW = hint('other sessions message this one by this name')
52
53/** Parts joined by a faint dot, the way the band's own figures sit together. */
54function joined(groups: readonly Part[][]): Part[] {
55 return groups.flatMap((group, index) => (index === 0 ? group : [faint(' · '), ...group]))
56}
57
58/** What the last turn came to, in one row. */
59function receiptRow(receipt: TurnReceipt): Part[] {
60 const groups: Part[][] = [[{ text: elapsed(receipt.durationMs) }]]
61
62 if (receipt.tools > 0) groups.push([{ text: plural(receipt.tools, 'tool') }])
63 if (receipt.edits > 0) groups.push([{ text: plural(receipt.edits, 'edit') }])
64 if (receipt.errors > 0) groups.push([{ text: plural(receipt.errors, 'error'), color: 'error' }])
65 if (receipt.costUsd !== null) groups.push([{ text: dollars(receipt.costUsd) }])
66 if (receipt.reason === 'aborted') groups.push([{ text: 'interrupted', color: 'warning' }])
67 if (receipt.reason === 'error' || receipt.reason === 'refusal') {
68 groups.push([{ text: receipt.reason === 'error' ? 'ended on an error' : 'refused', color: 'error' }])
69 }
70
71 return [quiet('last turn '), ...joined(groups)]
72}
73
74export function modelCard(identity: Identity, lastTurn: TurnReceipt | null, palette: Palette): Card {
75 const rows: Part[][] = [[quiet(identity.model)]]
76 let aside: Part | undefined
77
78 if (identity.effort !== null) {
79 const style = effortStyle(identity.effort)
80 const lit = style.rank === 5 ? palette.ultra : palette.accent
81
82 rows.push([
83 quiet('effort '),
84 ...(style.rank > 0 ? [{ text: style.lit, color: lit }, faint(style.unlit), { text: ' ' }] : []),
85 { text: identity.effort, bold: true },
86 ...(style.rank > 0 ? [quiet(` ${style.rank} of 5`)] : []),
87 ])
88 aside = { text: identity.effort, color: style.rank === 5 ? palette.ultra : 'inactive' }
89 }
90
91 if (lastTurn !== null) rows.push(receiptRow(lastTurn))
92 rows.push(hint('click to copy the model id'))
93
94 return { title: prettyModel(identity.model), ...(aside === undefined ? {} : { aside }), rows }
95}
96
97export function placeCard(identity: Identity, place: string, isName: boolean): Card {
98 return isName
99 ? { title: place, rows: [NAME_ROW, [quiet(identity.cwd)], hint('click to copy the name')] }
100 : { title: place, rows: [[quiet(identity.cwd || place)], hint('click to copy the path')] }
101}
102
103export function tagCard(tag: string): Card {
104 return { title: tag, rows: [NAME_ROW, hint('click to copy the name')] }
105}
106
107export function gitCard(git: GitInfo): Card {
108 const counts: Part[][] = []
109
110 if (git.conflicted > 0) counts.push([{ text: `${git.conflicted} conflicted`, color: 'error', bold: true }])
111 if (git.staged > 0) counts.push([{ text: `${git.staged} staged`, color: 'success' }])
112 if (git.modified > 0) counts.push([{ text: `${git.modified} modified`, color: 'warning' }])
113 if (git.untracked > 0) counts.push([{ text: `${git.untracked} untracked` }])
114
115 const sync = [
116 ...(git.ahead > 0 ? [`${plural(git.ahead, 'commit')} to push`] : []),
117 ...(git.behind > 0 ? [`${plural(git.behind, 'commit')} to pull`] : []),
118 ]
119 const rows: Part[][] = [counts.length === 0 ? [{ text: 'working tree clean', color: 'success' }] : joined(counts)]
120
121 if (sync.length > 0) rows.push(hint(sync.join(' · ')))
122 rows.push(hint(`click to copy the ${git.isDetached ? 'commit' : 'branch'}`))
123
124 return {
125 title: git.isDetached ? `detached at ${git.branch}` : git.branch,
126 aside: counts.length === 0 ? { text: 'clean', color: 'success' } : quiet('uncommitted changes'),
127 rows,
128 }
129}
130
131function legend(parts: readonly ContextPart[]): Part[] {
132 return parts.slice(0, 3).flatMap((part, index) => [
133 ...(index === 0 ? [] : [{ text: ' ' }]),
134 { text: '■ ', color: part.color },
135 { text: part.name },
136 quiet(` ${tokensFine(part.tokens)}`),
137 ])
138}
139
140export function fuelCard(reading: Fuel | null, usage: Usage, activity: Activity, palette: Palette): Card {
141 if (reading === null || usage.tokens === null) {
142 return { title: 'Context', rows: [hint('fills in after the first reply')] }
143 }
144
145 const safe = safeLimit(usage.window, usage.isAutoCompact, usage.compactAt)
146 const rows: Part[][] = []
147 const mix = mixGauge(usage.parts, usage.window, safe, MIX_CELLS, {
148 fill: palette.fill,
149 track: palette.track,
150 reserve: 'warning',
151 })
152
153 if (mix.length > 0) rows.push(mix, legend(usage.parts))
154
155 rows.push([
156 { text: tokensFine(reading.remaining), bold: true, color: levelColor(reading.level) },
157 quiet(` of ${tokensFine(safe)} left `),
158 quiet(usage.isAutoCompact ? 'before auto-compact' : '(auto-compact is off)'),
159 ])
160
161 const { history } = usage
162
163 // How the fill moved over the last turns, on the scale of the whole tank.
164 if (history.length >= 2) {
165 rows.push([quiet('fill '), { text: sparkline(history, safe), color: palette.fill }, quiet(` last ${history.length} turns`)])
166 }
167
168 const last = history.length >= 2 ? (history.at(-1) ?? 0) - (history.at(-2) ?? 0) : 0
169 const turns = turnsLeft(reading.remaining, history)
170
171 if (last !== 0) {
172 rows.push([
173 quiet('last turn '),
174 { text: `${last > 0 ? '+' : '−'}${tokensFine(Math.abs(last))}` },
175 ...(turns === null ? [] : [quiet(` · room for ~${turns} more like it`)]),
176 ])
177 } else if (activity.lastTurn?.grewBy !== undefined && activity.lastTurn.grewBy !== null && activity.lastTurn.grewBy !== 0) {
178 const grew = activity.lastTurn.grewBy
179
180 rows.push([quiet('last turn '), { text: `${grew > 0 ? '+' : '−'}${tokensFine(Math.abs(grew))}` }])
181 }
182
183 if (reading.level === 'bad') rows.push(hint('/compact now, or auto-compact steps in soon'))
184
185 return {
186 title: 'Context',
187 aside: { text: `${reading.percent}% left`, bold: true, color: levelColor(reading.level) },
188 rows,
189 }
190}
191
192export function windowCard(
193 id: 'five_hour' | 'seven_day',
194 reading: WindowReading,
195 now: number,
196 palette: Palette,
197): Card {
198 const title = id === 'five_hour' ? '5-hour limit' : '7-day limit'
199 const percent = Math.round(reading.percentUsed)
200 const resetsAt = reading.resetsAt === null ? NaN : Date.parse(reading.resetsAt)
201
202 if (Number.isFinite(resetsAt) && resetsAt <= now) {
203 return {
204 title,
205 aside: { ...quiet('reset'), dim: true },
206 rows: [
207 [quiet('last seen at '), { text: `${percent}%` }, quiet('; it has reset since')],
208 hint('the next reply brings a fresh reading'),
209 ],
210 }
211 }
212
213 const level = usageLevel(reading.percentUsed)
214 const step = pace(reading.percentUsed, resetsAt, now, WINDOW_SPAN_MS[id])
215 const isRunningOut = step !== null && step.runsOutInMs !== null
216 const mark = step === null ? undefined : { at: step.elapsed, color: isRunningOut ? 'warning' : 'text', bold: isRunningOut }
217 const rows: Part[][] = [
218 [
219 ...gauge(reading.percentUsed / 100, CARD_GAUGE_CELLS, { fill: fillColor(level, GLYPHS.symbols, palette), track: palette.track }, mark),
220 { text: ` ${percent}%`, bold: true, color: levelColor(level) },
221 quiet(' used'),
222 ],
223 ]
224
225 if (step === null) {
226 rows.push(hint('too early in the window to call the pace'))
227 } else {
228 const gone = [{ text: `${Math.round(step.elapsed * 100)}%` }, quiet(' of the window gone')]
229
230 rows.push(
231 step.runsOutInMs !== null
232 ? [...gone, faint(' · '), { text: `runs out in ${countdown(step.runsOutInMs)} at this pace`, color: 'warning', bold: true }]
233 : [...gone, faint(' · '), quiet('at this pace '), { text: `~${Math.min(100, Math.round(step.projected))}%` }, quiet(' by the reset')],
234 )
235 rows.push(hint('│ marks where an even pace would be by now'))
236 }
237
238 return {
239 title,
240 ...(Number.isFinite(resetsAt) ? { aside: quiet(`resets in ${countdown(resetsAt - now)}`) } : {}),
241 rows,
242 }
243}
244
245export function cacheCard(cache: CacheState, now: number): Card {
246 const left = cacheLeft(cache, now) ?? 0
247 const ttl = cache.ttlMs >= TTL_1H ? 'an hour' : '5 minutes'
248 const rows: Part[][] = [
249 [quiet('an entry lives '), { text: ttl }, quiet(' unread'), ...(cache.isTtlKnown ? [] : [quiet(' (assumed)')])],
250 ]
251 const since = cache.lastRequestAt === null ? null : now - cache.lastRequestAt
252
253 if (since !== null) {
254 rows.push([
255 quiet('last request '),
256 { text: ago(since) },
257 ...(cache.hitRatio === null
258 ? []
259 : [faint(' · '), { text: `${Math.round(cache.hitRatio * 100)}%` }, quiet(' of it read from the cache')]),
260 ])
261 }
262
263 const size = cache.tokens === null ? null : tokensFine(cache.tokens)
264
265 if (left <= 0) {
266 const isCostly = (cache.tokens ?? 0) >= COLD_MATTERS_TOKENS
267
268 rows.push(
269 size === null
270 ? hint('the next message writes the context to the cache again')
271 : [
272 quiet('the next message writes '),
273 { text: `~${size}`, color: isCostly ? 'warning' : undefined, bold: isCostly },
274 quiet(' to the cache again'),
275 ],
276 )
277 } else {
278 rows.push([
279 quiet('reply within '),
280 { text: timeLeft(left) ?? '' },
281 quiet(` and ${size === null ? 'it' : size} reads back at 1/10 the price`),
282 ])
283 }
284
285 return {
286 title: 'Prompt cache',
287 aside: left > 0 ? { text: `${timeLeft(left) ?? ''} left` } : { text: 'cold', color: 'warning' },
288 rows,
289 }
290}
291
292export function tallyCard(activity: Activity): Card {
293 const rows: Part[][] = []
294
295 for (const run of activity.running) rows.push([{ text: '» ', color: 'claude', bold: true }, { text: phrase(run.tool, run.target) }])
296
297 // Four to a row: a turn of many tools still makes a small card.
298 for (let index = 0; index < activity.tally.length; index += 4) {
299 rows.push(
300 joined(
301 activity.tally.slice(index, index + 4).map(one => [
302 { text: one.tool },
303 quiet(` ×${one.count}`),
304 ...(one.errors > 0 ? [{ text: ` ${one.errors} failed`, color: 'error' }] : []),
305 ]),
306 ),
307 )
308 }
309
310 if (rows.length === 0) rows.push(hint('no tools yet this turn'))
311
312 const total = activity.tally.reduce((sum, one) => sum + one.count, 0)
313
314 return { title: 'This turn', aside: quiet(plural(total, 'tool')), rows }
315}
316
317/** How an agent stands, in one cell: running, done, failed, or stopped. */
318export function agentMark(agent: AgentRun, palette: Palette): Part {
319 switch (agent.status) {
320 case 'running':
321 return { text: '■', color: palette.fill }
322 case 'done':
323 return { text: '✓', color: 'success' }
324 case 'failed':
325 return { text: '!', color: 'error', bold: true }
326 case 'stopped':
327 return quiet('·')
328 }
329}
330
331function agentRow(agent: AgentRun, now: number, palette: Palette): Part[][] {
332 const took = elapsed((agent.endedAt ?? now) - agent.startedAt)
333 const rows: Part[][] = [
334 [
335 agentMark(agent, palette),
336 { text: ` ${agent.type}`, bold: agent.status === 'running' },
337 quiet(` ${took}`),
338 ...(agent.tools > 0 ? [quiet(` · ${plural(agent.tools, 'tool')}`)] : []),
339 ...(agent.isBackground ? [quiet(' · in the background')] : []),
340 ...(agent.status === 'stopped' ? [{ text: ' · interrupted', color: 'warning' }] : []),
341 ...(agent.status === 'failed' ? [{ text: ' · failed', color: 'error' }] : []),
342 ],
343 ]
344
345 if (agent.description !== '') rows.push([quiet(` ${clip(agent.description, 56)}`)])
346 if (agent.doing !== null) rows.push([{ text: ' » ', color: palette.accent }, quiet(clip(agent.doing, 52))])
347
348 return rows
349}
350
351export function agentsCard(activity: Activity, now: number, palette: Palette): Card {
352 const running = activity.agents.filter(one => one.status === 'running').length
353
354 return {
355 title: 'Agents',
356 aside: running > 0 ? { text: `${running} running`, color: palette.fill } : quiet('all done'),
357 rows: activity.agents.flatMap(agent => agentRow(agent, now, palette)),
358 }
359}
360
361export function tasksCard(tasks: readonly TaskItem[]): Card {
362 const { done, total } = progress(tasks)
363 const shown = tasks.slice(0, LIST_ROWS)
364 const rows: Part[][] = shown.map(task => {
365 if (task.status === 'completed') return [{ text: '✓ ', color: 'success' }, quiet(task.subject)]
366 if (task.status === 'in_progress') {
367 return [{ text: '» ', color: 'claude', bold: true }, { text: task.activeForm ?? task.subject, bold: true }]
368 }
369
370 return [faint('· '), { text: task.subject }]
371 })
372
373 if (tasks.length > shown.length) rows.push(hint(`+${tasks.length - shown.length} more`))
374
375 return {
376 title: 'Tasks',
377 aside: done === total ? { text: `✓ ${done}/${total}`, color: 'success' } : { text: `${done}/${total}` },
378 rows,
379 }
380}
381hooks/lib/format.ts 291 lines1// Pure helpers: the fuel arithmetic, countdowns and clocks, text width.
2// Nothing here touches `$`, so each is tested on its own; the surface
3// modules in `../live/` import from here too.
4
5/** Tokens held back for auto-compaction: the fixed buffer, ON or OFF. */
6export const BUFFER_AUTO_COMPACT = 33_000
7export const BUFFER_MANUAL = 3_000
8
9export type Level = 'good' | 'warn' | 'bad'
10
11export type Fuel = {
12 /** Share of the safe limit still free, 0 to 100. */
13 percent: number
14 /** Tokens still free before the buffer, never negative. */
15 remaining: number
16 level: Level
17}
18
19/** Where the context stops being usable: the engine's compaction point, or the window less the fixed buffer. */
20export function safeLimit(
21 windowSize: number,
22 isAutoCompact: boolean,
23 compactAt: number | null = null,
24): number {
25 if (isAutoCompact && compactAt !== null && compactAt > 0) return compactAt
26
27 return windowSize - (isAutoCompact ? BUFFER_AUTO_COMPACT : BUFFER_MANUAL)
28}
29
30/**
31 * How much room is left before auto-compaction would step in.
32 *
33 * The safe limit is the point the engine compacts at, when it has said; else
34 * the window less the buffer. Fuel is what is left of it. Null until a
35 * response has reported a token count.
36 */
37export function fuel(
38 tokens: number | null,
39 windowSize: number,
40 isAutoCompact: boolean,
41 compactAt: number | null = null,
42): Fuel | null {
43 if (tokens === null || tokens <= 0) return null
44
45 const safe = safeLimit(windowSize, isAutoCompact, compactAt)
46 const remaining = safe - tokens
47 const percent =
48 safe > 0 && remaining >= 0 ? Math.round((remaining * 100) / safe) : 0
49
50 // Reversed gauge: more is better, so the thresholds run the other way.
51 const level: Level = percent >= 70 ? 'good' : percent >= 30 ? 'warn' : 'bad'
52
53 return { percent, remaining: Math.max(0, remaining), level }
54}
55
56/**
57 * How many more turns like the recent ones fit: the room left over the
58 * average growth of the last few turns that grew. Null when there is no
59 * growth to go by (a fresh session, or one that only shrank).
60 */
61export function turnsLeft(remaining: number, history: readonly number[]): number | null {
62 const growth: number[] = []
63
64 for (let index = history.length - 1; index > 0 && growth.length < 3; index -= 1) {
65 const delta = (history[index] ?? 0) - (history[index - 1] ?? 0)
66
67 // A compaction or a /clear shrinks the context: not a turn's growth.
68 if (delta > 0) growth.push(delta)
69 }
70
71 if (growth.length === 0) return null
72
73 const average = growth.reduce((sum, delta) => sum + delta, 0) / growth.length
74
75 return Math.floor(remaining / average)
76}
77
78export type Pace = {
79 /** Share of the window already gone, 0 to 1. */
80 elapsed: number
81 /** Where the usage lands at the reset if it keeps the pace it has had. */
82 projected: number
83 /** Time until the limit at that pace, when that comes before the reset. */
84 runsOutInMs: number | null
85 resetsInMs: number
86}
87
88/**
89 * Whether a window's usage is keeping up with its clock. A window that is
90 * 40% gone with 60% used is burning ahead of an even pace; held to it, it
91 * reaches 100% before the reset. Too early in a window the guess is noise,
92 * so there is none for the first twentieth of it.
93 */
94export function pace(
95 percentUsed: number,
96 resetsAt: number,
97 now: number,
98 spanMs: number,
99): Pace | null {
100 const resetsInMs = resetsAt - now
101
102 if (!Number.isFinite(resetsAt) || resetsInMs <= 0 || spanMs <= 0) return null
103
104 const elapsed = Math.min(1, Math.max(0, 1 - resetsInMs / spanMs))
105
106 if (elapsed < 0.05) return null
107
108 const projected = percentUsed / elapsed
109 const elapsedMs = elapsed * spanMs
110 const toLimitMs =
111 percentUsed > 0 ? ((100 - percentUsed) / percentUsed) * elapsedMs : Infinity
112
113 return {
114 elapsed,
115 projected,
116 runsOutInMs: percentUsed < 100 && toLimitMs < resetsInMs ? toLimitMs : null,
117 resetsInMs,
118 }
119}
120
121/** A usage window's colour: it fills up, so less is better. */
122export function usageLevel(percent: number): Level {
123 return percent < 50 ? 'good' : percent < 80 ? 'warn' : 'bad'
124}
125
126/** 57000 -> "57K"; under a thousand the number as it is. */
127export function tokens(count: number): string {
128 return count > 1000 ? `${Math.floor(count / 1000)}K` : String(count)
129}
130
131/** One decimal where it says something: 12400 -> "12.4K", 1250000 -> "1.3M", 57000 -> "57K". */
132export function tokensFine(count: number): string {
133 const short = (value: number, unit: string) =>
134 `${value >= 100 ? Math.round(value) : Number(value.toFixed(1))}${unit}`
135
136 if (count >= 1_000_000) return short(count / 1_000_000, 'M')
137 if (count >= 1_000) return short(count / 1_000, 'K')
138
139 return String(count)
140}
141
142/** Fits `text` in `max` cells by cutting its middle, where names are least distinct. */
143export function clip(text: string, max: number): string {
144 if (width(text) <= max) return text
145 if (max <= 1) return '…'
146
147 const chars = [...text]
148 const keep = max - 1
149 const head = Math.ceil(keep / 2)
150 const tail = keep - head
151
152 return `${chars.slice(0, head).join('')}…${tail > 0 ? chars.slice(-tail).join('') : ''}`
153}
154
155export function dollars(usd: number): string {
156 return usd < 10 ? `$${usd.toFixed(2)}` : `$${usd.toFixed(1)}`
157}
158
159/** `text` with spaces before it up to `cells`, so a figure keeps its column as it changes. */
160export function pad(text: string, cells: number): string {
161 return `${' '.repeat(Math.max(0, cells - width(text)))}${text}`
162}
163
164/** `text` with spaces after it up to `cells`: a figure that stays beside what it belongs to, and still holds its room. */
165export function padEnd(text: string, cells: number): string {
166 return `${text}${' '.repeat(Math.max(0, cells - width(text)))}`
167}
168
169/** Time until a reset, as short as it reads: 2h13m, 45m, 3d4h. */
170export function countdown(ms: number): string {
171 const minutes = Math.max(1, Math.floor(ms / 60_000))
172
173 if (minutes < 60) return `${minutes}m`
174
175 const hours = Math.floor(minutes / 60)
176
177 if (hours < 24) return `${hours}h${String(minutes % 60).padStart(2, '0')}m`
178
179 return `${Math.floor(hours / 24)}d${hours % 24}h`
180}
181
182/** Time gone, as a running clock reads: 0:07, 12:05, then 1h02m. */
183export function elapsed(ms: number): string {
184 const seconds = Math.max(0, Math.floor(ms / 1000))
185 const minutes = Math.floor(seconds / 60)
186
187 if (minutes < 60) return `${minutes}:${String(seconds % 60).padStart(2, '0')}`
188
189 return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`
190}
191
192/**
193 * Time left on a cache entry: whole minutes while there are ten or more of
194 * them (52m), then minutes and seconds (9:42, 0:45). Null once it has run out.
195 */
196export function timeLeft(ms: number): string | null {
197 if (ms <= 0) return null
198
199 const seconds = Math.ceil(ms / 1000)
200
201 if (seconds >= 600) return `${Math.floor(seconds / 60)}m`
202
203 return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
204}
205
206/** How long ago, in the fewest words: just now, 4m ago, 2h ago. */
207export function ago(ms: number): string {
208 if (ms < 60_000) return 'just now'
209
210 return `${countdown(ms)} ago`
211}
212
213const EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max']
214const SIGNAL = '▁▂▄▆█'
215
216export type EffortStyle = {
217 /** The bars of a signal meter lit up to the level, so it reads like any other. */
218 lit: string
219 /** The rest of the meter, drawn dim; empty for a level this build does not know. */
220 unlit: string
221 /** 1 for `low` up to 5 for `max`; 0 when unknown. */
222 rank: number
223}
224
225/**
226 * How an effort level looks: a five-bar signal meter, lit to the level. The
227 * meter is the scale, so `xhigh` reads as four of five without knowing the
228 * names. A level this build does not know gets no meter, only its word.
229 */
230export function effortStyle(effort: string): EffortStyle {
231 const rank = EFFORT_LEVELS.indexOf(effort) + 1
232
233 if (rank === 0) return { lit: '', unlit: '', rank: 0 }
234
235 return { lit: SIGNAL.slice(0, rank), unlit: SIGNAL.slice(rank), rank }
236}
237
238/** `claude-sonnet-5-5` -> `Sonnet 5.5`; a name already readable is left as it is. */
239export function prettyModel(model: string): string {
240 const match = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?(\[[^\]]*\])?$/.exec(
241 model,
242 )
243
244 if (match === null) return model
245
246 const [, family = '', major = '', minor, context] = match
247 const name = family.charAt(0).toUpperCase() + family.slice(1)
248 const version = minor === undefined ? major : `${major}.${minor}`
249
250 return `${name} ${version}${context ?? ''}`
251}
252
253/** The terminal's own name for the last path segment of `path`. */
254export function baseName(path: string): string {
255 const parts = path.split(/[\\/]/).filter(part => part !== '')
256
257 return parts[parts.length - 1] ?? path
258}
259
260/**
261 * Cells a string takes: wide CJK and emoji two, variation selectors and
262 * combining marks none. An estimate that errs wide, used to choose a layout,
263 * not to paint.
264 */
265export function width(text: string): number {
266 let cells = 0
267
268 for (const char of text) {
269 const code = char.codePointAt(0) ?? 0
270
271 if (code === 0xfe0f || code === 0x200d || (code >= 0x300 && code < 0x370)) {
272 continue
273 }
274
275 const isWide =
276 (code >= 0x1100 && code <= 0x115f) ||
277 (code >= 0x2e80 && code <= 0xa4cf) ||
278 (code >= 0xac00 && code <= 0xd7a3) ||
279 (code >= 0xf900 && code <= 0xfaff) ||
280 (code >= 0xfe30 && code <= 0xfe6f) ||
281 (code >= 0xff00 && code <= 0xff60) ||
282 (code >= 0xffe0 && code <= 0xffe6) ||
283 (code >= 0x1f300 && code <= 0x1faff) ||
284 code === 0x26fd
285
286 cells += isWide ? 2 : 1
287 }
288
289 return cells
290}
291hooks/lib/git.ts 75 lines1import type { GitInfo } from '../../types'
2
3/**
4 * The argv for one status read. `--no-optional-locks` keeps a status bar from
5 * taking the index lock a person's own `git add` may be waiting on, and
6 * `gc.autodetach=false` keeps git from forking a background gc under it.
7 */
8export const GIT_STATUS_ARGV = [
9 'git',
10 '-c',
11 'gc.autodetach=false',
12 '--no-optional-locks',
13 'status',
14 '--porcelain=v2',
15 '--branch',
16] as const
17
18/**
19 * Reads `git status --porcelain=v2 --branch`.
20 *
21 * Counts files, not letters: a file changed in both the index and the working
22 * tree counts once under each, as the original's two dots did.
23 */
24export function parseGitStatus(output: string): GitInfo | null {
25 let head = ''
26 let oid = ''
27 let ahead = 0
28 let behind = 0
29 let staged = 0
30 let modified = 0
31 let untracked = 0
32 let conflicted = 0
33
34 for (const line of output.split('\n')) {
35 if (line.startsWith('# branch.head ')) {
36 head = line.slice('# branch.head '.length).trim()
37 } else if (line.startsWith('# branch.oid ')) {
38 oid = line.slice('# branch.oid '.length).trim()
39 } else if (line.startsWith('# branch.ab ')) {
40 const counts = /\+(\d+) -(\d+)/.exec(line)
41 ahead = Number(counts?.[1] ?? 0)
42 behind = Number(counts?.[2] ?? 0)
43 } else if (line.startsWith('1 ') || line.startsWith('2 ')) {
44 // `1 XY ...` and `2 XY ...`: X is the index, Y the working tree.
45 if (line.charAt(2) !== '.') staged += 1
46 if (line.charAt(3) !== '.') modified += 1
47 } else if (line.startsWith('u ')) {
48 conflicted += 1
49 } else if (line.startsWith('? ')) {
50 untracked += 1
51 }
52 }
53
54 if (head === '') return null
55
56 const isDetached = head === '(detached)'
57
58 return {
59 branch: isDetached ? oid.slice(0, 7) : head,
60 isDetached,
61 staged,
62 modified,
63 untracked,
64 conflicted,
65 ahead,
66 behind,
67 }
68}
69
70export function isClean(git: GitInfo): boolean {
71 return (
72 git.staged + git.modified + git.untracked + git.conflicted === 0
73 )
74}
75hooks/lib/session-name.ts 82 lines1// Claude Code lets sessions message each other by name, and that name is not
2// the conversation title on stdin: it lives in the session registry, one JSON
3// file per live session named by pid (`~/.claude/sessions/<pid>.json`), keyed
4// inside by `sessionId`. A derived name is `<dir>-XX`.
5
6export type RegistryEntry = {
7 sessionId: string
8 name: string
9 updatedAt: number
10}
11
12/** The registry files worth reading, newest first: `<pid>.json`, never the `.key` files beside them. */
13export function registryFiles(
14 entries: readonly { name: string; mtimeMs: number }[],
15 limit = 40,
16): string[] {
17 return entries
18 .filter(entry => /^\d+\.json$/.test(entry.name))
19 .sort((a, b) => b.mtimeMs - a.mtimeMs)
20 .slice(0, limit)
21 .map(entry => entry.name)
22}
23
24/**
25 * One file's entry, or null when it is not one: a file a session was killed
26 * in the middle of writing is cut off, and costs only itself.
27 */
28export function parseEntry(text: string): RegistryEntry | null {
29 try {
30 const raw: unknown = JSON.parse(text)
31
32 if (typeof raw !== 'object' || raw === null) return null
33
34 const { sessionId, name, updatedAt } = raw as Record<string, unknown>
35
36 if (typeof sessionId !== 'string' || typeof name !== 'string') return null
37 if (name === '') return null
38
39 return {
40 sessionId,
41 name,
42 updatedAt: typeof updatedAt === 'number' ? updatedAt : 0,
43 }
44 } catch {
45 return null
46 }
47}
48
49/** A resumed session can leave its previous pid's file behind: the latest update wins. */
50export function pickName(
51 entries: readonly (RegistryEntry | null)[],
52 sessionId: string,
53): string | null {
54 let best: RegistryEntry | null = null
55
56 for (const entry of entries) {
57 if (entry === null || entry.sessionId !== sessionId) continue
58 if (best === null || entry.updatedAt >= best.updatedAt) best = entry
59 }
60
61 return best?.name ?? null
62}
63
64/**
65 * What goes beside the directory. A derived name is `<dir>-XX`, so showing it
66 * next to the directory would say the same thing twice: it takes the
67 * directory's place. A name that has become something else (after /rename)
68 * gets a tag of its own and the directory stays.
69 */
70export function placeName(
71 dir: string,
72 sessionName: string | null,
73): { place: string; tag: string | null } {
74 if (sessionName === null) return { place: dir, tag: null }
75
76 const covers = sessionName === dir || sessionName.startsWith(`${dir}-`)
77
78 return covers
79 ? { place: sessionName, tag: null }
80 : { place: dir, tag: sessionName }
81}
82hooks/lib/settings.ts 23 lines1// What the person's settings say about effort. Current builds keep it per
2// model (`modelSettings["claude-opus-5-5"].effortLevel`); older ones kept a
3// single `effortLevel` at the top, which still counts when there is no entry
4// for the model.
5
6function record(value: unknown): Record<string, unknown> | null {
7 return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null
8}
9
10/** The effort level the settings hold for `model`, or null when they hold none. */
11export function effortSetting(settings: unknown, model: string): string | null {
12 const all = record(settings)
13
14 if (all === null) return null
15
16 const perModel = record(all.modelSettings)
17 // A model id can carry a context suffix (`claude-opus-5-5[1m]`) its settings key lacks.
18 const entry = record(perModel?.[model]) ?? record(perModel?.[model.replace(/\[[^\]]*\]$/, '')])
19 const level = entry?.effortLevel ?? all.effortLevel
20
21 return typeof level === 'string' && level !== '' ? level : null
22}
23hooks/lib/style.ts 234 lines1// The band's vocabulary: what a segment is made of, the two looks it can
2// wear, and the colours it paints with.
3//
4// The symbols look is calm by default and native to Claude Code: the gauges
5// are /usage's own (its fill on its track), the model sits on the badge
6// background a prompt has in the transcript, labels and units are the
7// theme's secondary text. Colour is spent where a look is needed: a gauge
8// running low, a window running hot, a conflict. The emoji look is the
9// original statusline's.
10
11import type { Activity, CacheState, GitInfo, Identity, Usage } from '../../types'
12import type { Level } from './format'
13
14/** A small live element the band hands to a surface module: it ticks without the band redrawing. */
15export type Live =
16 | {
17 kind: 'clock'
18 /** What the clock shows when drawn, in ms: time left (`down`) or time gone (`up`). */
19 ms: number
20 direction: 'up' | 'down'
21 color?: string
22 /** Counting down, under this many ms left the colour turns `soonColor`. */
23 soonMs?: number
24 soonColor?: string
25 /** What a countdown shows once it runs out, and in what colour. */
26 doneText?: string
27 doneColor?: string
28 /** Cells the clock always takes, so the line never shifts as it ticks. */
29 width: number
30 }
31 | { kind: 'spinner'; color: string }
32
33export type Part = {
34 text: string
35 /** A theme key (`warning`, `inactive`...), so the band follows the person's theme. */
36 color?: string
37 /** A theme key for the background: a gauge's track, a badge, an alarm. */
38 bg?: string
39 dim?: boolean
40 bold?: boolean
41 underline?: boolean
42 /** Draws the part as a button; the drawing hook says what pressing it does. */
43 press?: 'compact'
44 /**
45 * A click copies `text`. Consecutive parts with the same `id` are one button,
46 * so a glyph and the name beside it are one target.
47 */
48 copy?: { id: string; text: string }
49 /** Drawn by a surface module in place of `text`, which says what it shows first and how wide it is. */
50 live?: Live
51}
52
53/** The details behind a segment, shown while the pointer rests on it. */
54export type Card = {
55 title: string
56 /** Set against the right end of the title rule: the one figure that sums the card up. */
57 aside?: Part
58 rows: Part[][]
59}
60
61export type Row = 'context' | 'metrics' | 'activity'
62
63export type Seg = {
64 id: string
65 /** Which line it belongs to. */
66 row: Row
67 /** 0 is never dropped; a larger number goes first when a line cannot fit. */
68 priority: number
69 parts: Part[]
70 card?: Card
71}
72
73export type Settings = {
74 layout: 'auto' | 'two-line'
75 icons: 'symbols' | 'emoji'
76 showCost: boolean
77 /** The activity line: tools, subagents and tasks, while there are any. */
78 hasActivity: boolean
79 /** The prompt cache's countdown between turns. */
80 hasCache: boolean
81}
82
83export type Frame = {
84 identity: Identity
85 git: GitInfo | null
86 usage: Usage
87 activity: Activity
88 cache: CacheState
89 /** Wall clock in ms. */
90 now: number
91 /** True while a turn runs: nothing is offered to press then, and the activity line shows. */
92 isWorking: boolean
93 /** The terminal knows Claude Code's whole theme; other surfaces the common keys alone. */
94 isTerminal: boolean
95}
96
97/** How much a segment says: 0 everything, 1 trimmed, 2 the bare minimum. */
98export type Detail = 0 | 1 | 2
99
100export type Glyphs = {
101 /** The calm palette, the effort meter and the pace marks; off, the original's traffic lights. */
102 isModern: boolean
103 separator: string
104 model: string
105 effort: string
106 place: string
107 tag: string
108 branch: string
109 fuel: string
110 fuelLow: string
111 usage: string
112 cache: string
113 tools: string
114 agents: string
115 tasks: string
116}
117
118// Every glyph of the symbols look is in the fonts terminals ship with (box
119// drawing, block elements, Latin-1), so none falls back to a font of another
120// width.
121export const GLYPHS: Record<Settings['icons'], Glyphs> = {
122 symbols: {
123 isModern: true,
124 separator: ' ',
125 model: '',
126 effort: '',
127 place: '',
128 tag: '@',
129 branch: '',
130 fuel: 'fuel ',
131 fuelLow: 'fuel ',
132 usage: '',
133 cache: 'cache ',
134 tools: '',
135 agents: '',
136 tasks: 'tasks ',
137 },
138 emoji: {
139 isModern: false,
140 separator: ' | ',
141 model: '🤖 ',
142 effort: '🧠 ',
143 place: '📁 ',
144 tag: '🏷️ ',
145 branch: '🌿 ',
146 fuel: '⛽ ',
147 fuelLow: '⚠️ ',
148 usage: '📊 ',
149 cache: '💾 ',
150 tools: '🔧 ',
151 agents: '🧩 ',
152 tasks: '📋 ',
153 },
154}
155
156/** Between two segments; between the two groups of a one-line band, at least as wide. */
157export const SEPARATOR_WIDTH = 3
158
159export type Palette = {
160 /** The model's name and the lit effort bars. */
161 accent: string
162 /** The lit bars at `max`, as Claude Code marks its highest effort. */
163 ultra: string
164 /** A gauge's fill while calm. */
165 fill: string
166 /** A gauge's empty track, drawn as background. */
167 track: string
168 /** Behind the model: the badge the band starts with. */
169 badge: string
170 /** Behind a segment while the pointer rests on it. */
171 hover: string
172 /** A card's frame. */
173 border: string
174}
175
176// The terminal draws every key of the person's theme. Another surface knows
177// the common ones (ThemeKey), so there the gauges and the badge fall back on
178// those.
179const TERMINAL: Palette = {
180 accent: 'claude',
181 ultra: 'effortUltra',
182 fill: 'rate_limit_fill',
183 track: 'rate_limit_empty',
184 badge: 'userMessageBackground',
185 hover: 'userMessageBackgroundHover',
186 border: 'promptBorder',
187}
188
189const COMMON: Palette = {
190 accent: 'claude',
191 ultra: 'merged',
192 fill: 'suggestion',
193 track: 'subtle',
194 badge: 'subtle',
195 hover: 'subtle',
196 border: 'promptBorder',
197}
198
199export function paletteOf(isTerminal: boolean): Palette {
200 return isTerminal ? TERMINAL : COMMON
201}
202
203export function separatorPart(settings: Settings): Part {
204 return { text: GLYPHS[settings.icons].separator, color: 'subtle' }
205}
206
207/** A value's colour by its level: plain text while calm in the symbols look, green in the original. */
208export function levelColor(level: Level, glyphs: Glyphs = GLYPHS.symbols): string | undefined {
209 if (level === 'good') return glyphs.isModern ? undefined : 'success'
210
211 return level === 'warn' ? 'warning' : 'error'
212}
213
214/** A gauge's fill: the calm fill of the symbols look, green in the original, then amber and red. */
215export function fillColor(level: Level, glyphs: Glyphs, palette: Palette): string {
216 if (level === 'good') return glyphs.isModern ? palette.fill : 'success'
217
218 return level === 'warn' ? 'warning' : 'error'
219}
220
221/** Labels, units and asides: the theme's secondary text, readable but out of the way. */
222export function quiet(text: string): Part {
223 return { text, color: 'inactive' }
224}
225
226/** Separators, rules and unlit marks: the faintest grey the theme has. */
227export function faint(text: string): Part {
228 return { text, color: 'subtle' }
229}
230
231export function plural(count: number, word: string): string {
232 return `${count} ${word}${count === 1 ? '' : 's'}`
233}
234hooks/lib/view.ts 288 lines1// The band as an element tree: lines of segments, each segment a keyed Box,
2// so the pointer resting on it lights it and reveals its card above, over
3// the rows there, without moving anything.
4//
5// Nothing here touches `$`: the element constructors come in from the drawing
6// hook (`$.ui.resolve(e)`), and so do the closures a press runs and the
7// surface modules a live part draws with.
8
9import type {
10 BoxProps,
11 ButtonProps,
12 ElementConstructor,
13 RenderElement,
14 TextProps,
15 UiPressArgument,
16} from 'claude-code'
17
18import { chunkParts, partsWidth } from './arrange'
19import { width } from './format'
20import { SEPARATOR_WIDTH, type Card, type Live, type Part, type Seg } from './style'
21
22export type Elements = {
23 Box: ElementConstructor<BoxProps>
24 Text: ElementConstructor<TextProps>
25 Button: ElementConstructor<ButtonProps>
26}
27
28export type Actions = {
29 /** What a press on a copy target runs. */
30 copy: (text: string) => (press: UiPressArgument) => void
31 /** What the /compact button runs. */
32 compact: () => void
33 /**
34 * The surface module that draws a live part under `key`, or null where the
35 * surface has none: the part's own text stands in, still.
36 */
37 live: (live: Live, key: string) => RenderElement | null
38}
39
40export type ViewOptions = {
41 /** Cells the lines may take, the left margin already taken off. */
42 columns: number
43 separator: Part
44 /** Whether resting the pointer on a segment shows its card. */
45 hasCards: boolean
46 /** Behind a segment under the pointer; none leaves it unlit. */
47 hover?: string
48 /** A card's frame. */
49 border: string
50}
51
52/** The widest a card's rows grow; past it they are cut. */
53const CARD_MAX = 64
54
55function style(part: Part, hover?: string): TextProps {
56 return {
57 ...(part.color === undefined ? {} : { color: part.color }),
58 ...(part.bg === undefined ? {} : { backgroundColor: part.bg }),
59 ...(part.dim === true ? { dimColor: true } : {}),
60 ...(part.bold === true ? { bold: true } : {}),
61 ...(part.underline === true ? { underline: true } : {}),
62 // A part with a background of its own (a gauge's track, an alarm) keeps it.
63 ...(hover === undefined || part.bg !== undefined ? {} : { hover: { backgroundColor: hover } }),
64 }
65}
66
67function texts(els: Elements, parts: readonly Part[], hover?: string): RenderElement[] {
68 return parts.map(part => els.Text({ ...style(part, hover), children: part.text }))
69}
70
71/** `parts` cut to `cells`, the last cell an ellipsis when anything was cut; no room, nothing. */
72export function fitParts(parts: readonly Part[], cells: number): Part[] {
73 if (cells <= 0) return []
74 if (partsWidth(parts) <= cells) return [...parts]
75
76 const out: Part[] = []
77 let room = Math.max(0, cells - 1)
78
79 for (const part of parts) {
80 if (room <= 0) break
81
82 const chars = [...part.text]
83 let kept = ''
84
85 for (const char of chars) {
86 const cost = width(char)
87
88 if (cost > room) {
89 room = 0
90 break
91 }
92
93 kept += char
94 room -= cost
95 }
96
97 if (kept !== '') out.push({ ...part, text: kept })
98 }
99
100 out.push({ text: '…', color: 'inactive' })
101
102 return out
103}
104
105/** The card's size: as wide as its widest row (or its title rule), padded a cell each side, inside a frame. */
106export function cardSize(card: Card, columns: number): { width: number; height: number; inner: number } {
107 const rule = width(card.title) + (card.aside === undefined ? 0 : width(card.aside.text) + 3) + 2
108 const widest = Math.max(rule, ...card.rows.map(row => partsWidth(row)))
109 const inner = Math.max(1, Math.min(widest, CARD_MAX, columns - 4))
110
111 return { width: inner + 4, height: card.rows.length + 2, inner }
112}
113
114/**
115 * Where the card sits against its segment: over it, left-aligned with it, and
116 * pulled left as far as it must to stay inside the band.
117 */
118export function cardLeft(x: number, cardWidth: number, columns: number): number {
119 const overflow = x + cardWidth - columns
120
121 return overflow > 0 ? -Math.min(x, overflow) : 0
122}
123
124/**
125 * The top rule: the title set into it at the left, the aside at the right.
126 * Cut to the card's width when the room is short, never longer.
127 */
128function topRule(els: Elements, card: Card, size: { width: number; inner: number }, border: string): RenderElement {
129 const title = fitParts([{ text: card.title, bold: true }], Math.max(1, size.inner - 2))
130 const room = size.inner - partsWidth(title) - 5
131 // An aside with no room for two cells of itself is left out, not drawn as a lone `…`.
132 const aside = card.aside === undefined || room < 2 ? [] : fitParts([card.aside], room)
133 const used = 3 + partsWidth(title) + 1 + (aside.length > 0 ? 1 + partsWidth(aside) + 3 : 2)
134 const fill = Math.max(0, size.width - used)
135
136 return els.Text({
137 wrap: 'truncate',
138 children: [
139 els.Text({ color: border, children: '╭─ ' }),
140 ...texts(els, title),
141 els.Text({ color: border, children: ` ${'─'.repeat(fill)}${aside.length > 0 ? ' ' : '─╮'}` }),
142 ...(aside.length > 0 ? [...texts(els, aside), els.Text({ color: border, children: ' ─╮' })] : []),
143 ],
144 })
145}
146
147/**
148 * One row of a card, padded with spaces to the card's width: a card paints
149 * over the rows above the band, and a cell it leaves unwritten would let the
150 * text beneath show through.
151 */
152function cardRow(els: Elements, parts: readonly Part[], inner: number, border: string): RenderElement {
153 const shown = fitParts(parts, inner)
154 const pad = Math.max(0, inner - partsWidth(shown))
155
156 return els.Text({
157 wrap: 'truncate',
158 children: [
159 els.Text({ color: border, children: '│ ' }),
160 ...texts(els, shown),
161 els.Text({ children: ' '.repeat(pad) }),
162 els.Text({ color: border, children: ' │' }),
163 ],
164 })
165}
166
167/**
168 * A card floats clear of the band: its bottom edge sits on the band's top, so
169 * it covers the transcript above and never half a line of the band itself.
170 */
171function card(els: Elements, value: Card, at: { x: number; row: number }, options: ViewOptions): RenderElement {
172 const size = cardSize(value, options.columns)
173
174 return els.Box({
175 position: 'absolute',
176 display: 'none',
177 hover: { display: 'flex' },
178 top: -(size.height + at.row),
179 left: cardLeft(at.x, size.width, options.columns),
180 width: size.width,
181 flexDirection: 'column',
182 children: [
183 topRule(els, value, size, options.border),
184 ...value.rows.map(row => cardRow(els, row, size.inner, options.border)),
185 els.Text({ color: options.border, children: `╰${'─'.repeat(size.width - 2)}╯` }),
186 ],
187 })
188}
189
190function segment(
191 els: Elements,
192 seg: Seg,
193 at: { x: number; row: number },
194 actions: Actions,
195 options: ViewOptions,
196): RenderElement {
197 const { hover } = options
198 const children: RenderElement[] = chunkParts(seg.parts).map((chunk, index) => {
199 const copying = chunk.copy
200
201 // A click copies what the run shows (or, for a directory, its whole path).
202 if (copying !== undefined) {
203 return els.Button({
204 key: `copy:${copying.id}`,
205 plain: true,
206 onPress: actions.copy(copying.text),
207 children: texts(els, chunk.parts, hover),
208 })
209 }
210
211 if (chunk.press === 'compact') {
212 return els.Button({
213 key: 'compact',
214 plain: true,
215 onPress: () => actions.compact(),
216 children: texts(els, chunk.parts),
217 })
218 }
219
220 const [only] = chunk.parts
221
222 if (chunk.live !== undefined) {
223 const drawn = actions.live(chunk.live, `live:${seg.id}:${index}`)
224
225 if (drawn !== null) return drawn
226 }
227
228 return els.Text({ ...(only === undefined ? {} : style(only, hover)), children: only?.text ?? '' })
229 })
230
231 if (options.hasCards && seg.card !== undefined) {
232 children.push(card(els, seg.card, at, options))
233 }
234
235 return els.Box({ key: `seg:${seg.id}`, flexDirection: 'row', flexShrink: 0, children })
236}
237
238/**
239 * One line. Where it holds both groups (a wide band on one line), the
240 * metrics sit at the right edge, the identity at the left, and the room
241 * between them is the separator.
242 */
243function line(
244 els: Elements,
245 segs: readonly Seg[],
246 row: number,
247 actions: Actions,
248 options: ViewOptions,
249): RenderElement {
250 const split = segs.findIndex(seg => seg.row === 'metrics')
251 const hasGroups = split > 0
252 const metricsWidth = hasGroups
253 ? segs.slice(split).reduce((sum, seg) => sum + partsWidth(seg.parts), 0) +
254 SEPARATOR_WIDTH * (segs.length - split - 1)
255 : 0
256 const children: RenderElement[] = []
257 let x = 0
258
259 for (const [index, seg] of segs.entries()) {
260 if (index === split && hasGroups) {
261 children.push(els.Box({ flexGrow: 1, minWidth: SEPARATOR_WIDTH }))
262 x = options.columns - metricsWidth
263 } else if (index > 0) {
264 children.push(els.Text({ ...style(options.separator), children: options.separator.text }))
265 x += SEPARATOR_WIDTH
266 }
267
268 children.push(segment(els, seg, { x, row }, actions, options))
269 x += partsWidth(seg.parts)
270 }
271
272 return els.Box({ flexDirection: 'row', children })
273}
274
275/** The whole band: its lines, a cell in from the left so it lines up with the prompt's text. */
276export function view(
277 els: Elements,
278 lines: readonly (readonly Seg[])[],
279 actions: Actions,
280 options: ViewOptions,
281): RenderElement {
282 return els.Box({
283 flexDirection: 'column',
284 paddingLeft: 1,
285 children: lines.map((segs, row) => line(els, segs, row, actions, options)),
286 })
287}
288