A configurable multi-line Claude Code statusline (hooks mod): model, thinking effort, context window, cache hit rate, project, git, remote branches, output…

English | 简体中文
A configurable multi-line Claude Code statusline — model, context, usage limits, git, tools, agents, todos, and output speed, always visible under the prompt.
statusline is a Claude Code plugin / hooks mod, not a separate tmux pane or an external status-line process. Claude Code runs the plugin's hooks, and the plugin draws rows through the prompt footer's render sites. That is what lets it replace the hint line, react to tool calls, and place up to six rows exactly where you want them.
▶▶ bypass permissions on · ? for shortcuts
deepseek-flash[1m] · max · 9%/1.0M · 99% · HTML · main · +12 -3 ↑2 br2 · 48 tok/s
With more fields enabled and moved to their own rows:
▶▶ bypass permissions on · ? for shortcuts
[Opus] · high · 48 tok/s
9%/1.0M · 99% · statusline · main · +12 -3 ↑2 br2
Usage 5h 25% · 7d 41% · $0.42 · ⏱ 12m
◐ Edit: auth.ts | ✓ Read ×3 | ✓ Grep ×2
▸ Fix auth bug (2/5)
The first ▶▶ ... is Claude Code's own permission-mode indicator; it is engine-owned and always shares the first footer row.
The footer in a live session:

The /statusline settings pane — one row per field, an on/off switch, and an L1–L6 button that cycles the row:

