SLOPSHOPPER

statusline

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

newpanespinnerguardtoastprocess
v0.5.0MITupdated 2026-10-23ShaneLevs/statusline
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · statusline
│ ┃ statusline-settings ✕ › fix the failing auth test and add an audit log call │ ┃ Field · tap on/off and line │ ⏺ 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 │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts claude-opus-5-5 · 49%/200k · app

Draws

Pane · statusline-settings
Field · tap on/off and line
Prompt hint
? for shortcuts claude-opus-5-5 · 49%/200k · app
README

statusline

English | 简体中文

Claude Code plugin statusline version license platform tests GitHub stars

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.

Screenshots

The footer in a live session:

statusline footer

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

statusline settings pane

Install

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.

Install from a local folder (development)

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

Fields are joined with a dim · inside a row:

FieldColorDefault rowWhat it shows
Native hintdim1? for shortcuts, esc to interrupt, and the engine's other hints; live pills are kept when it is the only thing drawn
Modelblue2The name shown by /model
Thinking effortdefault2low … max
Contextgreen / yellow (≥70%) / red (≥90%)2used%/window, e.g. 9%/1.0M
Cache hit ratedefault2Share of the last response's input tokens served by the prompt cache
Projectblue2Last path segment of the session root
Branchblue2Current git branch (detached@<oid> on a detached HEAD)
Git changesgreen +N / red -N / ↑N2Added/deleted lines and commits to push; a green ✓ when clean
Remote branchesdefault2brN: remote branches with no local branch of the same name
Output speeddefault / dim while a turn runs2Tokens per second: the last request's own rate, then the turn average
Usage limitsdefault / yellow (≥70%) / red (≥90%)3Usage 5h 25% · 7d 41%, with reset countdowns when known
Session costdefault3$0.42 from Claude Code's cost ledger; omitted when no ledger exists
Session durationdefault3⏱ 12m from the start of the session
Toolsgreen / yellow / red4`◐ Edit: auth.ts \✗ Bash \✓ Read ×3`
Agentsblue5◐ Explore: finding auth; running subagents only
Todosdefault / dim6▸ 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.

Rows and settings

Every drawable field has two options (except show_footer_modes):

  • show_<field> — show or hide it
  • line_<field> — place it on row 1–6

Run /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:

  • native hint on row 1 and all nine original fields on row 2
  • usage, cost, duration, tools, agents, and todos off
  • show_footer_modes on, so the engine's memory paused / account memory labels remain visible

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

Output speed

  • A turn is everything between turn.start and turn.complete, including requests made by subagents.
  • Only writing time counts. A request is timed from its first streamed character to its last. Tool execution, waiting for the user, and time to first token are excluded; otherwise a one-minute Bash call would make the rate look like single digits. Thinking tokens and tool-call argument JSON count as output tokens.
  • The number does not move while streaming. When a request settles (the API reports its token count), the line shows that request's own average in a dim tone because the turn is still running. When the turn ends, it shows the average across all requests in the turn in the normal tone, and keeps it until the next turn's first request settles.
  • If a turn has no request that can be counted (interrupted with Esc, or no model call), the previous reading stays.

Usage limits, cost, and duration

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.

Activity: tools, agents, todos

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.

  • Running tools: ◐ Edit: auth.ts
  • Errors: ✗ Bash
  • Completed tools: ✓ Read ×3, aggregated by tool name
  • Running agents: ◐ Explore: finding auth
  • Todos: ▸ Fix bug (2/5), with the in-progress item or the first pending one

Model-written targets and todo text are stripped of control characters and truncation-capped before they reach the terminal.

How it works

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.

Requirements

  • Claude Code 2.1.309 or later, with mod/hooks support
  • A terminal or desktop surface for PromptHint / SessionMode rows
  • macOS, Linux, or Windows
  • No Node.js or Bun runtime of its own to manage

Limitations

  • The permission-mode indicator (▶▶ 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.
  • When the engine enters an interactive hint state (search, paste, exit message, row1Hint, …), it skips PromptHint hooks; the native hint line is shown for that moment.
  • The native hint keeps its live pills only when it is the sole visible content. With other fields visible, it is static text.
  • Usage limits need a subscriber session; cost needs a host cost ledger; tools/agents/todos need activity that has happened or been seeded from the transcript.
  • A background agent can be marked done when its spawn call returns; its own turn.complete corrects the entry when the agent's turn ends.

Development

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.

Repository layout

.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

Search terms

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 进度.

Star

If statusline saves you a /status or /cost lookup, a star helps other Claude Code users find it.

License

MIT © 2026 shanelevs

Source 6 files
hooks/register.tsx 572 lines
1import { 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}
572
hooks/activity.ts 238 lines
1import 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}
238
hooks/layout.ts 353 lines
1import 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}
353
hooks/rate.ts 84 lines
1import 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}
84
hooks/util.ts 339 lines
1import 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}
339
types/index.d.ts 125 lines
1/**
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