SLOPSHOPPER

byj-statusline

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…

newbandguardtoastpromptprocess
★ 1v1.0.0MITupdated 2026-10-13BitYoungjae/byj-cc-statusline
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · byj-statusline
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ⟨Claude Code's own drawing⟩ copy:model copy:placefuel ████▎ 42% 69K left 5h ██▌ 31% ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ copy:model copy:placefuel ████▎ 42% 69K left 5h ██▌ 31%
README

byj-cc-statusline

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

A live session: tools, a subagent and the tally move on the activity line while Claude works; afterwards the cache starts counting down

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.

Install

/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.

Reading it

 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.

While Claude works

A second line comes in under the band while a turn runs, and goes again when it ends:

  • the tool running now, with a small spinner, and what it works on: Edit receipt.ts, Bash npm test, Grep parseTotal
  • subagents, each with its type, its task and a clock that ticks while it runs; a finished one stays for half a minute with ✓ (or ! when it failed)
  • tasks, when Claude keeps a task list: how many are done and the one under way
  • the turn's tally, the tools it has run so far, most used first, with ! where one failed

Between 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.

Pointing at things

Resting the pointer on a part opens a card with the details behind it

Rest the pointer on a part and it lights up, and a card opens above the band:

  • model: the full id, the effort level, and what the last turn came to (time, tools, edits, cost)
  • session name: what the name is for, and the full path
  • branch: the counts spelled out, and what is left to push or pull
  • fuel: what fills the context, as one bar in /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 fit
  • 5h / 7d: a large gauge with the pace mark, how much of the window is gone, where you will end up at this pace or when you will run out, and when it resets
  • cache: how long entries live, how much of the last request came from the cache, and how much a cold message would rewrite
  • the activity line: the turn's tools in full, each subagent with its task, time, tool count and what it is doing now, and the whole task list

Clicking 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.

Narrow windows

The band going from one line to two, then trimming itself as the window gets narrower

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.

Options

In /config, under byj-statusline:

OptionDefault
layoutautotwo-line keeps the two lines apart even when one would fit
iconssymbolsemoji brings back the old statusline's look
detailsonthe hover cards
activityonthe line of tools, subagents and tasks while Claude works
cacheonthe prompt cache countdown between turns
showCostoffadds the session's cost
alertsona 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.

What it reads

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.

Hacking on it

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.

License

MIT

Source 16 files
hooks/register.tsx 1206 lines
1// 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 lines
1// 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
93
hooks/lib/activity.ts 285 lines
1// 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}
285
hooks/lib/arrange.ts 141 lines
1// 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}
141
hooks/lib/cache.ts 79 lines
1// 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}
79
hooks/lib/cards.ts 381 lines
1// 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}
381
hooks/lib/format.ts 291 lines
1// 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}
291
hooks/lib/git.ts 75 lines
1import 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}
75
hooks/lib/session-name.ts 82 lines
1// 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}
82
hooks/lib/settings.ts 23 lines
1// 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}
23
hooks/lib/style.ts 234 lines
1// 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}
234
hooks/lib/view.ts 288 lines
1// 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