Inside Claude Code:
/plugin install statusline --marketplace ShaneLevs/statusline
Confirm with y, then choose the user scope so the status line loads in every session.
Or add the marketplace first:
/plugin marketplace add ShaneLevs/statusline
/plugin install statusline
/reload-plugins
The marketplace is named local-mods in this repository, so the installed id is statusline@local-mods.
claude plugin marketplace add /path/to/statusline --scope user
claude plugin install statusline@local-mods --scope user
Then run /reload-plugins in any open session. A folder marketplace loads in place: edits to the source take effect on the next /reload-plugins or the next session.
For a one-off session:
claude --plugin-dir /path/to/statusline
Fields are joined with a dim · inside a row:
| Field | Color | Default row | What it shows | ||
|---|---|---|---|---|---|
| Native hint | dim | 1 | ? for shortcuts, esc to interrupt, and the engine's other hints; live pills are kept when it is the only thing drawn | ||
| Model | blue | 2 | The name shown by /model | ||
| Thinking effort | default | 2 | low … max | ||
| Context | green / yellow (≥70%) / red (≥90%) | 2 | used%/window, e.g. 9%/1.0M | ||
| Cache hit rate | default | 2 | Share of the last response's input tokens served by the prompt cache | ||
| Project | blue | 2 | Last path segment of the session root | ||
| Branch | blue | 2 | Current git branch (detached@<oid> on a detached HEAD) | ||
| Git changes | green +N / red -N / ↑N | 2 | Added/deleted lines and commits to push; a green ✓ when clean | ||
| Remote branches | default | 2 | brN: remote branches with no local branch of the same name | ||
| Output speed | default / dim while a turn runs | 2 | Tokens per second: the last request's own rate, then the turn average | ||
| Usage limits | default / yellow (≥70%) / red (≥90%) | 3 | Usage 5h 25% · 7d 41%, with reset countdowns when known | ||
| Session cost | default | 3 | $0.42 from Claude Code's cost ledger; omitted when no ledger exists | ||
| Session duration | default | 3 | ⏱ 12m from the start of the session | ||
| Tools | green / yellow / red | 4 | `◐ Edit: auth.ts \ | ✗ Bash \ | ✓ Read ×3` |
| Agents | blue | 5 | ◐ Explore: finding auth; running subagents only | ||
| Todos | default / dim | 6 | ▸ Fix bug (2/5), or ✓ todos (5/5) when all are done |
Fields with no data are omitted. Branch and git information is omitted outside a repository. A row number only orders the content: rows with no visible field are skipped, so a line number never leaves a blank row.
The native hint is different from the other fields: when it is the only visible content and is on row 1, the plugin yields to the engine so its clickable pills stay live. As soon as any other field is visible, the hint row is redrawn as static text — the shortcut text is still there, but the pills are no longer clickable.
Every drawable field has two options (except show_footer_modes):
show_<field> — show or hide itline_<field> — place it on row 1–6Run /statusline to open the settings pane. Each field gets one row with an on/off switch and an L1–L6 button; pressing the line button cycles to the next row. The same values are available in /config as statusline.show_* and statusline.line_*.
Defaults preserve the original single-status-line behavior:
usage, cost, duration, tools, agents, and todos offshow_footer_modes on, so the engine's memory paused / account memory labels remain visibleSettings live in the user-level settings.json under pluginConfigs, not in the project. A bare /statusline opens this plugin's pane; /statusline with arguments (for example /statusline delete) still goes to Claude Code's built-in command.
turn.start and turn.complete, including requests made by subagents.Bash call would make the rate look like single digits. Thinking tokens and tool-call argument JSON count as output tokens.Usage windows come from Claude Code's rate_limits: five-hour and seven-day windows. They appear only for subscriber sessions and only after the first response; API-key, Bedrock, and Vertex sessions have no subscriber limits, so the field stays hidden. display-style reset countdowns are computed from the ISO reset time and the session clock.
Cost comes from the host's cost ledger. It is hidden when the host keeps no ledger. Duration is the time since the session started.
The tools line is fed live by tool.call; the agents line tracks Agent/Task calls; the todos line reads the latest TodoWrite input. When the plugin loads in the middle of a conversation, it seeds all three from $.session.messages(), so the footer is not empty until the next tool call.
◐ Edit: auth.ts✗ Bash✓ Read ×3, aggregated by tool name◐ Explore: finding auth▸ Fix bug (2/5), with the in-progress item or the first pending oneModel-written targets and todo text are stripped of control characters and truncation-capped before they reach the terminal.
statusline uses the Claude Code mod/hooks API:
ui.render for PromptHint draws the footer rows.ui.render for SessionMode can keep or clear the engine's footer mode labels.session.measure pushes usage, cost, and context figures without polling.tool.call, turn.start, turn.step, and turn.complete keep activity and output speed current.$.session.usage({ breakdown: 'summary' }) supplies the cache hit rate and the session start time.$.process.run reads git status, git diff, and git for-each-ref — only when a visible field needs them.$.state holds the figures; the render subscribes to them and repaints when they change.It is local-only: no network requests, no extra process, no tmux, and no separate window. It reads the session, the transcript, and local git metadata. It writes only the plugin's own $.state and the values you change in the settings pane.
PromptHint / SessionMode rows▶▶ bypass permissions on) is drawn by the engine and cannot be removed; it always shares the first row.PromptHint and SessionMode are raised on terminal and desktop only. Mobile and VS Code do not draw these rows.row1Hint, …), it skips PromptHint hooks; the native hint line is shown for that moment.turn.complete corrects the entry when the agent's turn ends.claude plugin validate .
claude plugin test .
The suite currently has 38 tests covering field order and colors, visibility and row placement, git parsing, remote-branch counting, Windows/POSIX paths, per-request and per-turn output speed, usage/cost/duration formatting, tool/agent/todo parsing, the SessionMode switch, the settings pane's toggle and line cycling, /statusline argument forwarding, old stored state, and the engine-hint fallback.
A folder marketplace loads in place: edit the source, then run /reload-plugins. To publish a new version, bump version in .claude-plugin/plugin.json and commit.
.claude-plugin/
plugin.json Manifest (name / version / userConfig)
marketplace.json Makes this folder a marketplace too
hooks/
hooks.json Module entry point
register.tsx Hooks: data collection, footer rendering, settings pane
layout.ts Field order, row placement, per-field segments
activity.ts Tool/agent/todo parsing and rendering
rate.ts Output speed: per request, then per turn
util.ts Pure helpers: git parsing, formatting, legacy line builder
types/index.d.ts $.state contract (checked by claude plugin validate)
tests/
statusline.test.ts Original fields, settings pane, speed, old state
layout.test.ts Rows, placement, new field formatting
footer.test.ts New fields, activity, SessionMode, empty tree, full pane
docs/screenshots/ README images: footer and settings pane
Claude Code statusline · Claude Code status line · Claude Code footer HUD · Claude Code plugin · Claude Code mod · Claude Code hooks · prompt footer · context window indicator · prompt cache hit rate · rate limit usage · git branch and working tree · output tokens per second · subagent status · todo progress · terminal status bar.
Claude Code 状态栏 · 输入框状态栏 · 终端 HUD · 上下文占用 · 用量限额 · git 状态 · 输出速率 · 子 agent · todo 进度.
If statusline saves you a /status or /cost lookup, a star helps other Claude Code users find it.
MIT © 2026 shanelevs
hooks/register.tsx 572 lines1import { atom, read, update } from 'claude-code'
2import type { ConfigRow, EngineInterface, Register } from 'claude-code'
3
4import {
5 INITIAL_ACTIVITY,
6 activityFromMessages,
7 agentType,
8 cleanText,
9 heldActivity,
10 isAgentTool,
11 parseTodos,
12 toolTarget,
13} from './activity'
14import {
15 DEFAULT_LINES,
16 FIELD_KEYS,
17 FIELD_ORDER,
18 MAX_LINE,
19 needsOf,
20 placementsOf,
21 planLines,
22 type FieldName,
23 type Needs,
24} from './layout'
25import { createRateMeter, isWritingChunk } from './rate'
26import {
27 INITIAL_STAT,
28 baseName,
29 cachePercentOf,
30 effortLabel,
31 heldStat,
32 parseGitStatus,
33 parseNumstat,
34 parseRemoteBranches,
35} from './util'
36import type { Tone } from './util'
37import type {
38 ActivityStat,
39 AgentEntry,
40 GitInfo,
41 StatusStat,
42 TokenRate,
43 ToolEntry,
44 UsageInfo,
45} from '../types'
46
47/** How often the line re-reads session and git state, in milliseconds. */
48const REFRESH_MS = 5000
49
50/** The plugin's own name: it keys the manifest's options and the `/config` rows. */
51const PLUGIN = 'statusline'
52
53/** The pane `/statusline` opens, and the id its `ui.render` hook answers. */
54const PANE = 'statusline-settings'
55
56const stat = atom({ plugin: 'statusline', key: 'stat' } as const, INITIAL_STAT)
57
58/** What the tools, agents and todo lines draw, separate from the session figures. */
59const activity = atom({ plugin: 'statusline', key: 'activity' } as const, INITIAL_ACTIVITY)
60
61/** The settings row whose button holds the focus ring, so the whole row can light. */
62const focused = atom({ plugin: 'statusline', key: 'focused' } as const, null as string | null)
63
64/** The grey a settings row turns under the pointer or the focus ring. */
65const ROW_HIGHLIGHT = 'gray'
66
67/** The config suffix each field reads, as a lookup for the pane. */
68const FIELD_BY_SUFFIX = new Map<string, FieldName>(
69 FIELD_ORDER.map(field => [FIELD_KEYS[field], field] as const),
70)
71
72/** The order fields appear in the settings pane. */
73const FIELD_RANK = new Map<string, number>(
74 FIELD_ORDER.map((field, index) => [FIELD_KEYS[field], index] as const),
75)
76
77/** How fast the model is writing, kept across the session; the host's state holds what the line shows. */
78const rate = createRateMeter()
79
80/** A fallback id for a tool call the engine did not name. */
81let callSeq = 0
82
83/** Guards a slow refresh from overlapping the next timer tick. */
84let isRefreshing = false
85
86/** The Text props one tone draws with. */
87function toneProps(tone: Tone): { color?: 'claude' | 'success' | 'warning' | 'error'; dimColor?: true } {
88 switch (tone) {
89 case 'accent':
90 return { color: 'claude' }
91 case 'good':
92 return { color: 'success' }
93 case 'warn':
94 return { color: 'warning' }
95 case 'bad':
96 return { color: 'error' }
97 case 'dim':
98 return { dimColor: true }
99 default:
100 return {}
101 }
102}
103
104async function patch($: EngineInterface, change: Partial<StatusStat>): Promise<void> {
105 try {
106 // Held over the defaults, so a value an older build stored is completed
107 // rather than kept short of the fields this one reads.
108 await update($, stat, current => heldStat({ ...current, ...change }))
109 } catch {
110 // Keep the last reading; the next refresh tries again.
111 }
112}
113
114async function patchActivity($: EngineInterface, change: (current: ActivityStat) => ActivityStat): Promise<void> {
115 try {
116 await update($, activity, current => change(heldActivity(current)))
117 } catch {
118 // Activity is best-effort; a failed write must not change a tool's result.
119 }
120}
121
122/** Draws a speed the meter has read, when it read one. */
123async function publishRate($: EngineInterface, reading: TokenRate | null): Promise<void> {
124 if (reading === null) return
125 await patch($, { tokenRate: reading })
126}
127
128/** One `git status` and one `git diff`, merged; null outside a repository. */
129async function gitOf($: EngineInterface): Promise<GitInfo | null> {
130 try {
131 const { exitCode, stdout } = await $.process.run(['git', 'status', '--porcelain=v2', '--branch'])
132 if (exitCode !== 0) return null
133
134 const head = parseGitStatus(stdout)
135 if (head === null) return null
136
137 let added = 0
138 let deleted = 0
139 try {
140 const diff = await $.process.run(['git', 'diff', 'HEAD', '--numstat'])
141 if (diff.exitCode === 0) ({ added, deleted } = parseNumstat(diff.stdout))
142 } catch {
143 // Keep 0/0; the branch and the ahead count still stand.
144 }
145
146 let remoteBranches = 0
147 try {
148 const refs = await $.process.run([
149 'git',
150 'for-each-ref',
151 '--format=%(refname)',
152 'refs/heads',
153 'refs/remotes',
154 ])
155 if (refs.exitCode === 0) remoteBranches = parseRemoteBranches(refs.stdout)
156 } catch {
157 // Keep 0; the rest of the git part still stands.
158 }
159
160 return { ...head, added, deleted, remoteBranches }
161 } catch {
162 return null
163 }
164}
165
166/** The five-hour and seven-day windows from the session's rate-limit list. */
167function usageInfoOf(
168 rateLimits: readonly { kind: string; percentUsed: number; resetsAt?: string }[],
169): UsageInfo | null {
170 const five = rateLimits.find(one => one.kind === 'five_hour')
171 const seven = rateLimits.find(one => one.kind === 'seven_day')
172 if (five === undefined && seven === undefined) return null
173
174 return {
175 fiveHour: five?.percentUsed ?? null,
176 sevenDay: seven?.percentUsed ?? null,
177 fiveHourResetsAt: five?.resetsAt ?? null,
178 sevenDayResetsAt: seven?.resetsAt ?? null,
179 }
180}
181
182/** The agent id a spawn result carries, when it carries one. */
183function agentIdOf(result: unknown): string | null {
184 if (typeof result !== 'object' || result === null) return null
185 const record = result as Record<string, unknown>
186 if (typeof record.agentId === 'string') return record.agentId
187
188 const inner = record.result
189 if (typeof inner === 'object' && inner !== null) {
190 const id = (inner as Record<string, unknown>).agentId
191 if (typeof id === 'string') return id
192 }
193 return null
194}
195
196/** Re-reads everything the line shows, in one state write. Never rejects. */
197async function refresh($: EngineInterface, needs: Needs): Promise<void> {
198 if (isRefreshing) return
199 isRefreshing = true
200
201 try {
202 const change: Partial<StatusStat> = {}
203
204 try {
205 change.now = await $.clock.now()
206 } catch {
207 change.now = null
208 }
209
210 if (needs.model) {
211 try {
212 change.model = await $.session.model()
213 } catch {}
214 }
215
216 if (needs.project) {
217 try {
218 change.project = baseName(await $.session.root())
219 } catch {}
220 }
221
222 if (needs.usage) {
223 try {
224 const usage = await $.session.usage({ breakdown: 'summary' })
225 change.contextPercent = usage.context.percent ?? null
226 change.contextWindow = usage.context.window
227 change.startedAt = usage.startedAt ?? null
228 change.usage = usageInfoOf(usage.rateLimits)
229 change.costUsd = usage.cost?.usd ?? null
230 const last = usage.context.breakdown?.apiUsage ?? null
231 change.cachePercent = last === null ? null : cachePercentOf(last)
232 } catch {}
233 }
234
235 if (needs.git) change.git = await gitOf($)
236
237 await patch($, change)
238 } finally {
239 isRefreshing = false
240 }
241}
242
243/** Seeds the activity lines with what the transcript already holds. */
244async function seedActivity($: EngineInterface): Promise<void> {
245 try {
246 const messages = await $.session.messages()
247 await update($, activity, () => activityFromMessages(messages))
248 } catch {
249 // Start empty; live tool calls fill it in.
250 }
251}
252
253function numericLine(value: unknown, field: FieldName | undefined): number {
254 const fallback = field === undefined ? 2 : DEFAULT_LINES[field]
255 const number = Number(value)
256 if (!Number.isFinite(number)) return fallback
257 return Math.min(MAX_LINE, Math.max(1, Math.round(number)))
258}
259
260export const register: Register = (on, options) => {
261 // What the line draws, and which data its visible fields need.
262 const placements = placementsOf(options)
263 const needs = needsOf(placements)
264
265 on('session.start', async ($, e, next) => {
266 await refresh($, needs)
267 if (needs.activity) await seedActivity($)
268 $.clock.every(REFRESH_MS, () => {
269 void refresh($, needs)
270 })
271 return next(e)
272 })
273
274 // `/statusline` is a built-in command, which a plugin may not register over.
275 // The bare command opens this plugin's settings; with arguments it goes on
276 // to the engine, so the built-in's own subcommands still work.
277 on('command.run', { command: PLUGIN }, async ($, e, next) => {
278 // `args` is "" for a bare command in a session; a test may leave it out.
279 if ((e.args ?? '').trim() !== '') return next(e)
280
281 // A pane reopened shows no stale highlight from the last time it was up.
282 await update($, focused, () => null)
283 await $.ui.open({ id: PANE, title: 'Status line', focus: true, closeOnEscape: true, rows: 20 })
284 return { text: 'Status line settings opened.' }
285 }).catch(($, e, next) => next.called ? next(e) : { text: 'Status line settings could not be opened.' })
286
287 // The focus ring moves between the pane's buttons; the row it lands on is
288 // drawn lit, so a person can tell which label the button belongs to.
289 on('ui.focus', { requestId: PANE }, async ($, e, next) => {
290 await update($, focused, () => e.element ?? null)
291 return next(e)
292 }).catch(($, e, next) => next(e))
293
294 // One row per manifest option, its label and value read from the engine's
295 // `/config` rows: the pane and `/config` show and write the same values.
296 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
297 const { Box, Text, Button } = $.ui.resolve(e)
298 const rows = await $.config.list()
299 const focusedKey = await read($, focused)
300
301 const lineRows = new Map<string, ConfigRow>()
302 for (const row of rows) {
303 if (row.key.startsWith(`${PLUGIN}.line_`)) lineRows.set(row.key.slice(`${PLUGIN}.line_`.length), row)
304 }
305
306 const showRows = rows
307 .filter(row => row.key.startsWith(`${PLUGIN}.show_`))
308 .sort((a, b) => {
309 const left = a.key.slice(`${PLUGIN}.show_`.length)
310 const right = b.key.slice(`${PLUGIN}.show_`.length)
311 return (FIELD_RANK.get(left) ?? 999) - (FIELD_RANK.get(right) ?? 999)
312 })
313
314 return (
315 <Box flexDirection="column">
316 <Text dimColor>Field · tap on/off and line</Text>
317 {showRows.map(row => {
318 const suffix = row.key.slice(`${PLUGIN}.show_`.length)
319 const field = FIELD_BY_SUFFIX.get(suffix)
320 const lineRow = lineRows.get(suffix)
321 const lineValue = numericLine(lineRow?.value, field)
322 const isLit = focusedKey === row.key || (lineRow !== undefined && focusedKey === lineRow.key)
323
324 return (
325 <Box
326 key={`${row.key}:row`}
327 flexDirection="row"
328 justifyContent="space-between"
329 backgroundColor={isLit ? ROW_HIGHLIGHT : undefined}
330 hover={{ backgroundColor: ROW_HIGHLIGHT }}
331 >
332 <Box flexShrink={1}>
333 <Text wrap="truncate">{row.label}</Text>
334 </Box>
335 <Box flexDirection="row" gap={1}>
336 <Button
337 key={row.key}
338 label={row.value === true ? 'on' : 'off'}
339 onPress={async () => {
340 if (row.isLocked) {
341 $.ui.toast(`${row.label}: locked`)
342 return
343 }
344 // Read the row's value now, so two quick presses toggle twice
345 // rather than writing the value the drawing was made with.
346 const current = (await $.config.list()).find(one => one.key === row.key)
347 const result = await $.config.set({ key: row.key, value: current?.value !== true })
348 if (result.deny !== undefined) $.ui.toast(`${row.label}: ${result.deny}`)
349 }}
350 />
351 {lineRow === undefined ? null : (
352 <Button
353 key={lineRow.key}
354 label={`L${lineValue}`}
355 onPress={async () => {
356 if (lineRow.isLocked) {
357 $.ui.toast(`${row.label}: line locked`)
358 return
359 }
360 const current = (await $.config.list()).find(one => one.key === lineRow.key)
361 const nextLine = (numericLine(current?.value, field) % MAX_LINE) + 1
362 const result = await $.config.set({ key: lineRow.key, value: nextLine })
363 if (result.deny !== undefined) $.ui.toast(`${row.label}: ${result.deny}`)
364 }}
365 />
366 )}
367 </Box>
368 </Box>
369 )
370 })}
371 </Box>
372 )
373 })
374
375 // A subagent's run raises no `turn.start`: every step of one turn, the
376 // spawner's and its subagents' alike, is counted into that turn.
377 on('turn.start', ($, e, next) => {
378 rate.startTurn()
379 return next(e)
380 })
381
382 on('turn.step', async function* ($, e, next) {
383 if (e.agentId === undefined) {
384 await patch($, { effort: effortLabel(e.effort) })
385 }
386
387 const sentAt = await $.clock.now()
388 let writingAt: number | null = null
389
390 const stream = next(e)
391 for await (const chunk of stream) {
392 // A request is timed by its writing, so the wait for its first token
393 // costs the rate nothing; one clock read per request, not per chunk.
394 if (writingAt === null && isWritingChunk(chunk)) writingAt = await $.clock.now()
395 yield chunk
396 }
397
398 // The response is in: its own token count settles the request, exactly.
399 const response = await stream.result
400 const settledAt = await $.clock.now()
401 await publishRate($, rate.endStep(response.usage?.output_tokens ?? null, settledAt - (writingAt ?? sentAt)))
402
403 return response
404 })
405
406 on('turn.complete', async ($, e, next) => {
407 const done = await next(e)
408
409 if (e.agentId !== undefined && placements.agents.show) {
410 // A background agent's own turn ending is the reliable completion mark.
411 await patchActivity($, current => ({
412 ...current,
413 agents: current.agents.map(one =>
414 one.agentId === e.agentId || one.id === e.agentId ? { ...one, status: 'done' } : one,
415 ),
416 }))
417 }
418
419 if (e.agentId === undefined) {
420 // The turn's average, kept on the line until the next turn settles its own.
421 await publishRate($, rate.endTurn())
422 await refresh($, needs)
423 }
424 return done
425 })
426
427 // Rate-limit and context figures arrive pushed, without polling.
428 if (needs.usage) {
429 on('session.measure', async ($, e, next) => {
430 let now: number | null = null
431 try {
432 now = await $.clock.now()
433 } catch {}
434
435 await patch($, {
436 contextPercent: e.context.percent ?? null,
437 contextWindow: e.context.window,
438 usage: usageInfoOf(e.rateLimits),
439 costUsd: e.cost?.usd ?? null,
440 now,
441 })
442 return next(e)
443 })
444 }
445
446 // The tool, agent and todo lines are fed live; a session that loads
447 // mid-conversation seeds them from the transcript at session.start.
448 if (needs.activity) {
449 on('tool.call', async ($, e, next) => {
450 const tool = e.tool
451 const id = typeof e.tool_use_id === 'string' ? e.tool_use_id : `${tool}:${callSeq++}`
452 const input = e as unknown as Record<string, unknown>
453
454 if (tool === 'TodoWrite') {
455 if (placements.todos.show) {
456 const todos = parseTodos(input)
457 if (todos !== null) await patchActivity($, current => ({ ...current, todos }))
458 }
459 return next(e)
460 }
461
462 if (isAgentTool(tool)) {
463 if (!placements.agents.show) return next(e)
464
465 const entry: AgentEntry = {
466 id,
467 type: agentType(input, tool),
468 description: cleanText(input.description ?? input.prompt, 40),
469 status: 'running',
470 }
471 await patchActivity($, current => ({
472 ...current,
473 agents: [...current.agents, entry].slice(-3),
474 }))
475
476 let ran
477 try {
478 ran = await next(e)
479 } catch (error) {
480 await patchActivity($, current => ({
481 ...current,
482 agents: current.agents.map(one => (one.id === id ? { ...one, status: 'done' } : one)),
483 }))
484 throw error
485 }
486
487 const agentId = agentIdOf(ran)
488 await patchActivity($, current => ({
489 ...current,
490 agents: current.agents.map(one =>
491 one.id === id
492 ? { ...one, status: 'done', ...(agentId === null ? {} : { agentId }) }
493 : one,
494 ),
495 }))
496 return ran
497 }
498
499 if (!placements.tools.show) return next(e)
500
501 const entry: ToolEntry = {
502 id,
503 tool,
504 target: toolTarget(tool, input),
505 status: 'running',
506 }
507 await patchActivity($, current => ({
508 ...current,
509 tools: [...current.tools, entry].slice(-6),
510 }))
511
512 let ran
513 try {
514 ran = await next(e)
515 } catch (error) {
516 await patchActivity($, current => ({
517 ...current,
518 tools: current.tools.map(one => (one.id === id ? { ...one, status: 'error' } : one)),
519 }))
520 throw error
521 }
522
523 const status = ran.deny !== undefined || ran.isError === true ? 'error' : 'done'
524 await patchActivity($, current => ({
525 ...current,
526 tools: current.tools.map(one => (one.id === id ? { ...one, status } : one)),
527 }))
528 return ran
529 }).catch(($, e, next) => next(e))
530 }
531
532 // The engine draws the footer's memory/account mode labels; the switch
533 // simply lets a person keep or clear them.
534 if (options.show_footer_modes === false) {
535 on('ui.render', { component: 'SessionMode' }, ($, e, next) =>
536 next({ ...e, props: { ...e.props, modes: [] } }),
537 )
538 }
539
540 // The dim hint line under the prompt. A tree replaces the engine's line,
541 // but the engine still adds the permission-mode indicator on the first row.
542 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
543 const held = heldStat(await read($, stat))
544 const act = heldActivity(await read($, activity))
545 const plan = planLines(placements, held, act, e.props.hint)
546
547 // Before any figures are known, let the engine draw its own hint line so
548 // its live pills and hotkeys stay live.
549 if (plan.hasOnlyHint) return next(e)
550
551 const { Box, Text } = $.ui.resolve(e)
552
553 // Everything is hidden: keep the engine's native hint out of the way, but
554 // the permission-mode indicator still shows on the row it adds.
555 if (plan.lines.length === 0) return <Box />
556
557 return (
558 <Box flexDirection="column">
559 {plan.lines.map(line => (
560 <Text key={line.line} wrap="truncate">
561 {line.segments.map((segment, index) => (
562 <Text key={index} {...toneProps(segment.tone)}>
563 {segment.text}
564 </Text>
565 ))}
566 </Text>
567 ))}
568 </Box>
569 )
570 })
571}
572hooks/activity.ts 238 lines1import type { ActivityStat, AgentEntry, TodoState, ToolEntry } from '../types'
2import { baseName, type Segment } from './util'
3
4/** What the activity line draws from before any tool call is known. */
5export const INITIAL_ACTIVITY: ActivityStat = {
6 tools: [],
7 agents: [],
8 todos: null,
9}
10
11/** Completes a value an older build or a partial write left short. */
12export function heldActivity(held: Partial<ActivityStat> | undefined): ActivityStat {
13 return {
14 tools: Array.isArray(held?.tools) ? held.tools : [],
15 agents: Array.isArray(held?.agents) ? held.agents : [],
16 todos: held?.todos ?? null,
17 }
18}
19
20/** The tool names that spawn an agent. */
21export function isAgentTool(tool: string): boolean {
22 return tool === 'Agent' || tool === 'Task'
23}
24
25/**
26 * Terminal-safe text from model-written values: no escape sequences or
27 * control characters, one line, capped with an ellipsis.
28 */
29export function cleanText(value: unknown, max = 48): string {
30 if (max <= 0) return ''
31 if (typeof value !== 'string' && typeof value !== 'number') return ''
32
33 let text = String(value)
34 text = text.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '')
35 text = text.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ')
36 text = text.replace(/\s+/g, ' ').trim()
37 if (text.length <= max) return text
38 return `${text.slice(0, max - 1)}…`
39}
40
41/** The type an agent call names, or a short fallback. */
42export function agentType(input: unknown, tool: string): string {
43 const args = isRecord(input) ? input : {}
44 const type = args.subagent_type
45 if (typeof type === 'string' && type.trim() !== '') return cleanText(type, 24)
46 return tool === 'Task' ? 'task' : 'agent'
47}
48
49/** The short target a tool call names, for the activity line. */
50export function toolTarget(tool: string, input: unknown): string {
51 const args = isRecord(input) ? input : {}
52 const file = firstString(args.file_path, args.path, args.notebook_path)
53 if (file !== null) return cleanText(baseName(file), 32)
54 if (tool === 'Bash') return cleanText(args.description ?? args.command, 32)
55 if (tool === 'Grep' || tool === 'Glob') return cleanText(args.pattern, 24)
56 if (tool === 'WebFetch') return cleanText(args.url, 32)
57 if (tool === 'WebSearch') return cleanText(args.query, 32)
58 if (isAgentTool(tool)) return cleanText(args.description, 32)
59 if (tool.startsWith('mcp__')) return cleanText(tool.split('__').at(-1), 24)
60 return ''
61}
62
63/** The latest TodoWrite list, or null when the call carried none. */
64export function parseTodos(input: unknown): TodoState {
65 const args = isRecord(input) ? input : {}
66 const raw = Array.isArray(args.todos) ? args.todos : Array.isArray(input) ? input : null
67 if (raw === null || raw.length === 0) return null
68
69 const items = raw
70 .filter(isRecord)
71 .map(item => ({
72 content: cleanText(item.content, 48),
73 status: typeof item.status === 'string' ? item.status : '',
74 }))
75 .filter(item => item.content !== '')
76
77 if (items.length === 0) return null
78 const done = items.filter(item => item.status === 'completed').length
79 const current = items.find(item => item.status === 'in_progress') ?? items.find(item => item.status === 'pending')
80 return { done, total: items.length, current: current?.content ?? null }
81}
82
83/** The activity line's tool run: running calls, errors, then completed counts. */
84export function toolLine(tools: readonly ToolEntry[]): Segment[] {
85 if (tools.length === 0) return []
86
87 const parts: Segment[][] = []
88 for (const entry of tools.filter(one => one.status === 'running').slice(-2)) {
89 const part: Segment[] = [
90 { text: '◐ ', tone: 'warn' },
91 { text: entry.tool, tone: 'plain' },
92 ]
93 if (entry.target !== '') part.push({ text: `: ${entry.target}`, tone: 'dim' })
94 parts.push(part)
95 }
96
97 for (const entry of tools.filter(one => one.status === 'error').slice(-2)) {
98 parts.push([
99 { text: '✗ ', tone: 'bad' },
100 { text: entry.tool, tone: 'bad' },
101 ])
102 }
103
104 const counts = new Map<string, number>()
105 const lastSeen = new Map<string, number>()
106 tools.forEach((entry, index) => {
107 if (entry.status !== 'done') return
108 counts.set(entry.tool, (counts.get(entry.tool) ?? 0) + 1)
109 lastSeen.set(entry.tool, index)
110 })
111 const groups = [...counts.entries()]
112 .sort((a, b) => (lastSeen.get(a[0]) ?? 0) - (lastSeen.get(b[0]) ?? 0))
113 .slice(-4)
114 for (const [tool, count] of groups) {
115 const part: Segment[] = [
116 { text: '✓ ', tone: 'good' },
117 { text: tool, tone: 'good' },
118 ]
119 if (count > 1) part.push({ text: ` ×${count}`, tone: 'dim' })
120 parts.push(part)
121 }
122
123 return joined(parts)
124}
125
126/** The activity line's agents: the ones still running, at most two. */
127export function agentLine(agents: readonly AgentEntry[]): Segment[] {
128 const running = agents.filter(entry => entry.status === 'running').slice(-2)
129 if (running.length === 0) return []
130
131 return joined(
132 running.map(entry => {
133 const part: Segment[] = [
134 { text: '◐ ', tone: 'accent' },
135 { text: entry.type, tone: 'accent' },
136 ]
137 if (entry.description !== '') part.push({ text: `: ${entry.description}`, tone: 'dim' })
138 return part
139 }),
140 )
141}
142
143/** The activity line's todo run: the current item and how many are done. */
144export function todoLine(todos: TodoState): Segment[] {
145 if (todos === null || todos.total <= 0) return []
146
147 if (todos.current === null && todos.done === todos.total) {
148 return [
149 { text: '✓ ', tone: 'good' },
150 { text: 'todos', tone: 'good' },
151 { text: ` (${todos.done}/${todos.total})`, tone: 'dim' },
152 ]
153 }
154
155 const parts: Segment[] = [{ text: '▸ ', tone: 'dim' }]
156 if (todos.current === null) parts.push({ text: 'todos', tone: 'dim' })
157 else parts.push({ text: todos.current, tone: 'plain' })
158 parts.push({ text: ` (${todos.done}/${todos.total})`, tone: 'dim' })
159 return parts
160}
161
162type ToolUseLike = {
163 tool_use_id: string
164 tool: string
165 input: Record<string, unknown>
166 text?: string
167 isError?: true
168 agentId?: string
169}
170
171type MessageLike = {
172 toolUses?: readonly ToolUseLike[]
173}
174
175/**
176 * Rebuilds the activity from the transcript rows: the last TodoWrite list,
177 * the latest tools, and the latest agents. Used once at session start, so a
178 * plugin that loads mid-session starts with what the conversation already did.
179 */
180export function activityFromMessages(messages: readonly MessageLike[]): ActivityStat {
181 const tools: ToolEntry[] = []
182 const agents: AgentEntry[] = []
183 let todos: TodoState = null
184
185 for (const message of messages) {
186 for (const use of message.toolUses ?? []) {
187 if (use.tool === 'TodoWrite') {
188 const parsed = parseTodos(use.input)
189 if (parsed !== null) todos = parsed
190 continue
191 }
192
193 const input = isRecord(use.input) ? use.input : {}
194 if (isAgentTool(use.tool)) {
195 const entry: AgentEntry = {
196 id: use.tool_use_id,
197 type: agentType(input, use.tool),
198 description: cleanText(input.description ?? input.prompt, 40),
199 status: use.text === undefined ? 'running' : 'done',
200 }
201 if (use.agentId !== undefined) entry.agentId = use.agentId
202 agents.push(entry)
203 continue
204 }
205
206 tools.push({
207 id: use.tool_use_id,
208 tool: use.tool,
209 target: toolTarget(use.tool, input),
210 status: use.text === undefined ? 'running' : use.isError === true ? 'error' : 'done',
211 })
212 }
213 }
214
215 return { tools: tools.slice(-6), agents: agents.slice(-3), todos }
216}
217
218/** Runs separated by a dim bar. */
219function joined(parts: Segment[][]): Segment[] {
220 const out: Segment[] = []
221 for (const part of parts) {
222 if (out.length > 0) out.push({ text: ' | ', tone: 'dim' })
223 out.push(...part)
224 }
225 return out
226}
227
228function firstString(...values: unknown[]): string | null {
229 for (const value of values) {
230 if (typeof value === 'string' && value !== '') return value
231 }
232 return null
233}
234
235function isRecord(value: unknown): value is Record<string, unknown> {
236 return typeof value === 'object' && value !== null && !Array.isArray(value)
237}
238hooks/layout.ts 353 lines1import type { ActivityStat, StatusStat, UsageInfo } from '../types'
2import { agentLine, todoLine, toolLine } from './activity'
3import {
4 branchSegment,
5 cacheSegment,
6 contextSegment,
7 effortSegment,
8 gitMarksSegments,
9 modelSegment,
10 projectSegment,
11 rateSegment,
12 remoteBranchesSegment,
13 type Segment,
14} from './util'
15
16/** Every field the footer can draw, in the order it appears within a line. */
17export const FIELD_ORDER = [
18 'hint',
19 'model',
20 'effort',
21 'context',
22 'cache',
23 'project',
24 'branch',
25 'git',
26 'remoteBranches',
27 'rate',
28 'usage',
29 'cost',
30 'duration',
31 'tools',
32 'agents',
33 'todos',
34] as const
35
36export type FieldName = (typeof FIELD_ORDER)[number]
37
38/** The `userConfig` suffix each field reads (`show_<key>` and `line_<key>`). */
39export const FIELD_KEYS: Record<FieldName, string> = {
40 hint: 'hint',
41 model: 'model',
42 effort: 'effort',
43 context: 'context',
44 cache: 'cache',
45 project: 'project',
46 branch: 'branch',
47 git: 'git',
48 remoteBranches: 'remote_branches',
49 rate: 'rate',
50 usage: 'usage',
51 cost: 'cost',
52 duration: 'duration',
53 tools: 'tools',
54 agents: 'agents',
55 todos: 'todos',
56}
57
58/** A field is drawn unless its `show_*` option says otherwise; new fields start hidden. */
59export const DEFAULT_SHOW: Record<FieldName, boolean> = {
60 hint: true,
61 model: true,
62 effort: true,
63 context: true,
64 cache: true,
65 project: true,
66 branch: true,
67 git: true,
68 remoteBranches: true,
69 rate: true,
70 usage: false,
71 cost: false,
72 duration: false,
73 tools: false,
74 agents: false,
75 todos: false,
76}
77
78/** The row each field takes when no `line_*` option names one. */
79export const DEFAULT_LINES: Record<FieldName, number> = {
80 hint: 1,
81 model: 2,
82 effort: 2,
83 context: 2,
84 cache: 2,
85 project: 2,
86 branch: 2,
87 git: 2,
88 remoteBranches: 2,
89 rate: 2,
90 usage: 3,
91 cost: 3,
92 duration: 3,
93 tools: 4,
94 agents: 5,
95 todos: 6,
96}
97
98/** The highest row a field can be placed on. */
99export const MAX_LINE = 6
100
101export type Placement = {
102 show: boolean
103 line: number
104}
105
106export type Placements = Record<FieldName, Placement>
107
108/** One drawn row: its configured row number and the runs it holds. */
109export type Line = {
110 line: number
111 segments: Segment[]
112}
113
114export type LinePlan = {
115 lines: Line[]
116 /** True when the only thing drawn is the native hint on row 1, which the engine can draw itself. */
117 hasOnlyHint: boolean
118}
119
120/** What a refresh must collect for the fields currently shown. */
121export type Needs = {
122 model: boolean
123 project: boolean
124 git: boolean
125 usage: boolean
126 activity: boolean
127}
128
129/** Reads the manifest options into per-field visibility and row placement. */
130export function placementsOf(options: Readonly<Record<string, unknown>>): Placements {
131 const out = {} as Placements
132
133 for (const field of FIELD_ORDER) {
134 const key = FIELD_KEYS[field]
135 const showOption = options[`show_${key}`]
136 const lineOption = options[`line_${key}`]
137 const rawLine = lineOption === undefined ? DEFAULT_LINES[field] : Number(lineOption)
138 const line = Number.isFinite(rawLine) ? clampLine(rawLine) : DEFAULT_LINES[field]
139
140 out[field] = {
141 show: showOption === undefined ? DEFAULT_SHOW[field] : showOption === true,
142 line,
143 }
144 }
145
146 return out
147}
148
149/** Which data sources the current placements need. */
150export function needsOf(placements: Placements): Needs {
151 return {
152 model: placements.model.show,
153 project: placements.project.show,
154 git: placements.branch.show || placements.git.show || placements.remoteBranches.show,
155 usage:
156 placements.context.show ||
157 placements.cache.show ||
158 placements.usage.show ||
159 placements.cost.show ||
160 placements.duration.show,
161 activity: placements.tools.show || placements.agents.show || placements.todos.show,
162 }
163}
164
165/** The segments each field can draw, by name; a field with no data is absent. */
166export function fieldSegments(
167 stat: StatusStat,
168 activity: ActivityStat,
169): Partial<Record<FieldName, Segment[]>> {
170 const fields: Partial<Record<FieldName, Segment[]>> = {}
171
172 const model = modelSegment(stat)
173 if (model !== null) fields.model = [model]
174 const effort = effortSegment(stat)
175 if (effort !== null) fields.effort = [effort]
176 const context = contextSegment(stat)
177 if (context !== null) fields.context = [context]
178 const cache = cacheSegment(stat)
179 if (cache !== null) fields.cache = [cache]
180 const project = projectSegment(stat)
181 if (project !== null) fields.project = [project]
182 const branch = branchSegment(stat)
183 if (branch !== null) fields.branch = [branch]
184 if (stat.git !== null) {
185 const marks = gitMarksSegments(stat)
186 if (marks.length > 0) fields.git = marks
187 }
188 const remote = remoteBranchesSegment(stat)
189 if (remote !== null) fields.remoteBranches = [remote]
190 const rate = rateSegment(stat)
191 if (rate !== null) fields.rate = [rate]
192
193 const usage = usageSegments(stat.usage, stat.now)
194 if (usage.length > 0) fields.usage = usage
195 const cost = costSegment(stat.costUsd)
196 if (cost !== null) fields.cost = [cost]
197 const duration = durationSegment(stat.startedAt, stat.now)
198 if (duration !== null) fields.duration = [duration]
199
200 const tools = toolLine(activity.tools)
201 if (tools.length > 0) fields.tools = tools
202 const agents = agentLine(activity.agents)
203 if (agents.length > 0) fields.agents = agents
204 const todos = todoLine(activity.todos)
205 if (todos.length > 0) fields.todos = todos
206
207 return fields
208}
209
210/**
211 * Groups the visible fields into their configured rows. Empty rows are
212 * skipped, so a line number orders the content rather than reserving a blank
213 * row. `hasOnlyHint` says the engine's own hint line can stand in when the
214 * hint is the only thing left on row 1.
215 */
216export function planLines(
217 placements: Placements,
218 stat: StatusStat,
219 activity: ActivityStat,
220 hintText: string,
221): LinePlan {
222 const fields = fieldSegments(stat, activity)
223 const lines: Line[] = []
224 let hasOther = false
225
226 for (let line = 1; line <= MAX_LINE; line += 1) {
227 const segments: Segment[] = []
228 let previous: FieldName | null = null
229
230 for (const field of FIELD_ORDER) {
231 const placement = placements[field]
232 if (!placement.show || placement.line !== line) continue
233
234 let fieldText: Segment[] | undefined
235 if (field === 'hint') {
236 if (typeof hintText !== 'string' || hintText === '') continue
237 fieldText = [{ text: hintText, tone: 'dim' }]
238 } else {
239 fieldText = fields[field]
240 if (fieldText === undefined || fieldText.length === 0) continue
241 hasOther = true
242 }
243
244 if (segments.length > 0) {
245 const space = field === 'remoteBranches' && previous === 'git'
246 segments.push({ text: space ? ' ' : ' · ', tone: 'dim' })
247 }
248 segments.push(...fieldText)
249 previous = field
250 }
251
252 if (segments.length > 0) lines.push({ line, segments })
253 }
254
255 const onlyHint =
256 !hasOther &&
257 placements.hint.show &&
258 placements.hint.line === 1 &&
259 typeof hintText === 'string' &&
260 hintText !== '' &&
261 lines.length === 1 &&
262 lines[0]?.line === 1
263
264 return { lines, hasOnlyHint: onlyHint }
265}
266
267/** The subscriber rate-limit windows, e.g. `Usage 5h 25% · 7d 41%`, or []. */
268export function usageSegments(usage: UsageInfo | null, now: number | null): Segment[] {
269 if (usage === null || typeof usage !== 'object') return []
270
271 const parts: Segment[] = []
272 const add = (label: string, percent: number | null, resetsAt: string | null): void => {
273 if (typeof percent !== 'number' || !Number.isFinite(percent)) return
274 if (parts.length > 0) parts.push({ text: ' · ', tone: 'dim' })
275 parts.push({ text: label, tone: 'dim' })
276 parts.push({ text: ' ', tone: 'dim' })
277 parts.push({ text: percentLabel(percent), tone: toneForPercent(percent) })
278 const reset = resetLabel(resetsAt, now)
279 if (reset !== null) parts.push({ text: ` (${reset})`, tone: 'dim' })
280 }
281
282 add('5h', usage.fiveHour, usage.fiveHourResetsAt)
283 add('7d', usage.sevenDay, usage.sevenDayResetsAt)
284 if (parts.length === 0) return []
285 parts.unshift({ text: 'Usage ', tone: 'dim' })
286 return parts
287}
288
289/** The session's spend, e.g. `$0.42`, or null when the host keeps no ledger. */
290export function costSegment(costUsd: number | null): Segment | null {
291 if (costUsd === null || !Number.isFinite(costUsd)) return null
292 return { text: `$${costUsd.toFixed(2)}`, tone: 'plain' }
293}
294
295/** How long the session has run, e.g. `⏱ 12m`, or null when the start is unknown. */
296export function durationSegment(startedAt: number | null, now: number | null): Segment | null {
297 if (
298 typeof startedAt !== 'number' ||
299 typeof now !== 'number' ||
300 !Number.isFinite(startedAt) ||
301 !Number.isFinite(now) ||
302 startedAt <= 0 ||
303 now < startedAt
304 ) {
305 return null
306 }
307 return { text: `⏱ ${formatDuration(now - startedAt)}`, tone: 'plain' }
308}
309
310/** A duration in the line's spelling: `45s`, `12m`, `1h 5m`, `2d 3h`. */
311export function formatDuration(ms: number): string {
312 if (!Number.isFinite(ms) || ms <= 0) return '0s'
313
314 const seconds = Math.floor(ms / 1000)
315 if (seconds < 60) return `${seconds}s`
316
317 const minutes = Math.floor(seconds / 60)
318 if (minutes < 60) return `${minutes}m`
319
320 const hours = Math.floor(minutes / 60)
321 if (hours < 24) {
322 const rest = minutes % 60
323 return rest === 0 ? `${hours}h` : `${hours}h ${rest}m`
324 }
325
326 const days = Math.floor(hours / 24)
327 const rest = hours % 24
328 return rest === 0 ? `${days}d` : `${days}d ${rest}h`
329}
330
331/** A rate-limit reset as a countdown from `now`, or null when it is in the past. */
332function resetLabel(resetsAt: string | null, now: number | null): string | null {
333 if (typeof resetsAt !== 'string' || typeof now !== 'number' || !Number.isFinite(now)) return null
334 const at = Date.parse(resetsAt)
335 if (!Number.isFinite(at) || at <= now) return null
336 return formatDuration(at - now)
337}
338
339/** `25%`, or `25.5%` when the window has a tenth. */
340function percentLabel(value: number): string {
341 const rounded = Math.round(value * 10) / 10
342 return Number.isInteger(rounded) ? `${rounded}%` : `${rounded.toFixed(1)}%`
343}
344
345/** How a rate-limit window is tinted by its fill. */
346function toneForPercent(value: number): Segment['tone'] {
347 return value >= 90 ? 'bad' : value >= 70 ? 'warn' : 'plain'
348}
349
350function clampLine(value: number): number {
351 return Math.min(MAX_LINE, Math.max(1, Math.round(value)))
352}
353hooks/rate.ts 84 lines1import type { TurnStepChunk } from 'claude-code'
2
3import type { TokenRate } from '../types'
4
5/**
6 * How fast the model writes, measured a request at a time.
7 *
8 * A request's figure is the output tokens the API reports for it over the time
9 * it spent writing — its first streamed characters to its last — so a tool
10 * call's own running time, the wait for the person and a slow first token all
11 * cost it nothing. A turn's figure is its requests' summed, which is what the
12 * line keeps once the turn is over.
13 *
14 * Nothing moves while a request writes: the line settles once per request and
15 * once more when the turn ends, rather than reading a rate off a stream that
16 * is still arriving.
17 */
18
19/** True for the chunks the model is paying output tokens for: its text, its thinking, a tool call's arguments. */
20export function isWritingChunk(chunk: TurnStepChunk): boolean {
21 switch (chunk.kind) {
22 case 'text':
23 case 'thinking':
24 case 'input':
25 return true
26 default:
27 return false
28 }
29}
30
31/** One request's, or one turn's, tokens over its writing time in milliseconds. */
32function rateOf(tokens: number, ms: number, isSettled: boolean): TokenRate | null {
33 if (tokens <= 0 || ms <= 0) return null
34
35 return { perSecond: tokens / (ms / 1000), isSettled }
36}
37
38export type RateMeter = {
39 /** A model turn is beginning: nothing counted yet. What the line shows stands until this turn's first request settles. */
40 startTurn: () => void
41 /**
42 * One request of the turn settled, having generated `tokens` (`null` when no
43 * response arrived) over `ms` of writing. Returns that request's own rate —
44 * the reading to draw now — or null when the request counts nothing.
45 */
46 endStep: (tokens: number | null, ms: number) => TokenRate | null
47 /** The turn ended: the average of its requests, or null when it counted nothing. */
48 endTurn: () => TokenRate | null
49}
50
51/**
52 * A meter for one session, fed what the caller measured: the requests' real
53 * token counts and their own writing times, so it holds no clock and a test
54 * can run a whole turn in microseconds.
55 */
56export function createRateMeter(): RateMeter {
57 /** The turn's requests so far: their output tokens, and the time they wrote for. */
58 let tokens = 0
59 let writingMs = 0
60
61 return {
62 startTurn() {
63 tokens = 0
64 writingMs = 0
65 },
66
67 endStep(stepTokens, ms) {
68 if (stepTokens === null || stepTokens <= 0 || ms <= 0) return null
69
70 tokens += stepTokens
71 writingMs += ms
72 return rateOf(stepTokens, ms, false)
73 },
74
75 endTurn() {
76 const rate = rateOf(tokens, writingMs, true)
77
78 tokens = 0
79 writingMs = 0
80 return rate
81 },
82 }
83}
84hooks/util.ts 339 lines1import type { ModelUsage } from 'claude-code'
2
3import type { StatusStat } from '../types'
4
5/** "1.0M", "200k", "850": a token count in the status line's own spelling. */
6export function formatTokens(n: number): string {
7 if (n >= 1_000_000) {
8 const millions = n / 1_000_000
9 return `${millions >= 10 ? Math.round(millions) : millions.toFixed(1)}M`
10 }
11 if (n >= 1_000) return `${Math.round(n / 1_000)}k`
12 return `${n}`
13}
14
15/** The share of the last response's input tokens the prompt cache served, 0-100. */
16export function cachePercentOf(usage: ModelUsage): number | null {
17 const total = usage.input_tokens + usage.cache_creation_input_tokens + usage.cache_read_input_tokens
18 if (total <= 0) return null
19 return Math.round((usage.cache_read_input_tokens / total) * 100)
20}
21
22/** The effort a model request asks for, as the line spells it; null when none. */
23export function effortLabel(effort: string | number | undefined): string | null {
24 return effort === undefined ? null : String(effort)
25}
26
27/** A speed in tokens a second, as the line spells it: `48 tok/s`, a tenth of one under ten. */
28export function formatRate(perSecond: number): string {
29 if (!Number.isFinite(perSecond) || perSecond <= 0) return '0 tok/s'
30
31 const shown = perSecond >= 10 ? Math.round(perSecond) : Math.round(perSecond * 10) / 10
32 return `${shown} tok/s`
33}
34
35/**
36 * Reads `git status --porcelain=v2 --branch`: the branch (or a detached
37 * short oid) and how many commits it is ahead of its upstream. Null when the
38 * output names no branch (not a repository, or a git error).
39 */
40export function parseGitStatus(stdout: string): { branch: string; ahead: number } | null {
41 let head = ''
42 let oid = ''
43 let ahead = 0
44
45 for (const line of stdout.split('\n')) {
46 if (line.startsWith('# branch.head ')) {
47 head = line.slice('# branch.head '.length).trim()
48 } else if (line.startsWith('# branch.oid ')) {
49 oid = line.slice('# branch.oid '.length).trim()
50 } else if (line.startsWith('# branch.ab ')) {
51 const match = /\+(\d+) -(\d+)/.exec(line)
52 if (match !== null) ahead = Number(match[1] ?? 0)
53 }
54 }
55
56 if (head === '') return null
57 const branch = head === '(detached)' ? detachedLabel(oid) : head
58 return { branch, ahead }
59}
60
61function detachedLabel(oid: string): string {
62 return oid === '' || oid === '(initial)' ? 'detached' : `detached@${oid.slice(0, 7)}`
63}
64
65/** Sums `git diff HEAD --numstat`'s columns; a binary entry's `-` adds nothing. */
66export function parseNumstat(stdout: string): { added: number; deleted: number } {
67 let added = 0
68 let deleted = 0
69
70 for (const line of stdout.split('\n')) {
71 const [a, d] = line.split('\t')
72 if (a === undefined || d === undefined) continue
73 const add = Number(a)
74 const del = Number(d)
75 if (Number.isFinite(add) && Number.isFinite(del)) {
76 added += add
77 deleted += del
78 }
79 }
80
81 return { added, deleted }
82}
83
84/**
85 * Reads `git for-each-ref --format=%(refname) refs/heads refs/remotes`: how
86 * many remote branches have no local branch of the same name. A remote's
87 * `HEAD` symref is skipped, and a name several remotes carry counts once.
88 */
89export function parseRemoteBranches(stdout: string): number {
90 const local = new Set<string>()
91 const remote = new Set<string>()
92
93 for (const line of stdout.split('\n')) {
94 const ref = line.trim()
95 if (ref.startsWith('refs/heads/')) {
96 local.add(ref.slice('refs/heads/'.length))
97 } else if (ref.startsWith('refs/remotes/')) {
98 const rest = ref.slice('refs/remotes/'.length)
99 const cut = rest.indexOf('/')
100 if (cut === -1) continue
101 const name = rest.slice(cut + 1)
102 if (name !== '' && name !== 'HEAD') remote.add(name)
103 }
104 }
105
106 let count = 0
107 for (const name of remote) {
108 if (!local.has(name)) count += 1
109 }
110 return count
111}
112
113/** The last path segment of `dir`, for the project name; both separators count. */
114export function baseName(dir: string): string {
115 const trimmed = dir.replace(/[\\/]+$/, '')
116 const cut = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
117 return cut === -1 ? trimmed : trimmed.slice(cut + 1)
118}
119
120/** How the line tints one run of text. */
121export type Tone = 'accent' | 'plain' | 'dim' | 'good' | 'warn' | 'bad'
122
123/** One run of the line: what it says and how it is tinted. */
124export type Segment = { text: string; tone: Tone }
125
126/**
127 * Which of the line's fields are drawn. Each comes from the manifest's
128 * `userConfig`, so a person can hide a field from `/statusline`.
129 */
130export type Visibility = {
131 model: boolean
132 effort: boolean
133 context: boolean
134 cache: boolean
135 project: boolean
136 branch: boolean
137 git: boolean
138 remoteBranches: boolean
139 rate: boolean
140}
141
142/** Every field drawn: what a manifest with no option switched off asks for. */
143export const ALL_VISIBLE: Visibility = {
144 model: true,
145 effort: true,
146 context: true,
147 cache: true,
148 project: true,
149 branch: true,
150 git: true,
151 remoteBranches: true,
152 rate: true,
153}
154
155/**
156 * Reads the line's options as `register(on, options)` receives them: a field
157 * is hidden only by its own option being `false`, so an older manifest or a
158 * test that names none still draws everything.
159 */
160export function visibilityOf(options: Readonly<Record<string, unknown>>): Visibility {
161 return {
162 model: options.show_model !== false,
163 effort: options.show_effort !== false,
164 context: options.show_context !== false,
165 cache: options.show_cache !== false,
166 project: options.show_project !== false,
167 branch: options.show_branch !== false,
168 git: options.show_git !== false,
169 remoteBranches: options.show_remote_branches !== false,
170 rate: options.show_rate !== false,
171 }
172}
173
174const SEPARATOR: Segment = { text: ' · ', tone: 'dim' }
175
176/** What the line draws from before anything is known: every field it can show, empty. */
177export const INITIAL_STAT: StatusStat = {
178 model: '',
179 effort: null,
180 cachePercent: null,
181 contextPercent: null,
182 contextWindow: null,
183 project: '',
184 git: null,
185 tokenRate: null,
186 usage: null,
187 costUsd: null,
188 startedAt: null,
189 now: null,
190}
191
192/**
193 * The value the line draws, from what the host holds under the plugin's key.
194 *
195 * The host keeps `$.state` across a hot reload and across versions, so what it
196 * holds may be a value an older build wrote, without a key this one reads:
197 * every field the line shows is filled from the defaults, and the fields the
198 * value does carry stand.
199 */
200export function heldStat(held: Partial<StatusStat> | undefined): StatusStat {
201 return { ...INITIAL_STAT, ...held }
202}
203
204/** The model field, or null while the name is unknown. */
205export function modelSegment(stat: StatusStat): Segment | null {
206 return stat.model === '' ? null : { text: stat.model, tone: 'accent' }
207}
208
209/** The effort field, or null when the last request named none. */
210export function effortSegment(stat: StatusStat): Segment | null {
211 return stat.effort === null ? null : { text: stat.effort, tone: 'plain' }
212}
213
214/** The context fill and window, tinted by how full the window is. */
215export function contextSegment(stat: StatusStat): Segment | null {
216 if (stat.contextPercent === null) return null
217 return {
218 text: `${stat.contextPercent}%${stat.contextWindow === null ? '' : `/${formatTokens(stat.contextWindow)}`}`,
219 tone: stat.contextPercent >= 90 ? 'bad' : stat.contextPercent >= 70 ? 'warn' : 'good',
220 }
221}
222
223/** The cache hit rate, or null while it is unknown. */
224export function cacheSegment(stat: StatusStat): Segment | null {
225 return stat.cachePercent === null ? null : { text: `${stat.cachePercent}%`, tone: 'plain' }
226}
227
228/** The project folder, or null while it is unknown. */
229export function projectSegment(stat: StatusStat): Segment | null {
230 return stat.project === '' ? null : { text: stat.project, tone: 'accent' }
231}
232
233/** The checked-out branch, or null outside a repository. */
234export function branchSegment(stat: StatusStat): Segment | null {
235 return stat.git === null ? null : { text: stat.git.branch, tone: 'accent' }
236}
237
238/**
239 * The working copy's marks: added/deleted lines, commits to push, or a green
240 * check when it is clean. Empty outside a repository.
241 */
242export function gitMarksSegments(stat: StatusStat): Segment[] {
243 if (stat.git === null) return []
244
245 const marks: Segment[] = []
246 if (stat.git.added > 0) marks.push({ text: `+${stat.git.added}`, tone: 'good' })
247 if (stat.git.deleted > 0) marks.push({ text: `-${stat.git.deleted}`, tone: 'bad' })
248 if (stat.git.ahead > 0) marks.push({ text: `↑${stat.git.ahead}`, tone: 'plain' })
249 if (marks.length === 0) return [{ text: '✓', tone: 'good' }]
250 return spaced(marks)
251}
252
253/** The count of remote branches with no local copy, or null when none is waiting. */
254export function remoteBranchesSegment(stat: StatusStat): Segment | null {
255 if (stat.git === null || stat.git.remoteBranches <= 0) return null
256 return { text: `br${stat.git.remoteBranches}`, tone: 'plain' }
257}
258
259/** The output speed, dim while the turn is still running. */
260export function rateSegment(stat: StatusStat): Segment | null {
261 if (stat.tokenRate === null) return null
262 return {
263 text: formatRate(stat.tokenRate.perSecond),
264 tone: stat.tokenRate.isSettled ? 'plain' : 'dim',
265 }
266}
267
268/**
269 * The one line the band draws under the prompt, fields in order and joined
270 * with " · ": model, effort, context fill and window, cache, project, branch,
271 * working-copy state, remote branches with no local copy, and how fast the
272 * model is writing. Unknown fields and those `visible` switches off drop out;
273 * [] when nothing is left.
274 */
275export function statusSegments(stat: StatusStat, visible: Visibility = ALL_VISIBLE): Segment[] {
276 const fields: Segment[][] = []
277
278 if (visible.model) {
279 const segment = modelSegment(stat)
280 if (segment !== null) fields.push([segment])
281 }
282 if (visible.effort) {
283 const segment = effortSegment(stat)
284 if (segment !== null) fields.push([segment])
285 }
286 if (visible.context) {
287 const segment = contextSegment(stat)
288 if (segment !== null) fields.push([segment])
289 }
290 if (visible.cache) {
291 const segment = cacheSegment(stat)
292 if (segment !== null) fields.push([segment])
293 }
294 if (visible.project) {
295 const segment = projectSegment(stat)
296 if (segment !== null) fields.push([segment])
297 }
298
299 if (stat.git !== null) {
300 if (visible.branch) {
301 const segment = branchSegment(stat)
302 if (segment !== null) fields.push([segment])
303 }
304
305 const gitField: Segment[] = []
306 if (visible.git) gitField.push(...gitMarksSegments(stat))
307 if (visible.remoteBranches) {
308 const segment = remoteBranchesSegment(stat)
309 if (segment !== null) {
310 if (gitField.length > 0) gitField.push({ text: ' ', tone: 'dim' })
311 gitField.push(segment)
312 }
313 }
314 if (gitField.length > 0) fields.push(gitField)
315 }
316
317 if (visible.rate) {
318 const segment = rateSegment(stat)
319 if (segment !== null) fields.push([segment])
320 }
321
322 const out: Segment[] = []
323 for (const field of fields) {
324 if (out.length > 0) out.push(SEPARATOR)
325 out.push(...field)
326 }
327 return out
328}
329
330/** One run's marks on a row, a space between them. */
331function spaced(marks: Segment[]): Segment[] {
332 const out: Segment[] = []
333 for (const mark of marks) {
334 if (out.length > 0) out.push({ text: ' ', tone: 'dim' })
335 out.push(mark)
336 }
337 return out
338}
339types/index.d.ts 125 lines1/**
2 * The status the line shows, one named value in `$.state` under the plugin's
3 * name (`statusline`) and key (`stat`), plus the activity under key
4 * (`activity`).
5 */
6
7/** The working copy's state, from one `git status` and one `git diff HEAD`. */
8export type GitInfo = {
9 /** The checked-out branch, or `detached@<short oid>` on a detached HEAD. */
10 branch: string
11 /** Commits to push: how far the branch is ahead of its upstream; 0 without one. */
12 ahead: number
13 /** Lines added in the working copy against HEAD. */
14 added: number
15 /** Lines deleted in the working copy against HEAD. */
16 deleted: number
17 /** Remote branches with no local branch of the same name. */
18 remoteBranches: number
19}
20
21/** How fast the model writes, as the line shows it. */
22export type TokenRate = {
23 /** Tokens a second: one request's own rate, or a whole turn's average. */
24 perSecond: number
25 /** True once the turn it belongs to ended, the figure its requests' average; false, one request's own. */
26 isSettled: boolean
27}
28
29/** The subscriber rate-limit windows the line can show. */
30export type UsageInfo = {
31 /** Share of the five-hour window used, 0–100; null when the session has no such window. */
32 fiveHour: number | null
33 /** Share of the seven-day window used, 0–100; null when the session has no such window. */
34 sevenDay: number | null
35 /** When the five-hour window resets, ISO 8601; null when unknown. */
36 fiveHourResetsAt: string | null
37 /** When the seven-day window resets, ISO 8601; null when unknown. */
38 sevenDayResetsAt: string | null
39}
40
41/** One tool call the activity line shows. */
42export type ToolEntry = {
43 /** The call's id, so a result can settle the entry it started. */
44 id: string
45 /** The tool's name (`Read`, `Bash`, `mcp__server__tool`). */
46 tool: string
47 /** The short target the line shows (`auth.ts`, a Bash description, a pattern); '' when none. */
48 target: string
49 /** Where the call stands. */
50 status: 'running' | 'done' | 'error'
51}
52
53/** One subagent the activity line shows. */
54export type AgentEntry = {
55 /** The spawning tool call's id. */
56 id: string
57 /** The agent type (`Explore`, `Plan`, a custom type); `agent` when unknown. */
58 type: string
59 /** The short description the line shows. */
60 description: string
61 /** Where the agent stands. */
62 status: 'running' | 'done'
63 /** The agent id Claude Code gave it, when known; lets `turn.complete` settle it. */
64 agentId?: string
65}
66
67/** The latest todo list, as the todo line shows it. */
68export type TodoState = {
69 /** Items completed. */
70 done: number
71 /** Items in the list. */
72 total: number
73 /** The in-progress item, or the first pending one, truncated; null when none is left. */
74 current: string | null
75} | null
76
77/** The activity the footer can draw: tools, agents, and the latest todo list. */
78export type ActivityStat = {
79 /** Recent tool calls, oldest first; the line shows the latest few. */
80 tools: ToolEntry[]
81 /** Recent subagents, oldest first. */
82 agents: AgentEntry[]
83 /** The latest todo list, or null before one is written. */
84 todos: TodoState
85}
86
87/** Everything the line shows. Nullable fields drop out of the line. */
88export type StatusStat = {
89 /** The main loop's model, as `/model` shows it. */
90 model: string
91 /** The effort the last model request asked for (`low` … `max`), or null. */
92 effort: string | null
93 /** Share of the live window's last response served from the prompt cache. */
94 cachePercent: number | null
95 /** The live context window's fill, 0–100. */
96 contextPercent: number | null
97 /** The live context window's size in tokens. */
98 contextWindow: number | null
99 /** The session's project root, its last path segment. */
100 project: string
101 /** The git working copy, or null outside a repository. */
102 git: GitInfo | null
103 /** How fast the model wrote, or null while nothing has been measured. */
104 tokenRate: TokenRate | null
105 /** The subscriber rate-limit windows, or null when the session has none. */
106 usage: UsageInfo | null
107 /** The session's cost in US dollars, or null where the host keeps no ledger. */
108 costUsd: number | null
109 /** When the session started, in `$.clock.now()` milliseconds; null when unknown. */
110 startedAt: number | null
111 /** The clock the last refresh read, in milliseconds; the line computes duration and resets from it. */
112 now: number | null
113}
114
115declare module 'claude-code' {
116 interface PluginState {
117 statusline: {
118 stat: StatusStat
119 activity: ActivityStat
120 /** The settings row whose button holds the focus ring, so the row can light. */
121 focused: string | null
122 }
123 }
124}
125