SLOPSHOPPER

Statusline Dashboard

A mod: an opinionated dashboard above the prompt with collapsible rows — context (with a per-category breakdown), rate-limit windows with pace balance, cost…

newbandguardcommandtoastprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · statusline-dashboard
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /dash ⎿ statusline-dashboard: Dashboard hidden — /dash shows it again. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

statusline-dashboard

An opinionated Claude Code dashboard: context, cost, rate-limit windows with pace balance, activity and sub-agents, git/PR, and full config-scope breakdowns, drawn with Nerd Font glyphs in the band above the prompt. Every row folds to one line and opens to its full detail.

It's a mod (Claude Code 2.1.287+): nothing to set up, nothing written to your settings. Install it and it draws. Uninstall it and it's gone.

▸ Model    󰚩 Opus 4.7 |  xhigh 󰛨 |  ████░░░░░░ 40% 80K/200K
  Limits   󰊚 5h ████▎░░░░░ 42% 15:00 +18% | 7d █▎░░░░░░░░ 13% Tue 09:00 +16%
  Usage     $1.50 |  ~$1.50/h |  44 tok/s |  API 12m |  1h
▾ Repo      cc-plugins | 󰊤 anthonybaldwin |  main ↑2 +1 ~3 ?1 |  #12 ● |  v1.2.0+3
           a1b2c3d Fix the pace sign  12m ago
           ~ plugins/statusline-dashboard/hooks/layout.ts
           ? notes.md
  Info.     ~/src/cc-plugins |  v2.1.310 |  abcd1234
+ Turn Activity Config Exts. Host

Install

/plugin marketplace add anthonybaldwin/cc-plugins
/plugin install statusline-dashboard@cc-plugins

Coming from 1.x? 1.x was a statusLine script wired into ~/.claude/settings.json. Run /dash cleanup once: it removes that block (it backs up settings.json to settings.json.bak first), so you don't get two dashboards. Coming from context-bar? It's now the Model row's open view. Run /plugin uninstall context-bar@cc-plugins.

Use

Click a row's ▸/▾ label (fullscreen), or ctrl+x tab then Tab and EnterOpen or close that row. Only rows with more to show have the arrow; a row without one is already whole.
/dashShow or hide the whole dashboard. Your choice is remembered across sessions.
/dash <row>Open or close one row (/dash config)
/dash open all · /dash close allOpen or close every row
/dash cleanupRemove the 1.x statusLine / subagentStatusLine blocks from settings.json (the agent panel's rows are now the agent-panel plugin)
ctrl+x ctrl+aClaude Code's own collapse for the band above the prompt

The band is shared with other mods and with Claude Code's surveys. A survey takes precedence while it's up.

How it fits the terminal

  • Closed rows are one line each. Items take their compact form (no bars, no scope parentheses) and are cut with … when even that won't fit.
  • Open rows show everything. Items wrap to aligned continuation lines, gauges keep their bars, and Config keeps its (m/u/p/l/x) breakdowns. Some rows add what a single line has no room for:
  • Model: the context window broken down by category, as /context shows it — a stacked bar, one colour per category, and a legend.
  • Turn: the last five turns — duration, output, input and how much of it came from the cache.
  • Activity: the todo list around the current item, the last tool calls with what each acted on (✔ answered, ✘ failed, … running; repeats collapse to ×n), and the session's sub-agents in the order they started, each with its task and how it ended.
  • Repo: the last commit and the first changed files.
  • Height budget. In fullscreen the dashboard takes what the bottom slot has left. On the main screen it takes at most a third of the terminal, so the conversation keeps its room. Over budget, in this order:
  • The oldest open row folds back to one line (▹). The row you opened last wins.
  • The least essential rows move into a one-line strip of toggles (+ Turn Activity …). They stay one click away.
  • Only then is the open row cut short.
  • Width. No line is ever wider than the band. A terminal hard-wrap would desync Claude Code's repaint, so Nerd Font glyphs are measured as two cells (fonts draw them one or two wide).
  • A row with nothing to show takes no space, for example Activity after /clear, or Repo outside a git repository.

What it shows

  • Model: the model (⚡ when fast mode is on), the reasoning effort of the last model request (styled to echo Claude Code's /effort menu) with a lamp when extended thinking is on, and the context gauge. The gauge warns early: yellow from 50%, red from 80%. Open for the per-category breakdown.
  • Limits: the 5h and 7d windows with bars, percentages, reset times and a signed pace balance against even consumption. +N% (green) is quota in hand, -N% (yellow) is burning ahead, and it's dim within ±2%. It also shows model-scoped weekly caps from the OAuth usage API (7dF for the Fable weekly allowance, on its own 0–100% scale). A Cr gauge appears once usage credits start being spent, and Sp appears behind a Claude apps gateway spend limit.
  • Usage: session cost, $/h burn rate, throughput (tok/s over the time the model was actually streaming), API time, and wall-clock.
  • Turn: the last call's token makeup (input / output / cache write / cache read) and the session's prompt-cache hit ratio.
  • Activity: the active slash command, todo or task progress, the last tool call (yellow while it runs) with a call count, sub-agents (running, a green ✔ tally of finished ones, a red ✘ for failed ones), agent-team teammates, and the session's edit volume (+added / −removed lines).
  • Repo (inside a git repository only): project name, origin owner with a GitHub / GitLab / Bitbucket badge, worktree, branch with ahead/behind and staged / modified / untracked / conflict counts, and the latest v* tag with commits since. With an authed gh, the branch's open PR also shows, colored by review state and clickable. gh runs at most every five minutes per branch.
  • Config: what's loaded, each item broken down by scope (see below): instruction files (CLAUDE.md / AGENTS.md), auto-memory files, agents, commands, skills, workflows, routines, rules, MCP servers, claude.ai connectors, claude-in-chrome, hooks, plugins, themes, and added directories.
  • Exts.: plugin-only component types: LSP servers, background monitors, bin/ executables, and message channels.
  • Host: clock, OS and version, user@host, and how many other Claude Code sessions are live on this machine.
  • Info.: current directory (leaf kept when long), output style, Claude Code version, agent name, and the session id.

Instruction files, agents, skills, MCP servers and the sub-agent roster come straight from Claude Code: what is actually loaded, with trust and precedence applied. The rest is counted from disk and gated on workspace trust the same way Claude Code gates loading.

Reading the (0/1/1/0/-) scope breakdown

icon total (m/u/p/l/x) is a count followed by where those items come from. The slots are fixed, broadest to narrowest:

SlotScopeWhere it lives
mmanagedEnterprise/org policy: managed-settings.json, managed-mcp.json, the Windows registry policy.
uuser~/.claude/ and ~/.claude.json. Loads in every project.
pprojectCommitted in the repo: .claude/, .mcp.json, CLAUDE.md.
llocalYour uncommitted overrides: .claude/settings.local.json, CLAUDE.local.md, ~/.claude.json projects[…].
xpluginBundled by enabled plugins.

A number (0 included) means the item can come from that scope, and that many currently do. A - means it never can. The parentheses appear only when the total is above 0.

ItemSlots that can hold a count
Instruction files, Pluginsm u p l
MCP servers, Hooksm u p l x
Agents, Skillsm u p x
Commands, Workflowsu p x
Rules, Routinesu p
Themes, Channelsu x

Requirements

  • Claude Code 2.1.287+ (mods). The dashboard draws in the terminal and in the Desktop app's Code tab. The VS Code chat panel and claude -p run the hooks but draw nothing.
  • A Nerd Font for the icons in the terminal. It doesn't have to be your primary font: Ghostty falls back to installed fonts automatically, and in Windows Terminal you can set the font face to e.g. MonoLisa, SymbolsNerdFont. The Desktop app draws no icons.
  • Optional: git for the Repo row, and an authed gh for its PR badge.

Configuration

Everything is optional. Create ~/.claude/statusline-dashboard.json, or run /statusline-dashboard:configure and describe what you want. Changes apply within a minute. The file lives outside the plugin, so updates and uninstalls never touch it.

{
  "rows": ["model", "limits", "usage", "repo", "info"],
  "hide": ["host.os", "usage.api", "config.scopes"],
  "expanded": ["limits"],
  "labels": false,
  "bars": true,
  "pace": true,
  "clock": "12h",
  "usageApi": true,
  "pr": true,
  "maxRows": 6
}
KeyDefaultEffect
rowsall rowsWhich rows render, and in what order: model, limits, usage, turn, activity, repo, config, exts, host, info.
hide[]Row ids or individual widget ids to drop (listed below).
expanded[]Rows open by default, until you first open or close one yourself.
labelstruefalse drops the row labels. The ▸/▾ toggles stay.
barstruefalse renders every gauge as a number only.
pacetruefalse drops the +N% / -N% pace balance.
clock"24h""12h" for a 12-hour clock and reset times.
usageApitruefalse never calls the usage API: no model-scoped weekly gauges and no Cr gauge.
prtruefalse never runs gh for the Repo row's PR badge.
maxRowsnoneCaps the dashboard's height. It can only lower the built-in budget.

Widget ids for hide:

RowWidgets
Modelmodel.effort model.thinking model.context
Limitslimits.5h limits.7d limits.scoped limits.spend limits.credits
Usageusage.cost usage.burn usage.speed usage.api usage.duration
Turnturn.tokens turn.cache turn.history
Activityactivity.command activity.todo activity.tool activity.agents activity.team activity.lines
Reporepo.remote repo.worktree repo.branch repo.pr repo.tag repo.files
Configconfig.instructions config.memory config.agents config.commands config.skills config.workflows config.routines config.rules config.mcp config.connectors config.chrome config.hooks config.plugins config.themes config.dirs config.scopes
Exts.exts.lsp exts.monitors exts.bin exts.channels
Hosthost.clock host.os host.user host.fleet
Info.info.cwd info.style info.version info.agent info.session

Unknown keys and ids are ignored. A file that isn't valid JSON falls back to the defaults, so a typo never blanks the dashboard. CLAUDE_STATUSLINE_CONFIG points it at a different file.

Uninstall

/plugin uninstall statusline-dashboard@cc-plugins. Nothing is left behind except your ~/.claude/statusline-dashboard.json, which is kept on purpose for a reinstall. Delete it if you don't want it.

How it works

hooks/register.tsx collects into $.state from Claude Code's events. The render hook for the band above the prompt only reads that state, so each update redraws just the band, and nothing ever blocks a render on git, the disk or the network.

EventWhat it refreshes
session.start, /clear, /resume, compactionEverything. The slow parts run off the event, so the first prompt never waits.
session.measure (after each turn, when a rate-limit window moves)Usage, plus the context breakdown when it changed
turn.step (each model request)Effort level and API time
turn.completeTokens, cache ratio, then git and the config counts
tool.callActivity: last tool, todos and tasks, edit lines
Every minuteClock, countdowns, pace, other sessions, git, the usage API (at most every 5 minutes)

The usage API call uses $.session.authorize(): Claude Code attaches your credential to the request itself, so the token never reaches the mod.

Tests: claude plugin test plugins/statusline-dashboard covers the layout arithmetic (width, height budget, the breakdown bar), the parsers, the activity bookkeeping, drawing on both surfaces, toggling rows, /dash, the user config, and the 1.x cleanup.

Source 5 files
hooks/register.tsx 528 lines
1// statusline-dashboard — a mod that draws the dashboard in the band above the prompt, one
2// collapsible row per section (Model, Limits, Usage, Turn, Activity, Repo, Config, Exts., Host,
3// Info.).
4//
5// WHY A MOD (2.x; 1.x was a Bun `statusLine` script): a plugin can't set `statusLine`, so 1.x had
6// to stage scripts into the data dir and edit settings.json — which an uninstall could never undo,
7// leaving a status line pointed at a deleted script. A mod needs no settings edit at all: install
8// and it draws, uninstall and it's gone. It also gets live engine truth the status line had to
9// reconstruct from the transcript and config files (the context breakdown, loaded memory files,
10// agents, skills and MCP tools, the agent roster, each model request's effort), and it can be
11// interactive: each row folds to one line and opens to its full detail on a click, or ctrl+x tab
12// then Tab/Enter on its label.
13//
14// Data flows one way: event hooks collect into $.state; the ui.render hook only reads it, so a
15// write redraws exactly the band. Nothing blocks the render on git, the filesystem or the network.
16
17import { atom, read, update } from 'claude-code'
18import type { EngineInterface, Register } from 'claude-code'
19
20import type { Activity, Repo, View } from '../types'
21import {
22  collectAgents,
23  collectApi,
24  collectBreakdown,
25  collectCounts,
26  collectHost,
27  collectInfo,
28  collectRepo,
29  collectUsage,
30  homeDir,
31  legacyKeys,
32  platform,
33  readJson,
34  removeLegacy,
35} from './collect'
36import { ROW_IDS, SECTION_WIDTH, legendLines, plan, stackRuns, stripLabels, type Line, type RowId, type Run } from './layout'
37import { buildSections, parseConfig } from './sections'
38
39const usage = atom({ plugin: 'statusline-dashboard', key: 'usage' } as const, null)
40const breakdown = atom({ plugin: 'statusline-dashboard', key: 'breakdown' } as const, null)
41const model = atom({ plugin: 'statusline-dashboard', key: 'model' } as const, null)
42const repo = atom({ plugin: 'statusline-dashboard', key: 'repo' } as const, null)
43const host = atom({ plugin: 'statusline-dashboard', key: 'host' } as const, null)
44const info = atom({ plugin: 'statusline-dashboard', key: 'info' } as const, null)
45const counts = atom({ plugin: 'statusline-dashboard', key: 'counts' } as const, null)
46const api = atom({ plugin: 'statusline-dashboard', key: 'api' } as const, null)
47const activity = atom({ plugin: 'statusline-dashboard', key: 'activity' } as const, null)
48const view = atom({ plugin: 'statusline-dashboard', key: 'view' } as const, { isVisible: true, expanded: [] } as View)
49const config = atom({ plugin: 'statusline-dashboard', key: 'config' } as const, null as unknown)
50const tick = atom({ plugin: 'statusline-dashboard', key: 'tick' } as const, 0)
51
52/** $.store key for the person's view (shown or not, which rows are open), kept across sessions. */
53export const STORE_VIEW = 'view'
54const API_TTL_MS = 5 * 60e3
55
56export const emptyActivity = (): Activity => ({
57  toolCalls: 0,
58  running: 0,
59  tasks: {},
60  linesAdded: 0,
61  linesRemoved: 0,
62  outputTokens: 0,
63  apiMs: 0,
64  cacheRead: 0,
65  inputTotal: 0,
66  agents: { running: 0, completed: 0, failed: 0, teammates: [] },
67})
68
69// ── collecting ───────────────────────────────────────────────────────────────────────────────────
70
71async function loadConfig($: EngineInterface): Promise<void> {
72  const path = (await $.env.get('CLAUDE_STATUSLINE_CONFIG')) || `${await homeDir($)}/.claude/statusline-dashboard.json`
73  const raw = await readJson($, path)
74  await update($, config, () => raw)
75}
76
77// The person's view, restored from $.store; $.state resets on /clear, so this runs on every
78// start-like event. Never toggled → the config file's `expanded` rows.
79async function loadView($: EngineInterface): Promise<void> {
80  const saved = (await $.store.get(STORE_VIEW)) as Partial<View> | undefined
81  const cfg = parseConfig(await read($, config))
82  const v: View = {
83    isVisible: saved?.isVisible !== false,
84    expanded: Array.isArray(saved?.expanded) ? saved.expanded.filter((id) => (ROW_IDS as readonly string[]).includes(id)) : cfg.expanded,
85  }
86  await update($, view, () => v)
87}
88
89async function saveView($: EngineInterface, fn: (v: View) => View): Promise<View> {
90  const next = await update($, view, (v) => fn(v ?? { isVisible: true, expanded: [] }))
91  await $.store.set(STORE_VIEW, next)
92  return next
93}
94
95/** Opens a closed row (as the newest open one) or closes an open one. */
96async function toggleRow($: EngineInterface, id: RowId): Promise<void> {
97  await saveView($, (v) => ({ ...v, expanded: v.expanded.includes(id) ? v.expanded.filter((x) => x !== id) : [...v.expanded, id] }))
98}
99
100async function refreshUsage($: EngineInterface): Promise<void> {
101  const u = await collectUsage($)
102  if (u) await update($, usage, () => u)
103}
104
105async function refreshBreakdown($: EngineInterface): Promise<void> {
106  const b = await collectBreakdown($)
107  if (b) await update($, breakdown, () => b)
108  const name = await $.session.model().catch(() => '')
109  const settings = (await $.settings.read().catch(() => ({}))) as Record<string, unknown>
110  await update($, model, (m) => ({
111    name: name || m?.name || 'Unknown Model',
112    effort: m?.effort,
113    thinking: settings.alwaysThinkingEnabled === true,
114    fast: settings.fastMode === true,
115  }))
116}
117
118async function refreshRepo($: EngineInterface): Promise<void> {
119  const cfg = parseConfig(await read($, config))
120  const prev = (await read($, repo)) as Repo | null
121  const r = await collectRepo($, await $.session.cwd(), prev, cfg.pr && !cfg.hide.has('repo.pr'), await $.clock.now())
122  await update($, repo, () => r)
123}
124
125async function refreshCounts($: EngineInterface): Promise<void> {
126  const home = await homeDir($)
127  const b = await read($, breakdown)
128  const r = (await read($, repo)) as Repo | null
129  // The project root: the working tree's top when in a repo (a worktree counts its own .claude/).
130  const cwd = await $.session.cwd()
131  const root = r ? ((await $.process.run(['git', 'rev-parse', '--show-toplevel'], { timeoutMs: 5000 }).catch(() => null))?.stdout.trim() || cwd) : cwd
132  const c = await collectCounts($, home, await platform($), root, b, b?.mcpServers ?? [])
133  await update($, counts, () => c)
134}
135
136async function refreshHost($: EngineInterface): Promise<void> {
137  const h = await collectHost($, await homeDir($), await $.session.id())
138  await update($, host, () => h)
139}
140
141async function refreshInfo($: EngineInterface): Promise<void> {
142  const i = await collectInfo($, await homeDir($))
143  await update($, info, () => i)
144}
145
146async function refreshAgents($: EngineInterface): Promise<void> {
147  const agents = await collectAgents($)
148  await update($, activity, (a) => ({ ...(a ?? emptyActivity()), agents }))
149}
150
151// The usage API at most every five minutes, and only for a subscription session (one whose
152// responses report rate-limit windows) with the config allowing it.
153async function refreshApi($: EngineInterface, force = false): Promise<void> {
154  const cfg = parseConfig(await read($, config))
155  if (!cfg.usageApi || (cfg.hide.has('limits.scoped') && cfg.hide.has('limits.credits'))) return
156  const u = await read($, usage)
157  if (!u?.limits.some((l) => l.kind === 'five_hour' || l.kind === 'seven_day')) return
158  const now = await $.clock.now()
159  const prev = await read($, api)
160  if (!force && prev && now - prev.at < API_TTL_MS) return
161  const a = await collectApi($, now)
162  await update($, api, () => a ?? (prev ? { ...prev, at: now } : null))
163}
164
165async function refreshAll($: EngineInterface): Promise<void> {
166  await loadConfig($)
167  await Promise.all([refreshUsage($), refreshBreakdown($), refreshInfo($), refreshRepo($), refreshHost($), refreshAgents($)])
168  await Promise.all([refreshCounts($), refreshApi($)])
169}
170
171// Every minute: the clock, reset countdowns and pace move with nothing else changing; the config
172// file may have been edited; other sessions come and go.
173async function everyMinute($: EngineInterface): Promise<void> {
174  await update($, tick, (n) => (n ?? 0) + 1)
175  await loadConfig($)
176  await Promise.all([refreshHost($), refreshApi($), refreshRepo($)])
177}
178
179// 1.x left `statusLine` blocks in settings.json pointing at its scripts: say so once per session.
180async function warnLegacy($: EngineInterface): Promise<void> {
181  const settings = await readJson($, `${await homeDir($)}/.claude/settings.json`)
182  if (legacyKeys(settings).length) $.ui.toast('statusline-dashboard now draws above the prompt — run /dash cleanup to remove the old status line from settings.json')
183}
184
185// ── activity ─────────────────────────────────────────────────────────────────────────────────────
186
187const base = (p: unknown) => String(p ?? '').replace(/\\/g, '/').split('/').pop() ?? ''
188const short = (s: unknown, n: number) => {
189  const t = String(s ?? '').replace(/\s+/g, ' ').trim()
190  return t.length <= n ? t : `${t.slice(0, n - 1)}…`
191}
192
193export function toolTarget(name: string, input: Record<string, unknown>): string | undefined {
194  switch (name) {
195    case 'Read':
196    case 'Write':
197    case 'Edit':
198    case 'MultiEdit':
199      return typeof input.file_path === 'string' ? base(input.file_path) : undefined
200    case 'Glob':
201    case 'Grep':
202      return typeof input.pattern === 'string' ? short(input.pattern, 20) : undefined
203    case 'Bash':
204      return typeof input.command === 'string' ? short(input.command, 25) : undefined
205    case 'Agent':
206    case 'Task':
207      return typeof input.subagent_type === 'string' ? input.subagent_type : typeof input.description === 'string' ? short(input.description, 25) : undefined
208    case 'Skill':
209      return typeof input.skill === 'string' ? input.skill : undefined
210    case 'WebFetch':
211      return typeof input.url === 'string' ? (input.url.match(/^[a-z]+:\/\/([^/]+)/i)?.[1] ?? short(input.url, 25)) : undefined
212    case 'WebSearch':
213      return typeof input.query === 'string' ? short(input.query, 25) : undefined
214    case 'TaskCreate':
215      return typeof input.subject === 'string' ? short(input.subject, 25) : undefined
216    case 'TaskUpdate':
217      return input.taskId !== undefined ? `#${String(input.taskId)}` : undefined
218    default:
219      return undefined
220  }
221}
222
223/** A tool's display name: an MCP tool's `mcp__server__tool` reads `server:tool`. */
224export const toolName = (name: string) => name.match(/^mcp__(.+?)__(.+)$/)?.slice(1).join(':') ?? name
225
226/** Lines an edit adds and removes: the old and new text with their common head and tail lines trimmed. */
227export function editLines(oldText: string, newText: string): { added: number; removed: number } {
228  const a = oldText ? oldText.split('\n') : []
229  const b = newText ? newText.split('\n') : []
230  let head = 0
231  while (head < a.length && head < b.length && a[head] === b[head]) head++
232  let tail = 0
233  while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail]) tail++
234  return { added: b.length - head - tail, removed: a.length - head - tail }
235}
236
237const normStatus = (s: unknown) => {
238  const v = String(s ?? '')
239  return v === 'not_started' ? 'pending' : v === 'running' ? 'in_progress' : v === 'complete' || v === 'done' ? 'completed' : v
240}
241
242/** Folds one main-loop tool call (its input, and its result text once answered) into the activity. */
243export function recordTool(a: Activity, name: string, input: Record<string, unknown>, resultText?: string, isError = false): Activity {
244  const next: Activity = { ...a, tasks: { ...a.tasks }, recentTools: [...(a.recentTools ?? [])] }
245  if (resultText === undefined) {
246    next.toolCalls += 1
247    next.running += 1
248    next.lastTool = { name: toolName(name), target: toolTarget(name, input) }
249    // Agent calls stay out: the agent list beside it shows each one, with its task and status.
250    if (name !== 'Agent' && name !== 'Task') next.recentTools = [...(next.recentTools ?? []), { name: toolName(name), target: toolTarget(name, input) }].slice(-8)
251    if (name === 'TodoWrite' && Array.isArray(input.todos)) {
252      const todos = input.todos as Array<{ status?: string; content?: string }>
253      const current = todos.find((t) => normStatus(t.status) === 'in_progress') ?? todos.find((t) => normStatus(t.status) === 'pending')
254      next.todos = { completed: todos.filter((t) => normStatus(t.status) === 'completed').length, total: todos.length, current: current?.content }
255      next.todoItems = todos.map((t) => ({ subject: String(t.content ?? ''), status: normStatus(t.status) }))
256    }
257    if (name === 'Edit') {
258      const d = editLines(String(input.old_string ?? ''), String(input.new_string ?? ''))
259      next.linesAdded += d.added
260      next.linesRemoved += d.removed
261    } else if (name === 'MultiEdit' && Array.isArray(input.edits)) {
262      for (const e of input.edits as Array<{ old_string?: string; new_string?: string }>) {
263        const d = editLines(String(e.old_string ?? ''), String(e.new_string ?? ''))
264        next.linesAdded += d.added
265        next.linesRemoved += d.removed
266      }
267    } else if (name === 'Write' && typeof input.content === 'string') {
268      next.linesAdded += input.content.split('\n').length
269    }
270    return next
271  }
272  next.running = Math.max(0, next.running - 1)
273  // Mark the newest unanswered call of this tool answered.
274  const tools = next.recentTools ?? []
275  for (let i = tools.length - 1; i >= 0; i--) {
276    const t = tools[i]
277    if (t && t.name === toolName(name) && t.ok === undefined) {
278      tools[i] = { ...t, ok: !isError }
279      break
280    }
281  }
282  // TaskCreate answers "Task #N created …"; TaskUpdate names the task it changes.
283  if (name === 'TaskCreate' && typeof input.subject === 'string') {
284    const id = resultText.match(/#(\d+)/)?.[1] ?? String(Object.keys(next.tasks).length + 1)
285    next.tasks[id] = { subject: input.subject, status: normStatus(input.status ?? 'pending') }
286  } else if (name === 'TaskUpdate' && input.taskId !== undefined) {
287    const id = String(input.taskId)
288    const t = next.tasks[id]
289    if (t) next.tasks[id] = { subject: typeof input.subject === 'string' ? input.subject : t.subject, status: input.status ? normStatus(input.status) : t.status }
290  }
291  return next
292}
293
294// ── drawing ──────────────────────────────────────────────────────────────────────────────────────
295
296export const register: Register = (on) => {
297  on('session.start', async ($, e, next) => {
298    await $.command.register({
299      name: 'dash',
300      description: 'Show or hide the dashboard above the prompt, open or close its rows, or clean up the 1.x status line',
301      argumentHint: '[row | open <row|all> | close <row|all> | show | hide | cleanup]',
302      immediate: true,
303    })
304    await update($, activity, (a) => a ?? emptyActivity())
305    await loadConfig($)
306    await loadView($)
307    await refreshUsage($)
308    // The slow part (git, config scans, the usage API) runs off the start, so it never holds up
309    // the first prompt; the band fills in as each piece lands.
310    $.clock.after(0, () => void refreshAll($).then(() => warnLegacy($)))
311    $.clock.every(60e3, () => void everyMinute($))
312    return next(e)
313  })
314
315  // /clear, /resume, /branch and compaction reset or reshape the session ($.state resets with
316  // /clear too): bring the view back and measure again.
317  on('classic.SessionStart', { source: ['clear', 'resume', 'fork', 'compact'] }, async ($, e, next) => {
318    if (e.source === 'clear') await update($, activity, () => emptyActivity())
319    await loadConfig($)
320    await loadView($)
321    $.clock.after(0, () => void refreshAll($))
322    return next(e)
323  }).catch(($, e, next) => next(e)) // observing only: on a failure, carry on as if unhooked
324
325  // Pushed after each main-thread turn and when a rate-limit window moves a point.
326  on('session.measure', async ($, e, next) => {
327    await refreshUsage($)
328    if (e.changed.includes('context')) await refreshBreakdown($)
329    if (e.changed.includes('rateLimits')) await refreshApi($)
330    return next(e)
331  })
332
333  // Each model request of the main loop: its effort is the live truth for the Model row, and its
334  // duration is API time (what tok/s divides by — tool and idle time excluded).
335  on('turn.step', async function* ($, e, next) {
336    if (e.agentId !== undefined) return yield* next(e)
337    const started = await $.clock.now()
338    if (typeof e.effort === 'string') {
339      const effort = e.effort
340      await update($, model, (m) => (m ? { ...m, effort } : m))
341    }
342    try {
343      return yield* next(e)
344    } finally {
345      const ms = (await $.clock.now()) - started
346      await update($, activity, (a) => ({ ...(a ?? emptyActivity()), apiMs: (a?.apiMs ?? 0) + ms }))
347    }
348  })
349
350  on('turn.complete', async ($, e, next) => {
351    const result = await next(e)
352    if (e.agentId === undefined) {
353      const u = e.usage
354      if (u) {
355        await update($, activity, (a) => {
356          const x = a ?? emptyActivity()
357          return {
358            ...x,
359            outputTokens: x.outputTokens + u.output_tokens,
360            cacheRead: x.cacheRead + u.cache_read_input_tokens,
361            inputTotal: x.inputTotal + u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens,
362            command: undefined,
363            turns: [
364              ...(x.turns ?? []),
365              { ms: e.durationMs, output: u.output_tokens, input: u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens, cacheRead: u.cache_read_input_tokens },
366            ].slice(-5),
367          }
368        })
369      }
370      // git and the config scan after the turn, off its dispatch: edits and commits land here.
371      $.clock.after(0, () => void refreshRepo($).then(() => refreshCounts($)))
372    }
373    await refreshAgents($)
374    return result
375  })
376
377  on('prompt.submit', async ($, e, next) => {
378    if (!e.isMeta) {
379      const command = e.command ? `/${e.command.name}` : undefined
380      await update($, activity, (a) => ({ ...(a ?? emptyActivity()), command }))
381    }
382    return next(e)
383  }).catch(($, e, next) => next(e)) // observing only: on a failure, carry on as if unhooked
384
385  // Observes main-loop tool calls only; never refuses one.
386  on('tool.call', async ($, e, next) => {
387    if (e.agentId !== undefined) return next(e)
388    const name = String(e.tool)
389    const input = e as unknown as Record<string, unknown>
390    await update($, activity, (a) => recordTool(a ?? emptyActivity(), name, input))
391    if (name === 'Agent' || name === 'Task') $.clock.after(500, () => void refreshAgents($))
392    const result = await next(e)
393    await update($, activity, (a) => recordTool(a ?? emptyActivity(), name, input, String(result.text ?? ''), 'deny' in result || result.isError === true))
394    return result
395  }).catch(($, e, next) => next(e)) // observing only: on a failure, carry on as if unhooked
396
397  on('command.run', { command: 'dash' }, async ($, e) => {
398    const [verb = '', arg = ''] = e.args.trim().toLowerCase().split(/\s+/)
399    const isRow = (s: string): s is RowId => (ROW_IDS as readonly string[]).includes(s.replace(/\.$/, ''))
400    const rowOf = (s: string) => s.replace(/\.$/, '') as RowId
401    if (verb === '') {
402      const v = await saveView($, (x) => ({ ...x, isVisible: !x.isVisible }))
403      return { text: v.isVisible ? 'Dashboard shown.' : 'Dashboard hidden — /dash shows it again.' }
404    }
405    if (verb === 'show' || verb === 'hide') {
406      await saveView($, (x) => ({ ...x, isVisible: verb === 'show' }))
407      return { text: verb === 'show' ? 'Dashboard shown.' : 'Dashboard hidden — /dash shows it again.' }
408    }
409    if (verb === 'cleanup') {
410      const removed = await removeLegacy($, await homeDir($))
411      return {
412        text: removed.length
413          ? `Removed ${removed.join(' and ')} from ~/.claude/settings.json (backup: settings.json.bak). Restart Claude Code to drop the old status line.`
414          : 'No statusline-dashboard 1.x status line found in ~/.claude/settings.json.',
415      }
416    }
417    if ((verb === 'open' || verb === 'close') && (arg === 'all' || isRow(arg))) {
418      await saveView($, (x) => {
419        if (arg === 'all') return { ...x, isVisible: true, expanded: verb === 'open' ? [...ROW_IDS] : [] }
420        const rest = x.expanded.filter((r) => r !== rowOf(arg))
421        return { ...x, isVisible: true, expanded: verb === 'open' ? [...rest, rowOf(arg)] : rest }
422      })
423      return { text: `${verb === 'open' ? 'Opened' : 'Closed'} ${arg}.` }
424    }
425    if (isRow(verb)) {
426      await toggleRow($, rowOf(verb))
427      await saveView($, (x) => ({ ...x, isVisible: true }))
428      return { text: `Toggled ${verb}.` }
429    }
430    return { text: `Usage: /dash [${ROW_IDS.join(' | ')} | open <row|all> | close <row|all> | show | hide | cleanup]` }
431  })
432
433  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
434    const v = await read($, view)
435    if (e.props.hasSurvey || !v.isVisible) return next(e)
436    const cfg = parseConfig(await read($, config))
437    await read($, tick) // subscribe: the minute tick redraws the clock and countdowns
438    const now = await $.clock.now()
439    const inputs = {
440      usage: await read($, usage),
441      breakdown: await read($, breakdown),
442      model: await read($, model),
443      repo: await read($, repo),
444      host: await read($, host),
445      info: await read($, info),
446      counts: await read($, counts),
447      api: await read($, api),
448      activity: await read($, activity),
449      now,
450    }
451    const glyphs = e.surface === 'terminal'
452    // One cell of cushion: a glyph a font draws wider than measured must never hard-wrap a row.
453    const width = Math.max(20, e.props.bodyColumns - 1)
454    const toggleW = 2 // "▸ "
455    const indent = toggleW + (cfg.labels ? SECTION_WIDTH + 1 : 0)
456    // Height: in fullscreen the band gets what the bottom slot has left (capped by the engine at
457    // half the terminal); on the main screen maxRows is the whole terminal, so take a third, as
458    // the status line did, leaving the transcript room.
459    const vp = e.viewport
460    const room = vp?.isFullscreen === false ? Math.floor(vp.rows / 3) : e.props.maxRows
461    const budget = Math.max(1, Math.min(room, e.props.maxRows, cfg.maxRows))
462
463    const sections = buildSections(inputs, cfg, glyphs, width - indent)
464    const b = inputs.breakdown
465    const modelRow = sections.find((s) => s.id === 'model')
466    if (modelRow && b && !cfg.hide.has('model.context')) {
467      const room = width - indent
468      modelRow.detail = [stackRuns(b.categories, room), ...legendLines(b.categories, b.maxTokens, room)]
469    }
470    const laid = plan(sections, v.expanded, budget, width, indent)
471    if (!laid.rows.length) return next(e)
472
473    const { Box, Text, Button, Link } = $.ui.resolve(e)
474    const runText = (r: Run, k: number) =>
475      r.href && Link ? (
476        <Link key={String(k)} href={r.href} label={r.t} />
477      ) : (
478        <Text key={String(k)} color={r.c} backgroundColor={r.bg} bold={r.b} italic={r.i} dimColor={r.d}>
479          {r.t}
480        </Text>
481      )
482    const runs = (rs: Run[]) =>
483      rs.map((r, i) => (
484        <Text key={String(i)} color={r.c} bold={r.b} italic={r.i} dimColor={r.d}>
485          {r.t}
486        </Text>
487      ))
488    // The head is a Button only when the row has more to show than its closed line; a row that is
489    // already whole gets its label with no arrow and nothing to press.
490    const line = (l: Line, head: Run[] | null, canToggle: boolean, id: RowId, k: number) => (
491      <Box key={`${id}:${k}`} flexDirection="row">
492        {!head ? (
493          <Text>{' '.repeat(indent)}</Text>
494        ) : canToggle ? (
495          <Button key={`row:${id}`} plain onPress={() => void toggleRow($, id)}>
496            {runs(head)}
497          </Button>
498        ) : (
499          <Box key={`head:${id}`} flexDirection="row">
500            {runs(head)}
501          </Box>
502        )}
503        {l.runs.map(runText)}
504      </Box>
505    )
506    const rows = laid.rows.flatMap((r) => {
507      // ▾ open · ▸ closed, more inside · ▹ open but folded to one line for room · none: nothing more
508      const arrow = !r.canToggle ? '  ' : r.state === 'open' ? '▾ ' : r.state === 'folded' ? '▹ ' : '▸ '
509      const head: Run[] = [{ t: arrow, c: 'suggestion' }, ...(cfg.labels ? [{ t: r.label.padEnd(SECTION_WIDTH) + ' ', b: true, i: true, d: true }] : [])]
510      return r.lines.map((l, k) => line(l, l.isHead ? head : null, r.canToggle, r.id, k))
511    })
512    if (laid.overflow.length) {
513      const labels = stripLabels(laid.overflow.map((o) => o.label), width)
514      rows.push(
515        <Box key="overflow" flexDirection="row" columnGap={1}>
516          <Text dimColor>+</Text>
517          {laid.overflow.slice(0, labels.length).map((o, i) => (
518            <Button key={`row:${o.id}`} plain dimColor onPress={() => void toggleRow($, o.id)}>
519              {labels[i] ?? o.label}
520            </Button>
521          ))}
522        </Box>,
523      )
524    }
525    return <Box flexDirection="column">{rows}</Box>
526  })
527}
528
hooks/collect.ts 637 lines
1// Collectors: everything that reads the engine, the filesystem, git or the network. Each returns
2// plain data for $.state; none draws. Top-level function declarations taking `$`, because the
3// validator follows `$` only into those.
4//
5// Where the engine knows the answer live (the context breakdown's memory files, agents, skills and
6// MCP tools; settings by source; the agent roster) it is asked rather than the files re-parsed —
7// that is what the status line had to guess from disk. Disk is read only for what the engine does
8// not expose (directory-based config the context doesn't list, plugin component counts, trust).
9// Every collector swallows its own failures and returns what it has: a missing file or a dead
10// subprocess costs one widget, never the band.
11
12import type { EngineInterface } from 'claude-code'
13
14import type { ApiUsage, Breakdown, Counts, Host, Info, Repo, Scopes, Segment, Usage } from '../types'
15
16type $ = EngineInterface
17
18// ── small I/O helpers ────────────────────────────────────────────────────────────────────────────
19
20const join = (...parts: string[]) => parts.filter(Boolean).join('/').replace(/\\/g, '/').replace(/\/{2,}/g, '/')
21const dirname = (p: string) => p.replace(/\\/g, '/').replace(/\/[^/]*$/, '') || '/'
22const basename = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '').split('/').pop() ?? p
23const norm = (s: string) => String(s || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
24
25export async function homeDir($: $): Promise<string> {
26  return ((await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || '').replace(/\\/g, '/')
27}
28
29export async function readText($: $, path: string): Promise<string | null> {
30  try {
31    return await $.fs.read(path)
32  } catch {
33    return null // missing, unreadable, or over the 4 MiB read limit
34  }
35}
36
37export async function readJson($: $, path: string): Promise<Record<string, any> | null> {
38  const text = await readText($, path)
39  if (text === null) return null
40  try {
41    const v = JSON.parse(text)
42    return v && typeof v === 'object' ? v : null
43  } catch {
44    return null
45  }
46}
47
48async function list($: $, dir: string) {
49  try {
50    return await $.fs.list(dir)
51  } catch {
52    return []
53  }
54}
55
56async function countFiles($: $, dir: string, pattern?: RegExp): Promise<number> {
57  return (await list($, dir)).filter((e) => !pattern || pattern.test(e.name)).length
58}
59
60// *.md counted down nested subdirs: agents and rules load that way, subdirs being organizational.
61async function countMdRecursive($: $, dir: string, depth = 0): Promise<number> {
62  if (depth > 6) return 0
63  let n = 0
64  for (const e of await list($, dir)) {
65    if (e.kind === 'dir') n += await countMdRecursive($, join(dir, e.name), depth + 1)
66    else if (/\.md$/i.test(e.name)) n++
67  }
68  return n
69}
70
71async function run($: $, argv: string[], cwd?: string, timeoutMs = 5000): Promise<string | null> {
72  try {
73    const r = await $.process.run(argv, { cwd, timeoutMs })
74    return r.exitCode === 0 ? r.stdout : null
75  } catch {
76    return null // not installed, or timed out
77  }
78}
79
80export async function platform($: $): Promise<'windows' | 'macos' | 'linux'> {
81  if ((await $.env.get('OS')) === 'Windows_NT') return 'windows'
82  const uname = (await run($, ['uname', '-s'])) ?? ''
83  return /darwin/i.test(uname) ? 'macos' : 'linux'
84}
85
86// ── usage and the context breakdown ──────────────────────────────────────────────────────────────
87
88export async function collectUsage($: $): Promise<Usage | null> {
89  try {
90    const u = await $.session.usage()
91    return {
92      startedAt: u.startedAt,
93      ctxTokens: u.context.tokens,
94      ctxWindow: u.context.window,
95      ctxPercent: u.context.percent,
96      limits: u.rateLimits.map((l) => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt })),
97      costUsd: u.cost?.usd,
98    }
99  } catch {
100    return null // no session bound yet
101  }
102}
103
104// The engine's word for where an agent/skill was defined → the scope slot it counts in.
105const SOURCE_SCOPE: Record<string, keyof Scopes> = {
106  policySettings: 'm',
107  userSettings: 'u',
108  projectSettings: 'p',
109  localSettings: 'l',
110  plugin: 'x',
111}
112// /context's memory-file labels → scope slots. AutoMem is the auto-memory index, counted apart.
113const MEMORY_SCOPE: Record<string, keyof Scopes> = { Managed: 'm', User: 'u', Project: 'p', Local: 'l' }
114
115function tally<T>(rows: readonly T[], scopeOf: (r: T) => keyof Scopes | undefined): Scopes {
116  const s: Scopes = {}
117  for (const r of rows) {
118    const k = scopeOf(r)
119    if (k) s[k] = (s[k] ?? 0) + 1
120  }
121  return s
122}
123
124// 'summary' estimates locally and sends no token-count requests.
125export async function collectBreakdown($: $): Promise<Breakdown | null> {
126  try {
127    const usage = await $.session.usage({ breakdown: 'summary' })
128    const b = usage.context.breakdown
129    if (!b) return null
130    const api = b.apiUsage
131    const autoMem = b.memoryFiles.find((f) => f.type === 'AutoMem')
132    return {
133      categories: b.categories.map((c): Segment => ({ name: c.name, tokens: c.tokens, color: c.color, kind: c.kind as Segment['kind'] })),
134      totalTokens: b.totalTokens,
135      maxTokens: b.rawMaxTokens,
136      percentage: b.percentage,
137      lastCall: api ? { input: api.input_tokens, output: api.output_tokens, cacheWrite: api.cache_creation_input_tokens, cacheRead: api.cache_read_input_tokens } : null,
138      // Rules files load as instruction files too, but have their own widget: keep them out here.
139      instructions: tally(
140        b.memoryFiles.filter((f) => !/\/\.claude\/rules\//.test(f.path.replace(/\\/g, '/'))),
141        (f) => MEMORY_SCOPE[f.type],
142      ),
143      memoryDir: autoMem ? dirname(autoMem.path) : undefined,
144      agents: tally(b.agents, (a) => SOURCE_SCOPE[a.source]),
145      skills: tally(b.skills?.skillFrontmatter ?? [], (s) => SOURCE_SCOPE[String(s.source)]),
146      mcpServers: [...new Set(b.mcpTools.map((t) => t.serverName))],
147    }
148  } catch {
149    return null
150  }
151}
152
153// ── git ──────────────────────────────────────────────────────────────────────────────────────────
154
155export function parseGitStatus(out: string) {
156  let branch = ''
157  let oid = ''
158  let ahead = 0
159  let behind = 0
160  let staged = 0
161  let modified = 0
162  let untracked = 0
163  let conflicts = 0
164  for (const line of out.split('\n')) {
165    if (line.startsWith('# branch.head ')) branch = line.slice(14).trim()
166    else if (line.startsWith('# branch.oid ')) oid = line.slice(13).trim()
167    else if (line.startsWith('# branch.ab ')) {
168      const m = line.match(/\+(\d+)\s+-(\d+)/)
169      if (m) {
170        ahead = Number(m[1]) || 0
171        behind = Number(m[2]) || 0
172      }
173    } else if (line[0] === '1' || line[0] === '2') {
174      const xy = line.split(' ')[1] ?? '..'
175      if (xy[0] !== '.') staged++
176      if (xy[1] !== '.') modified++
177    } else if (line[0] === 'u') conflicts++
178    else if (line[0] === '?') untracked++
179  }
180  if (!branch || branch === '(detached)') branch = oid && oid !== '(initial)' ? oid.slice(0, 7) : 'detached'
181  return { branch, ahead, behind, staged, modified, untracked, conflicts }
182}
183
184/** The changed paths in porcelain v2 output, first few kept: `M.` staged, `.M` modified, `??` untracked, `UU` conflict. */
185export function parseChanged(out: string, keep = 5): { files: Array<{ xy: string; path: string }>; changedTotal: number } {
186  const files: Array<{ xy: string; path: string }> = []
187  for (const line of out.split('\n')) {
188    const f = line.split(' ')
189    if (line[0] === '1' && f.length >= 9) files.push({ xy: f[1] ?? '..', path: f.slice(8).join(' ') })
190    else if (line[0] === '2' && f.length >= 10) files.push({ xy: f[1] ?? '..', path: f.slice(9).join(' ').split('\t')[0] ?? '' })
191    else if (line[0] === 'u' && f.length >= 11) files.push({ xy: f[1] ?? 'UU', path: f.slice(10).join(' ') })
192    else if (line.startsWith('? ')) files.push({ xy: '??', path: line.slice(2) })
193  }
194  return { files: files.slice(0, keep), changedTotal: files.length }
195}
196
197export function parseLastCommit(out: string | null) {
198  const [sha, ct, ...subject] = String(out ?? '').trim().split('\t')
199  return sha && ct ? { sha, at: Number(ct) * 1000, subject: subject.join('\t') } : undefined
200}
201
202// `--long` always emits "<tag>-<count>-g<sha>", even on the tag; tag names may contain dashes
203// (v1.2-rc1), so anchor the split on the trailing "-<N>-g<hex>".
204export function parseTag(out: string | null) {
205  const m = String(out ?? '').trim().match(/^(.+)-(\d+)-g[0-9a-f]+$/)
206  return m ? { tag: m[1] ?? '', count: Number(m[2]) || 0 } : undefined
207}
208
209/** `git@host:owner/name.git`, `https://host/owner/name`, `ssh://git@host:22/owner/name.git` → parts. */
210export function parseRemote(url: string | null) {
211  const s = String(url ?? '').trim()
212  const m = s.match(/^(?:[a-z+]+:\/\/)?(?:[^@/]+@)?([^/:]+)(?::\d+)?[:/](.+?)(?:\.git)?\/?$/i)
213  if (!m) return undefined
214  const path = (m[2] ?? '').split('/').filter(Boolean)
215  if (path.length < 2) return undefined
216  return { host: m[1] ?? '', owner: path.slice(0, -1).join('/'), name: path[path.length - 1] ?? '' }
217}
218
219const PR_TTL_MS = 5 * 60e3
220
221/**
222 * The Repo row: one concurrent batch of git subprocesses (status / toplevel+common dir / describe
223 * / origin), and — at most every five minutes per branch, when enabled — `gh pr view` for the
224 * branch's open PR. Null outside a repository.
225 */
226export async function collectRepo($: $, cwd: string, prev: Repo | null, withPr: boolean, now: number): Promise<Repo | null> {
227  const git = (...args: string[]) => run($, ['git', '--no-optional-locks', ...args], cwd)
228  const [st, rp, desc, origin, log] = await Promise.all([
229    git('status', '--porcelain=v2', '--branch'),
230    git('rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'),
231    git('describe', '--tags', '--long', '--match', 'v*', 'HEAD'),
232    git('remote', 'get-url', 'origin'),
233    git('log', '-1', '--format=%h%x09%ct%x09%s'),
234  ])
235  if (st === null) return null
236  const s = parseGitStatus(st)
237  const [top = cwd, common = ''] = (rp ?? '').split('\n').map((l) => l.trim()).filter(Boolean)
238  // The common git dir's parent is the MAIN repo root, so a linked worktree shows its parent
239  // repository's name as the headline and its own name as the worktree widget.
240  const stripped = common.replace(/[\\/]\.git[\\/]*$/i, '')
241  const main = stripped && stripped !== common ? stripped : top
242  const isWorktree = norm(main) !== norm(top)
243  const repo: Repo = {
244    name: basename(main),
245    ...s,
246    worktree: isWorktree ? basename(top) : undefined,
247    remote: parseRemote(origin),
248    tag: parseTag(desc),
249    ...parseChanged(st),
250    lastCommit: parseLastCommit(log),
251  }
252  if (withPr && repo.remote?.host.includes('github')) {
253    const fresh = prev?.prBranch === s.branch && prev.prCheckedAt !== undefined && now - prev.prCheckedAt < PR_TTL_MS
254    if (fresh) {
255      repo.pr = prev?.pr
256      repo.prCheckedAt = prev?.prCheckedAt
257    } else {
258      repo.pr = await prView($, cwd)
259      repo.prCheckedAt = now
260    }
261    repo.prBranch = s.branch
262  }
263  return repo
264}
265
266// The branch's open PR, colored by review state. `gh` missing, signed out, or no PR → undefined.
267async function prView($: $, cwd: string): Promise<Repo['pr']> {
268  const out = await run($, ['gh', 'pr', 'view', '--json', 'number,url,state,isDraft,reviewDecision'], cwd, 8000)
269  if (!out) return undefined
270  try {
271    const p = JSON.parse(out)
272    if (p.state !== 'OPEN' || typeof p.number !== 'number') return undefined
273    const rd = String(p.reviewDecision ?? '')
274    const state = p.isDraft ? 'draft' : rd === 'APPROVED' ? 'approved' : rd === 'CHANGES_REQUESTED' ? 'changes' : rd === 'REVIEW_REQUIRED' ? 'pending' : 'open'
275    return { number: p.number, url: String(p.url ?? ''), state }
276  } catch {
277    return undefined
278  }
279}
280
281// ── host ─────────────────────────────────────────────────────────────────────────────────────────
282
283async function osLabel($: $, kind: Host['os']['kind']): Promise<string> {
284  if (kind === 'windows') {
285    // "Microsoft Windows [Version 10.0.26200.1234]": 11 from build 22000.
286    const ver = (await run($, ['cmd', '/d', '/c', 'ver'])) ?? ''
287    const build = Number(ver.match(/\d+\.\d+\.(\d+)/)?.[1]) || 0
288    return build ? `Win ${build >= 22000 ? '11' : '10'} (${build})` : 'Windows'
289  }
290  if (kind === 'macos') {
291    const plist = (await readText($, '/System/Library/CoreServices/SystemVersion.plist')) ?? ''
292    const ver = plist.match(/<key>ProductVersion<\/key>\s*<string>([^<]+)<\/string>/)?.[1]?.trim()
293    return ver ? `macOS ${ver}` : 'macOS'
294  }
295  // Short "Name Version" from /etc/os-release: Debian 12, Ubuntu 22.04, …
296  const txt = (await readText($, '/etc/os-release')) ?? ''
297  const field = (k: string) => txt.match(new RegExp(`^${k}="?([^"\\n]+)`, 'm'))?.[1]
298  const id = field('ID')
299  if (!id) return field('PRETTY_NAME') ?? 'Linux'
300  const name = id.charAt(0).toUpperCase() + id.slice(1)
301  const ver = field('VERSION_ID')
302  return ver ? `${name} ${ver}` : name
303}
304
305/**
306 * Other live Claude Code sessions on this machine. Claude Code registers each running process in
307 * ~/.claude/sessions/<pid>.json ({pid, sessionId, name, …}); a crashed process can leave its file
308 * behind, so a pid only counts once one `ps`/`tasklist` call shows it alive.
309 */
310async function fleet($: $, home: string, kind: Host['os']['kind'], sessionId: string): Promise<string[]> {
311  const dir = join(home, '.claude', 'sessions')
312  const entries: Array<{ pid: number; name: string }> = []
313  for (const e of await list($, dir)) {
314    if (!e.name.endsWith('.json')) continue
315    const s = await readJson($, join(dir, e.name))
316    if (!s?.pid || !s.sessionId || s.sessionId === sessionId) continue
317    entries.push({ pid: Number(s.pid), name: String(s.name || s.pid) })
318  }
319  if (!entries.length) return []
320  const alive = new Set<number>()
321  if (kind === 'windows') {
322    for (const line of ((await run($, ['tasklist', '/FO', 'CSV', '/NH'])) ?? '').split('\n')) {
323      const pid = Number(line.split('","')[1])
324      if (pid) alive.add(pid)
325    }
326  } else {
327    const out = (await run($, ['ps', '-o', 'pid=', '-p', entries.map((e) => e.pid).join(',')])) ?? ''
328    for (const t of out.split(/\s+/)) if (Number(t)) alive.add(Number(t))
329  }
330  return entries.filter((e) => alive.has(e.pid)).map((e) => e.name)
331}
332
333export async function collectHost($: $, home: string, sessionId: string): Promise<Host> {
334  const kind = await platform($)
335  const user = (await $.env.get('USER')) || (await $.env.get('USERNAME')) || ''
336  let host = (await $.env.get('HOSTNAME')) || (await $.env.get('COMPUTERNAME')) || ''
337  if (!host) host = ((await run($, ['hostname'])) ?? '').trim()
338  return {
339    os: { kind, label: await osLabel($, kind) },
340    user,
341    host: host.split('.')[0] ?? host, // drop .local / the domain
342    fleet: await fleet($, home, kind, sessionId),
343  }
344}
345
346export async function collectInfo($: $, home: string): Promise<Info> {
347  const settings = await $.settings.read().catch(() => ({}) as Record<string, unknown>)
348  return {
349    cwd: await $.session.cwd(),
350    home,
351    version: (await $.session.version()).version,
352    sessionId: await $.session.id(),
353    outputStyle: typeof settings.outputStyle === 'string' ? settings.outputStyle : undefined,
354    agent: typeof settings.agent === 'string' ? settings.agent : undefined,
355  }
356}
357
358// ── the OAuth usage API ──────────────────────────────────────────────────────────────────────────
359// Model-scoped weekly caps and usage-credit spend are not in the session's rate limits; only the
360// OAuth usage endpoint has them. The session's credential stays in the engine: `authorize` hands
361// back an opaque handle and the engine sets the header on a first-party https request.
362
363export async function collectApi($: $, now: number): Promise<ApiUsage | null> {
364  try {
365    const auth = await $.session.authorize()
366    if (!auth || auth.kind !== 'bearer') return null // an API key or a gateway has no subscription usage
367    const res = await $.http.fetch('https://api.anthropic.com/api/oauth/usage', {
368      auth: auth.handle,
369      headers: { Accept: 'application/json', 'anthropic-beta': 'oauth-2025-04-20' },
370    })
371    if (!res.ok) return null
372    return parseApiUsage(JSON.parse(res.text), now)
373  } catch {
374    return null
375  }
376}
377
378export function parseApiUsage(u: any, now: number): ApiUsage {
379  const scoped: ApiUsage['scoped'] = []
380  // The modern `limits` array: `weekly_scoped` entries are per-model caps, each on its own 0-100%
381  // scale; `session` / `weekly_all` duplicate the 5h / 7d windows the session already reports.
382  // Labelled 7d + the model's initial (Fable → 7dF).
383  for (const l of Array.isArray(u?.limits) ? u.limits : []) {
384    if (l?.kind !== 'weekly_scoped') continue
385    const name = String(l.scope?.model?.display_name || l.scope?.surface || '').trim()
386    const pct = Number(l.percent)
387    if (Number.isFinite(pct)) scoped.push({ label: `7d${(name.charAt(0) || '?').toUpperCase()}`, percent: pct, resetsAt: l.resets_at })
388  }
389  // Credits in minor currency units (amount_minor 5000, exponent 2 = $50.00); shown only once
390  // something has actually been spent this month.
391  let credits: ApiUsage['credits']
392  const sp = u?.spend
393  const minor = (m: any) => (Number(m?.amount_minor) || 0) / 10 ** (Number.isFinite(Number(m?.exponent)) ? Number(m.exponent) : 2)
394  if (sp?.enabled && Number(sp.used?.amount_minor) > 0) {
395    const used = minor(sp.used)
396    const limit = minor(sp.limit)
397    credits = { used, limit, percent: Math.round(Number(sp.percent) || (limit > 0 ? (used / limit) * 100 : 0)) }
398  }
399  return { at: now, scoped, credits }
400}
401
402// ── agents ───────────────────────────────────────────────────────────────────────────────────────
403
404export async function collectAgents($: $) {
405  try {
406    const all = await $.agent.list()
407    const subs = all.filter((a) => !a.teammateId)
408    return {
409      running: subs.filter((a) => a.status === 'running' || a.status === 'pending' || a.status === 'waiting').length,
410      completed: subs.filter((a) => a.status === 'completed').length,
411      failed: subs.filter((a) => a.status === 'failed' || a.status === 'killed').length,
412      teammates: all.filter((a) => a.teammateId).map((a) => a.name ?? a.teammateId?.split('@')[0] ?? '?'),
413      // In start order (the roster's), the last five, plus any older one still running.
414      list: subs
415        .filter((a, i) => i >= subs.length - 5 || a.status === 'running' || a.status === 'pending' || a.status === 'waiting')
416        .map((a) => ({ type: a.type, description: a.description, status: a.status })),
417    }
418  } catch {
419    return { running: 0, completed: 0, failed: 0, teammates: [] }
420  }
421}
422
423// ── config counts ────────────────────────────────────────────────────────────────────────────────
424// Scopes, broad → narrow: m = managed (policy), u = user (~/.claude), p = project (committed in the
425// repo), l = local (uncommitted per-project overrides), x = plugin (bundled by enabled plugins).
426// Instruction files, agents and skills come from the engine's context breakdown (exactly what is
427// loaded, trust and precedence applied); the rest is counted from disk and gated on workspace
428// trust the way Claude Code gates loading: an untrusted workspace loads no EXECUTABLE project or
429// local config (hooks, MCP, commands, workflows, routines, project-enabled plugins), while content
430// (rules) still loads.
431
432function managedDir(kind: Host['os']['kind']): string {
433  if (kind === 'windows') return 'C:/Program Files/ClaudeCode'
434  if (kind === 'macos') return '/Library/Application Support/ClaudeCode'
435  return '/etc/claude-code'
436}
437
438function countHookMap(hooks: unknown): number {
439  if (!hooks || typeof hooks !== 'object') return 0
440  let n = 0
441  for (const groups of Object.values(hooks as Record<string, unknown>)) {
442    if (!Array.isArray(groups)) continue
443    for (const g of groups) n += Array.isArray(g?.hooks) ? g.hooks.length : 1
444  }
445  return n
446}
447
448const enabledNames = (ep: unknown) =>
449  ep && typeof ep === 'object' ? Object.entries(ep as Record<string, unknown>).filter(([, v]) => v === true).map(([k]) => k) : []
450
451async function settingsOf($: $, source: 'user' | 'project' | 'local' | 'policy'): Promise<Record<string, any>> {
452  try {
453    return (await $.settings.read({ source })) as Record<string, any>
454  } catch {
455    return {}
456  }
457}
458
459/**
460 * Components the ENABLED plugins bundle (the x scope), de-duped by base name so a plugin enabled
461 * from two marketplaces counts once. Component declarations can also live INLINE in a plugin's
462 * marketplace entry (the official LSP plugins ship a stub folder and declare `lspServers` there),
463 * so each plugin's manifest is merged over its marketplace entry.
464 */
465async function pluginComponents($: $, home: string, keys: string[], mcpOff: Set<string>) {
466  const out = { agents: 0, commands: 0, skills: 0, hooks: 0, mcps: 0, workflows: 0, lsp: 0, monitors: 0, themes: 0, bin: 0, channelNames: new Set<string>() }
467  const installed = await readJson($, join(home, '.claude', 'plugins', 'installed_plugins.json'))
468  const marketplaces = new Map<string, any[]>()
469  const seen = new Set<string>()
470  for (const key of keys) {
471    const [base = key, mp = ''] = key.split('@')
472    if (seen.has(base)) continue
473    const recs: any[] = Array.isArray(installed?.plugins?.[key]) ? installed.plugins[key] : []
474    const root: string | undefined = recs[recs.length - 1]?.installPath
475    if (!root) continue
476    seen.add(base)
477    if (mp && !marketplaces.has(mp)) {
478      const j = await readJson($, join(home, '.claude', 'plugins', 'marketplaces', mp, '.claude-plugin', 'marketplace.json'))
479      marketplaces.set(mp, Array.isArray(j?.plugins) ? j.plugins : [])
480    }
481    const entry = (marketplaces.get(mp) ?? []).find((p) => p?.name === base) ?? {}
482    const eff = { ...entry, ...((await readJson($, join(root, '.claude-plugin', 'plugin.json'))) ?? {}) }
483    out.agents += await countMdRecursive($, join(root, 'agents'))
484    out.commands += await countMdRecursive($, join(root, 'commands'))
485    out.skills += await countFiles($, join(root, 'skills'))
486    out.hooks += countHookMap((await readJson($, join(root, 'hooks', 'hooks.json')))?.hooks) + countHookMap(eff.hooks)
487    // A plugin .mcp.json declares servers at the TOP LEVEL, not under `mcpServers` like a
488    // project's; accept either. Servers toggled off in /mcp read "plugin:<base>:<server>".
489    const mcpFile = (await readJson($, join(root, '.mcp.json'))) ?? {}
490    const servers = { ...(mcpFile.mcpServers && typeof mcpFile.mcpServers === 'object' ? mcpFile.mcpServers : mcpFile), ...(eff.mcpServers ?? {}) }
491    for (const srv of Object.keys(servers)) if (!mcpOff.has(`plugin:${base}:${srv}`)) out.mcps++
492    // .lsp.json is a top-level language → config map; inline form is eff.lspServers.
493    const lsp = (await readJson($, join(root, '.lsp.json'))) ?? {}
494    const lspInline = eff.lspServers && typeof eff.lspServers === 'object' && !Array.isArray(eff.lspServers) ? eff.lspServers : {}
495    out.lsp += new Set([...Object.keys(lsp), ...Object.keys(lspInline)]).size
496    // Monitors: monitors/monitors.json is an array; an inline array replaces it (a path string is
497    // the path form, left uncounted).
498    const mon = eff.experimental?.monitors ?? eff.monitors
499    if (Array.isArray(mon)) out.monitors += mon.length
500    else if (typeof mon !== 'string') {
501      const file = await readText($, join(root, 'monitors', 'monitors.json'))
502      try {
503        const arr = file ? JSON.parse(file) : null
504        if (Array.isArray(arr)) out.monitors += arr.length
505      } catch {}
506    }
507    const themes = eff.experimental?.themes ?? eff.themes
508    if (Array.isArray(themes)) out.themes += themes.length
509    else if (typeof themes !== 'string') out.themes += await countFiles($, join(root, 'themes'), /\.json$/i)
510    out.workflows += await countFiles($, join(root, 'workflows'))
511    out.bin += await countFiles($, join(root, 'bin'))
512    if (Array.isArray(eff.channels)) for (const ch of eff.channels) {
513      const name = typeof ch === 'string' ? ch : ch?.name || ch?.id
514      if (name) out.channelNames.add(String(name))
515    }
516  }
517  return out
518}
519
520export async function collectCounts($: $, home: string, kind: Host['os']['kind'], root: string, b: Breakdown | null, sessionMcp: string[]): Promise<Counts> {
521  // Launched in the home directory itself, the "project" .claude/ IS the user scope: treat it as
522  // no project, or user config would count twice.
523  const proj = norm(root) === norm(home) ? '' : root
524  // ~/.claude.json can outgrow the 4 MiB read limit on a long-lived install; then trust and the
525  // per-project MCP state are unknown and the project is taken as trusted (what Claude Code would
526  // have asked about already).
527  const cj = await readJson($, join(home, '.claude.json'))
528  const projects: Record<string, any> = cj?.projects ?? {}
529  const entry = Object.entries(projects).find(([k]) => norm(k) === norm(root))?.[1]
530  const trusted = !proj || !cj || entry?.hasTrustDialogAccepted === true
531  const arr = (v: unknown) => (Array.isArray(v) ? (v as string[]) : [])
532  const mcpOff = new Set([...arr(entry?.disabledMcpServers), ...Object.keys((await readJson($, join(home, '.claude', 'mcp-needs-auth-cache.json'))) ?? {})])
533
534  const [policy, user, project, local] = await Promise.all([settingsOf($, 'policy'), settingsOf($, 'user'), settingsOf($, 'project'), settingsOf($, 'local')])
535
536  // Plugins enabled per scope, counted once at the broadest scope enabling them; project and local
537  // enablement only takes effect in a trusted workspace.
538  const pm = new Set(enabledNames(policy.enabledPlugins).map((k) => k.split('@')[0]))
539  const pu = new Set(enabledNames(user.enabledPlugins).map((k) => k.split('@')[0]).filter((n) => !pm.has(n)))
540  const pp = new Set(trusted ? enabledNames(project.enabledPlugins).map((k) => k.split('@')[0]).filter((n) => !pm.has(n) && !pu.has(n)) : [])
541  const pl = new Set(trusted ? enabledNames(local.enabledPlugins).map((k) => k.split('@')[0]).filter((n) => !pm.has(n) && !pu.has(n) && !pp.has(n)) : [])
542  // The effective enabled keys: user < project < local, later layers overriding.
543  const merged: Record<string, unknown> = { ...(user.enabledPlugins ?? {}), ...(trusted ? project.enabledPlugins ?? {} : {}), ...(trusted ? local.enabledPlugins ?? {} : {}), ...(policy.enabledPlugins ?? {}) }
544  const plug = await pluginComponents($, home, enabledNames(merged), mcpOff)
545
546  // MCP servers, de-duped by precedence local > project > user: u = ~/.claude.json top level,
547  // p = the repo's .mcp.json once approved, l = ~/.claude.json projects[root], m = managed.
548  const on = (k: string) => !mcpOff.has(k)
549  const mu = new Set(Object.keys(cj?.mcpServers ?? {}).filter(on))
550  const ml = new Set(Object.keys(entry?.mcpServers ?? {}).filter(on))
551  const mp = new Set<string>()
552  if (proj && trusted) {
553    const pj = (await readJson($, join(proj, '.mcp.json')))?.mcpServers ?? {}
554    const approved = new Set(arr(entry?.enabledMcpjsonServers))
555    const rejected = new Set(arr(entry?.disabledMcpjsonServers))
556    for (const k of Object.keys(pj)) if ((entry?.enableAllProjectMcpServers === true || approved.has(k)) && !rejected.has(k) && on(k)) mp.add(k)
557  }
558  for (const k of ml) (mp.delete(k), mu.delete(k))
559  for (const k of mp) mu.delete(k)
560  const managedMcp = { ...((await readJson($, join(managedDir(kind), 'managed-mcp.json')))?.mcpServers ?? {}), ...(policy.mcpServers ?? {}) }
561
562  const dirCount = async (sub: string, recursive: boolean, pattern?: RegExp): Promise<Scopes> => {
563    const count = (d: string) => (recursive ? countMdRecursive($, d) : countFiles($, d, pattern))
564    return { u: await count(join(home, '.claude', sub)), p: proj ? await count(join(proj, '.claude', sub)) : 0 }
565  }
566  const gate = (s: Scopes): Scopes => (trusted ? s : { ...s, p: 0, l: 0 })
567
568  // Auto-memory: the memory files beside the loaded index (MEMORY.md is the index, not a memory).
569  const memDir = b?.memoryDir
570  const memFiles = memDir ? (await list($, memDir)).filter((e) => /\.md$/i.test(e.name) && e.name !== 'MEMORY.md').length : 0
571
572  // Message channels installed at user scope live in ~/.claude/channels/<name>/ (a non-empty dir
573  // is a configured one); a plugin-declared channel already installed counts once, as user.
574  const chanDir = join(home, '.claude', 'channels')
575  const userChans = new Set<string>()
576  for (const e of await list($, chanDir)) if (e.kind === 'dir' && (await list($, join(chanDir, e.name))).length) userChans.add(e.name)
577
578  const addDirs = (await $.settings.read({ key: 'permissions' }).catch(() => undefined)) as { additionalDirectories?: unknown } | undefined
579
580  return {
581    trusted,
582    instructions: b?.instructions ?? {},
583    memory: { u: memFiles },
584    agents: b?.agents ?? {},
585    commands: { ...gate(await dirCount('commands', false, /\.md$/i)), x: plug.commands },
586    skills: b?.skills ?? {},
587    workflows: { ...gate(await dirCount('workflows', false)), x: plug.workflows },
588    routines: gate(await dirCount('routines', false)),
589    rules: await dirCount('rules', true),
590    mcp: gate({ m: Object.keys(managedMcp).length, u: mu.size, p: mp.size, l: ml.size, x: plug.mcps }),
591    // Account-level claude.ai connectors: the connected servers the engine lists under that name.
592    connectors: { u: sessionMcp.filter((s) => /^claude\.ai /i.test(s)).length },
593    // claude-in-chrome counts as enabled only with the extension installed, the toggle on and
594    // onboarding done — all readable locally. Live "connected" is session-only.
595    chrome: cj?.cachedChromeExtensionInstalled === true && cj?.claudeInChromeDefaultEnabled === true && cj?.hasCompletedClaudeInChromeOnboarding === true,
596    hooks: gate({ m: countHookMap(policy.hooks), u: countHookMap(user.hooks), p: countHookMap(project.hooks), l: countHookMap(local.hooks), x: plug.hooks }),
597    plugins: { m: pm.size, u: pu.size, p: pp.size, l: pl.size },
598    themes: { u: await countFiles($, join(home, '.claude', 'themes'), /\.json$/i), x: plug.themes },
599    dirs: { l: arr(addDirs?.additionalDirectories).length },
600    lsp: { x: plug.lsp },
601    monitors: { x: plug.monitors },
602    bin: { x: plug.bin },
603    channels: { u: userChans.size, x: [...plug.channelNames].filter((n) => !userChans.has(n)).length },
604  }
605}
606
607// ── legacy status line ───────────────────────────────────────────────────────────────────────────
608// 1.x wired `statusLine` / `subagentStatusLine` in ~/.claude/settings.json to Bun scripts (staged
609// copies in the plugin data dir, or a checkout). 2.x draws in the band instead, so those blocks
610// would show a second dashboard. Only blocks whose command names this plugin's scripts are ours.
611
612const LEGACY_RE = /statusline-dashboard[\\/].*(?:subagent-)?statusline\.js|statusline-dashboard-[^\\/]*[\\/](?:subagent-)?statusline\.js/i
613
614export function legacyKeys(settings: Record<string, any> | null): string[] {
615  if (!settings) return []
616  return ['statusLine', 'subagentStatusLine'].filter((k) => LEGACY_RE.test(String(settings[k]?.command ?? '')))
617}
618
619/** Removes the legacy blocks from user settings, keeping a .bak of the file first. */
620export async function removeLegacy($: $, home: string): Promise<string[]> {
621  const path = join(home, '.claude', 'settings.json')
622  const text = await readText($, path)
623  if (text === null) return []
624  let settings: Record<string, any>
625  try {
626    settings = JSON.parse(text)
627  } catch {
628    return []
629  }
630  const keys = legacyKeys(settings)
631  if (!keys.length) return []
632  await $.fs.write(`${path}.bak`, text)
633  for (const k of keys) delete settings[k]
634  await $.fs.write(path, JSON.stringify(settings, null, 2) + '\n')
635  return keys
636}
637
hooks/layout.ts 501 lines
1// Pure layout: no `$`, no engine. Rows are built as lists of styled RUNS (a string plus how to
2// draw it), packed into lines that fit the band's width, then planned against the band's height
3// budget. register.tsx turns the planned lines into Text elements; the tests check this file's
4// arithmetic directly.
5
6import type { Segment } from '../types'
7
8/** One styled stretch of text. `c`/`bg` are a theme key (`success`, `warning`, …) or a `#rrggbb`. */
9export type Run = {
10  t: string
11  c?: string
12  bg?: string
13  b?: boolean
14  i?: boolean
15  /** Drawn dim (the theme's dim colour): labels, punctuation, annotations. */
16  d?: boolean
17  /** A link target (the PR badge); drawn as a Link where the surface has one. */
18  href?: string
19}
20
21/** A row item: its widest form and its narrowest (bars and parenthesized detail go in `wide` only). */
22export type Item = { wide: Run[]; narrow: Run[] }
23
24export type Section = {
25  id: RowId
26  label: string
27  /** Rendered once before the first item; continuation lines indent past it. */
28  lead?: Run[]
29  items: Item[]
30  /** Keep the wide forms when wrapping instead of first trying all-narrow on one line (Config). */
31  preferDetail?: boolean
32  /** Extra full-width lines shown only while the row is open (the Model row's context breakdown). */
33  detail?: Run[][]
34}
35
36export const ROW_IDS = ['model', 'limits', 'usage', 'turn', 'activity', 'repo', 'config', 'exts', 'host', 'info'] as const
37export type RowId = (typeof ROW_IDS)[number]
38
39/** Labels pad to this, as the status line's did; the toggle glyph and a space sit before it. */
40export const SECTION_WIDTH = 8
41export const SEP: Run = { t: ' | ', d: true }
42
43// ── colours ──────────────────────────────────────────────────────────────────────────────────────
44// Semantic colours are THEME keys, so they follow the person's Claude Code theme (and read on the
45// desktop surface too); decorative per-widget hues keep the status line's 256-colour picks,
46// converted to hex because a tree takes no ANSI.
47
48export const GOOD = 'success'
49export const WARN = 'warning'
50export const BAD = 'error'
51
52/** xterm's 256-colour palette entry as #rrggbb (the 16 system colours map to xterm's defaults). */
53export function c256(n: number): string {
54  const sys = ['000000', '800000', '008000', '808000', '000080', '800080', '008080', 'c0c0c0', '808080', 'ff0000', '00ff00', 'ffff00', '0000ff', 'ff00ff', '00ffff', 'ffffff']
55  if (n < 16) return `#${sys[n]}`
56  if (n >= 232) {
57    const v = (8 + (n - 232) * 10).toString(16).padStart(2, '0')
58    return `#${v}${v}${v}`
59  }
60  const i = n - 16
61  const step = (k: number) => (k === 0 ? 0 : 55 + k * 40).toString(16).padStart(2, '0')
62  return `#${step(Math.floor(i / 36))}${step(Math.floor(i / 6) % 6)}${step(i % 6)}`
63}
64
65/** Fill colour for quota use: neutral when low → warning ≥70 → error ≥90. */
66export function usageColor(p: number): string | undefined {
67  return p >= 90 ? BAD : p >= 70 ? WARN : undefined
68}
69
70// Context fills differently from rate-limit usage: chat quality starts degrading around 50%, and
71// with auto-compact OFF nearing the window limit is a hard wall — so warn EARLIER than usageColor.
72export function contextColor(p: number): string | undefined {
73  return p >= 80 ? BAD : p >= 50 ? WARN : undefined
74}
75
76// ── width ────────────────────────────────────────────────────────────────────────────────────────
77
78// Nerd Font glyphs live in the Private Use Areas. Terminals draw them one OR two cells depending
79// on the font, and the layout engine counts them as one: count them as TWO here so a row is
80// packed for the worst case and a wide-rendering font can never push it past the edge (a
81// hard-wrapped row desyncs the repaint).
82const isWide = (cp: number) =>
83  (cp >= 0xe000 && cp <= 0xf8ff) || (cp >= 0xf0000 && cp <= 0xffffd) || (cp >= 0x100000 && cp <= 0x10fffd) || (cp >= 0x1f300 && cp <= 0x1faff)
84
85export function cells(s: string): number {
86  let w = 0
87  for (const ch of s) {
88    const cp = ch.codePointAt(0) ?? 0
89    if (cp === 0xfe0e || cp === 0xfe0f || cp === 0x200d) continue
90    w += isWide(cp) ? 2 : 1
91  }
92  return w
93}
94
95export const runsWidth = (runs: readonly Run[]) => runs.reduce((n, r) => n + cells(r.t), 0)
96
97/** Cut runs to `max` cells, ending in `…`. */
98export function truncateRuns(runs: readonly Run[], max: number): Run[] {
99  if (runsWidth(runs) <= max) return [...runs]
100  if (max < 1) return []
101  const out: Run[] = []
102  let left = max - 1
103  for (const r of runs) {
104    if (left <= 0) break
105    let t = ''
106    for (const ch of r.t) {
107      const w = cells(ch)
108      if (w > left) {
109        left = 0
110        break
111      }
112      t += ch
113      left -= w
114    }
115    if (t) out.push({ ...r, t })
116  }
117  out.push({ t: '…', d: true })
118  return out
119}
120
121// ── packing ──────────────────────────────────────────────────────────────────────────────────────
122
123/** A packed line: its runs, and whether it is the section's first (where the toggle sits). */
124export type Line = { runs: Run[]; isHead: boolean }
125
126/**
127 * Packs a section's items into lines of at most `width` cells after `indent` cells of toggle and
128 * label (drawn by the caller), the status line's packSection carried over:
129 *   all-widest on one line → all-narrowest on one line (skipped for preferDetail) → greedy
130 *   per-item wrap, cut with `…` once `maxRows` lines are used.
131 * A finite `maxRows` is SQUEEZE mode: items go narrow-first and the all-narrow probe runs even
132 * for preferDetail sections, because under a row budget a cut item is lost outright while a
133 * dropped bar or scope paren is only detail. The first item on an empty line always tries its
134 * narrow form, then truncates in place — a line never holds just its label and `…`.
135 */
136export function packSection(section: Section, width: number, indent: number, maxRows = Infinity): Line[] {
137  const items = section.items
138  if (!items.length || maxRows < 1) return []
139  const lead = section.lead ?? []
140  const leadW = runsWidth(lead)
141  const room = Math.max(1, width - indent) // cells for content on every line
142  const contentRoom = Math.max(1, room - leadW) // past the lead, where continuation lines start
143  const squeeze = Number.isFinite(maxRows)
144
145  const join = (forms: Run[][]) => forms.flatMap((f, i) => (i ? [SEP, ...f] : f))
146  const fits = (forms: Run[][]) => runsWidth(join(forms)) <= contentRoom
147  const wide = items.map((it) => it.wide)
148  if (fits(wide)) return [{ runs: [...lead, ...join(wide)], isHead: true }]
149  const narrowAll = items.map((it) => it.narrow)
150  if ((!section.preferDetail || squeeze) && fits(narrowAll)) return [{ runs: [...lead, ...join(narrowAll)], isHead: true }]
151
152  const lines: Run[][] = []
153  let cur: Run[] = []
154  let used = 0
155  let placed = 0
156  for (const it of items) {
157    const first = squeeze ? it.narrow : it.wide
158    const sepW = placed > 0 ? cells(SEP.t) : 0
159    if (used + sepW + runsWidth(first) <= contentRoom) {
160      if (placed) cur.push(SEP)
161      cur.push(...first)
162      used += sepW + runsWidth(first)
163    } else if (used + sepW + runsWidth(it.narrow) <= contentRoom) {
164      if (placed) cur.push(SEP)
165      cur.push(...it.narrow)
166      used += sepW + runsWidth(it.narrow)
167    } else if (placed === 0) {
168      const form = truncateRuns(it.narrow, contentRoom - used)
169      cur.push(...form)
170      used += runsWidth(form)
171    } else {
172      // Out of rows: cut instead of wrapping, and say so.
173      if (lines.length + 2 > maxRows) {
174        if (used + 2 <= contentRoom) cur.push({ t: ' …', d: true })
175        break
176      }
177      if (used + 2 <= contentRoom) cur.push({ t: ' |', d: true })
178      lines.push(cur)
179      let form = runsWidth(first) <= contentRoom ? first : runsWidth(it.narrow) <= contentRoom ? it.narrow : truncateRuns(it.narrow, contentRoom)
180      cur = [...form]
181      used = runsWidth(form)
182      form = []
183    }
184    placed++
185  }
186  lines.push(cur)
187  return lines.map((runs, i) => ({ runs: i === 0 ? [...lead, ...runs] : [{ t: ' '.repeat(leadW) }, ...runs], isHead: i === 0 }))
188}
189
190// ── planning ─────────────────────────────────────────────────────────────────────────────────────
191
192/** Which section vanishes first when even one line each won't fit: essentials last. */
193export const DROP_ORDER: RowId[] = ['exts', 'config', 'host', 'activity', 'turn', 'repo', 'usage', 'info', 'limits', 'model']
194
195export type PlannedRow = {
196  id: RowId
197  label: string
198  /** Open as the person asked, or folded because the band had no room (shown dim). */
199  state: 'open' | 'closed' | 'folded'
200  /** Opening it shows more than its closed line: only then does it get a ▸/▾ toggle. */
201  canToggle: boolean
202  lines: Line[]
203}
204
205export type Plan = {
206  rows: PlannedRow[]
207  /** Sections with data that got no line of their own: drawn as one strip of toggles. */
208  overflow: Array<{ id: RowId; label: string }>
209}
210
211/**
212 * Lays the sections out against the band's height budget.
213 *
214 * Every section with data gets one line, its items packed narrow-first and cut with `…` (closed);
215 * an open section gets as many lines as its items need. When even one line per section won't fit,
216 * sections leave in DROP_ORDER into a single overflow strip of toggles, so nothing is unreachable
217 * and nothing scrolls. Over budget with rows open, the OLDEST open row folds back to one line
218 * first (the newest open wins — it is the one just asked for), then other rows move to the strip
219 * to make room for it, and only then is it cut short.
220 */
221export function plan(sections: Section[], expanded: readonly string[], budget: number, width: number, indent: number): Plan {
222  const live = sections.filter((s) => s.items.length > 0)
223  const closed = (s: Section) => packSection(s, width, indent, 1)
224  // An open row: its items wrapped, then its detail lines, all within `max` (detail goes first).
225  const open = (s: Section, max = Infinity): Line[] => {
226    const lines = packSection(s, width, indent, max)
227    const room = Math.max(0, max - lines.length)
228    return [...lines, ...(s.detail ?? []).slice(0, room).map((runs) => ({ runs, isHead: false }))]
229  }
230  budget = Math.max(1, Math.floor(budget))
231
232  // Too many sections for one line each: keep the essentials, the rest go to the strip.
233  let shown = live
234  let overflow: Section[] = []
235  if (live.length > budget) {
236    const keep = budget - 1
237    const byDrop = [...live].sort((a, b) => DROP_ORDER.indexOf(b.id) - DROP_ORDER.indexOf(a.id)) // essentials first
238    const kept = new Set(byDrop.slice(0, keep).map((s) => s.id))
239    // An open section always keeps its seat: it is what the person last asked to see.
240    const want = expanded.filter((id) => live.some((s) => s.id === id))
241    const last = want[want.length - 1]
242    if (last && !kept.has(last as RowId) && keep > 0) {
243      const victim = byDrop.slice(0, keep).reverse().find((s) => s.id !== last)
244      if (victim) kept.delete(victim.id)
245      kept.add(last as RowId)
246    }
247    shown = live.filter((s) => kept.has(s.id))
248    overflow = live.filter((s) => !kept.has(s.id))
249  }
250  const openIds = expanded.filter((id) => shown.some((s) => s.id === id))
251  const isOpen = new Set(openIds)
252  const newest = openIds[openIds.length - 1]
253  const folded = new Set<string>()
254  const height = () =>
255    shown.reduce((n, s) => n + (isOpen.has(s.id) && !folded.has(s.id) ? open(s).length : closed(s).length), 0) + (overflow.length ? 1 : 0)
256  // Fold the oldest open sections until the rest fit, keeping the newest open.
257  for (const id of openIds.slice(0, -1)) {
258    if (height() <= budget) break
259    folded.add(id)
260  }
261  // Still over: the newest open row is what the person just asked to see, so the least essential
262  // of the other rows give up their seats to the strip before it is cut short.
263  while (height() > budget && newest !== undefined) {
264    const victim = [...shown].filter((s) => s.id !== newest).sort((a, b) => DROP_ORDER.indexOf(a.id) - DROP_ORDER.indexOf(b.id))[0]
265    if (!victim) break
266    shown = shown.filter((s) => s !== victim)
267    overflow = [...overflow, victim].sort((a, b) => live.indexOf(a) - live.indexOf(b))
268  }
269  // A row whose full form is its one closed line has nothing to open or close.
270  const canToggle = (s: Section) => open(s).length > 1 || runsWidth(open(s)[0]?.runs ?? []) !== runsWidth(closed(s)[0]?.runs ?? [])
271  const rows: PlannedRow[] = shown.map((s) => {
272    const base = { id: s.id, label: s.label, canToggle: canToggle(s) }
273    if (!isOpen.has(s.id)) return { ...base, state: 'closed', lines: closed(s) }
274    if (folded.has(s.id)) return { ...base, state: 'folded', lines: closed(s) }
275    return { ...base, state: 'open', lines: open(s) }
276  })
277  // Last resort: cut the open row to the rows that are left.
278  const total = rows.reduce((n, r) => n + r.lines.length, 0) + (overflow.length ? 1 : 0)
279  const r = rows.find((x) => x.id === newest && x.state === 'open')
280  const s = shown.find((x) => x.id === newest)
281  if (total > budget && r && s) r.lines = open(s, Math.max(1, budget - (total - r.lines.length)))
282  return { rows, overflow: overflow.map((s) => ({ id: s.id, label: s.label })) }
283}
284
285// ── formatting ───────────────────────────────────────────────────────────────────────────────────
286
287export function formatTokens(tokens: number): string {
288  const n = Number(tokens) || 0
289  if (n >= 1_000_000) {
290    const v = n / 1_000_000
291    return v >= 10 ? `${Math.round(v)}M` : `${v.toFixed(1)}M`
292  }
293  if (n >= 1_000) {
294    const v = n / 1_000
295    return v >= 10 ? `${Math.round(v)}K` : `${v.toFixed(1)}K`
296  }
297  return String(Math.round(n))
298}
299
300export const formatCost = (usd: number) => `$${(Number(usd) || 0).toFixed(2)}`
301
302export function formatDuration(ms: number): string {
303  if (!Number.isFinite(ms) || ms <= 0) return '0m'
304  const totalMinutes = Math.floor(ms / 60000)
305  const hours = Math.floor(totalMinutes / 60)
306  const minutes = totalMinutes % 60
307  if (hours > 0 && minutes > 0) return `${hours}h${minutes}m`
308  if (hours > 0) return `${hours}h`
309  return `${minutes}m`
310}
311
312export function formatCountdown(ms: number): string {
313  if (!Number.isFinite(ms) || ms <= 0) return '0m'
314  const totalMinutes = Math.floor(ms / 60000)
315  const days = Math.floor(totalMinutes / 1440)
316  const hours = Math.floor((totalMinutes % 1440) / 60)
317  const minutes = totalMinutes % 60
318  if (days > 0) return `${days}d${hours}h`
319  if (hours > 0) return `${hours}h${minutes}m`
320  return `${minutes}m`
321}
322
323const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
324const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
325
326/** Local wall-clock time, 24h `HH:mm` or 12h `h:mmam`. */
327export function formatClock(ms: number, twelve: boolean): string {
328  const d = new Date(ms)
329  const h = d.getHours()
330  const mm = String(d.getMinutes()).padStart(2, '0')
331  return twelve ? `${h % 12 || 12}:${mm}${h < 12 ? 'am' : 'pm'}` : `${String(h).padStart(2, '0')}:${mm}`
332}
333
334/**
335 * A reset time: `auto` is the time alone, with the weekday when it falls on another calendar day
336 * (a 5h window rolling past midnight); `week` always has the weekday; `date` is for periods longer
337 * than a week, where a bare weekday is ambiguous (`Sep 30` while it's ≥6 days out).
338 */
339export function formatReset(iso: string | undefined, fmt: 'auto' | 'week' | 'date', now: number, twelve: boolean): string {
340  if (!iso) return ''
341  const t = new Date(iso).getTime()
342  if (!Number.isFinite(t)) return ''
343  const d = new Date(t)
344  const hm = formatClock(t, twelve)
345  const wd = WEEKDAYS[d.getDay()]
346  if (fmt === 'week') return `${wd} ${hm}`
347  if (fmt === 'date') return t - now < 6 * 864e5 ? `${wd} ${hm}` : `${MONTHS[d.getMonth()]} ${d.getDate()}`
348  const n = new Date(now)
349  const sameDay = d.getFullYear() === n.getFullYear() && d.getMonth() === n.getMonth() && d.getDate() === n.getDate()
350  return sameDay ? hm : `${wd} ${hm}`
351}
352
353/**
354 * Signed pace balance vs the even-consumption budget line ((elapsed/window)·100, the window
355 * starting `windowMs` before the reset): + = quota in hand, − = burning ahead of it. Null for a
356 * degenerate just-reset window or no window length.
357 */
358export function pace(percentUsed: number, resetsAt: string | undefined, windowMs: number, now: number): number | null {
359  if (!resetsAt || !windowMs) return null
360  const reset = new Date(resetsAt).getTime()
361  if (!Number.isFinite(reset)) return null
362  const elapsed = now - (reset - windowMs)
363  if (!(elapsed > 0)) return null
364  const expected = Math.min(100, (elapsed / windowMs) * 100)
365  return Math.round(expected - percentUsed)
366}
367
368/**
369 * A 10-cell gauge: the used part solid, the rest in the light shade the context bar uses for free
370 * space. Whole cells only — an eighth-block partial (▏▎▍…) leaves most of its cell blank, which
371 * reads as a gap between the fill and the track — and at least one cell for any use above 0%. No
372 * background colour: a painted track drew as a pale box on any terminal background but near-black.
373 */
374export function bar(pct: number, color: string | undefined, width = 10): Run[] {
375  const p = Math.max(0, Math.min(100, pct))
376  const full = p > 0 ? Math.max(1, Math.round((p / 100) * width)) : 0
377  const runs: Run[] = []
378  if (full) runs.push({ t: '█'.repeat(full), c: color ?? c256(250) })
379  if (width - full > 0) runs.push({ t: '░'.repeat(width - full), d: true })
380  return runs
381}
382
383export function truncate(value: string, max: number): string {
384  const s = String(value || '').replace(/\s+/g, ' ').trim()
385  return s.length <= max ? s : `${s.slice(0, Math.max(0, max - 1))}…`
386}
387
388/** `~/…` for paths under home, forward slashes. */
389export function homeRelative(dir: string, home: string): string {
390  if (!dir) return ''
391  const d = dir.replace(/\\/g, '/').replace(/\/+$/, '')
392  const h = home.replace(/\\/g, '/').replace(/\/+$/, '')
393  if (!h) return d
394  if (d.toLowerCase() === h.toLowerCase()) return '~'
395  if (d.toLowerCase().startsWith(h.toLowerCase() + '/')) return '~/' + d.slice(h.length + 1)
396  return d
397}
398
399// Middle-ellipsize a path so the LEAF (where you actually are) survives: keep the head (~ or the
400// drive) and as many trailing segments as fit. The packer cuts from the right, which would eat it.
401export function shortenPath(p: string, max = 40): string {
402  if (!p || p.length <= max) return p
403  const segs = p.split('/')
404  if (segs.length <= 2) {
405    const keep = Math.max(1, max - 1)
406    return p.slice(0, Math.ceil(keep / 2)) + '…' + p.slice(p.length - Math.floor(keep / 2))
407  }
408  const first = segs[0]
409  let tail = segs.slice(1)
410  while (tail.length > 1 && `${first}/…/${tail.join('/')}`.length > max) tail = tail.slice(1)
411  return `${first}/…/${tail.join('/')}`
412}
413
414// ── the context breakdown (was the context-bar plugin) ───────────────────────────────────────────
415// The status line only ever got the window's total; the per-category breakdown is a mods-API call,
416// so the Model row's open view adds it: one stacked bar, one colour per category as /context
417// draws them, then a legend.
418
419
420/** The glyph each kind of segment is drawn with: used solid, the compaction buffer hatched, free light. */
421export const glyphFor = (kind: Segment['kind']): string => (kind === 'free' ? '░' : kind === 'buffer' ? '▒' : '█')
422
423/**
424 * Splits `width` cells across the categories in proportion to their tokens (largest-remainder
425 * rounding, so the cells always sum to exactly `width` and no non-empty category rounds to zero
426 * while a cell can be found for it). Deferred rows are left out, as /context's grid leaves them.
427 */
428export function stack(categories: readonly Segment[], width: number): Array<Segment & { cells: number }> {
429  const rows = categories.filter((c) => c.kind !== 'deferred' && c.tokens > 0)
430  const total = rows.reduce((n, c) => n + c.tokens, 0)
431  if (width <= 0 || total <= 0 || rows.length === 0) return []
432  const exact = rows.map((c) => (c.tokens / total) * width)
433  const cellsOf = exact.map((x) => Math.floor(x))
434  let left = width - cellsOf.reduce((n, c) => n + c, 0)
435  // Hand the leftover cells to the largest fractional parts first.
436  const order = exact.map((x, i) => [x - (cellsOf[i] ?? 0), i] as const).sort((a, b) => b[0] - a[0])
437  for (const [, i] of order) {
438    if (left <= 0) break
439    cellsOf[i] = (cellsOf[i] ?? 0) + 1
440    left -= 1
441  }
442  // A non-empty row that still rounded to nothing borrows one cell from the widest row, so every
443  // category present is at least visible.
444  for (let i = 0; i < cellsOf.length; i++) {
445    if ((cellsOf[i] ?? 0) > 0) continue
446    const widest = cellsOf.indexOf(Math.max(...cellsOf))
447    if ((cellsOf[widest] ?? 0) > 1) {
448      cellsOf[widest] = (cellsOf[widest] ?? 0) - 1
449      cellsOf[i] = 1
450    }
451  }
452  return rows.map((c, i) => ({ ...c, cells: cellsOf[i] ?? 0 }))
453}
454
455/** The stacked bar as runs, exactly `width` cells. */
456export function stackRuns(categories: readonly Segment[], width: number): Run[] {
457  return stack(categories, width).map((seg) => ({ t: glyphFor(seg.kind).repeat(seg.cells), c: seg.color }))
458}
459
460/** Legend lines (`name NN%` in each category's colour), wrapped at `width`, at most `maxLines`. */
461export function legendLines(categories: readonly Segment[], maxTokens: number, width: number, maxLines = 2): Run[][] {
462  const entries = categories
463    .filter((c) => c.kind !== 'deferred' && c.tokens > 0)
464    .map((c) => ({ t: `${c.name} ${Math.max(1, Math.round((c.tokens / Math.max(1, maxTokens)) * 100))}%`, c: c.color }))
465    .filter((e) => cells(e.t) <= width)
466  const lines: Run[][] = []
467  let cur: Run[] = []
468  let used = 0
469  for (const e of entries) {
470    const gap = cur.length ? 2 : 0
471    if (used + gap + cells(e.t) > width) {
472      lines.push(cur)
473      if (lines.length >= maxLines) return lines
474      cur = []
475      used = 0
476    }
477    if (cur.length) cur.push({ t: '  ' })
478    cur.push(e)
479    used += (cur.length > 1 ? 2 : 0) + cells(e.t)
480  }
481  if (cur.length) lines.push(cur)
482  return lines.slice(0, maxLines)
483}
484
485/**
486 * The overflow strip's labels, fitted to `width` after its `+ ` lead: whole labels when they fit,
487 * else each cut to its first three letters (`Usa Tur Act`), else as many of those as fit — the
488 * strip is one line, like every other, and must never hard-wrap.
489 */
490export function stripLabels(labels: readonly string[], width: number): string[] {
491  const fits = (ls: readonly string[]) => 2 + ls.reduce((n, l, i) => n + cells(l) + (i ? 1 : 0), 0) <= width
492  if (fits(labels)) return [...labels]
493  const short = labels.map((l) => l.replace(/\.$/, '').slice(0, 3))
494  const out: string[] = []
495  for (const l of short) {
496    if (!fits([...out, l])) break
497    out.push(l)
498  }
499  return out
500}
501
hooks/sections.ts 641 lines
1// Pure row builders: the collected state in, one Section per row out. No `$` here: register.tsx
2// reads the state and hands it over, so the tests can build rows from fixtures.
3//
4// Widget ids (`model.effort`, `repo.pr`, …) are the user config's `hide` ids; the README table
5// and the configure skill's id list must stay in sync with the `on(...)` calls below.
6
7import type { Activity, ApiUsage, Breakdown, Counts, Host, Info, Model, Repo, Scopes, Usage } from '../types'
8import {
9  BAD,
10  GOOD,
11  WARN,
12  ROW_IDS,
13  bar,
14  c256,
15  contextColor,
16  formatClock,
17  formatCost,
18  formatCountdown,
19  formatDuration,
20  formatReset,
21  formatTokens,
22  homeRelative,
23  pace,
24  shortenPath,
25  truncate,
26  truncateRuns,
27  usageColor,
28  type Item,
29  type RowId,
30  type Run,
31  type Section,
32} from './layout'
33
34// ── user config ──────────────────────────────────────────────────────────────────────────────────
35// ~/.claude/statusline-dashboard.json (CLAUDE_STATUSLINE_CONFIG overrides the path). It lives under
36// ~/.claude, not in the plugin, so updates never clobber it and an uninstall leaves it for a
37// reinstall. Every key is optional; anything missing, mistyped or unknown falls back to the
38// default, so a broken file degrades to the stock dashboard instead of a blank one.
39
40export type Config = {
41  rows: RowId[]
42  hide: Set<string>
43  labels: boolean
44  bars: boolean
45  pace: boolean
46  clock12: boolean
47  usageApi: boolean
48  /** `gh pr view` for the Repo row's PR badge. */
49  pr: boolean
50  maxRows: number
51  /** Rows open when the person has never toggled one. */
52  expanded: RowId[]
53}
54
55export function parseConfig(raw: unknown): Config {
56  const u = raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {}
57  const ids = (v: unknown) => (Array.isArray(v) ? v.map((s) => String(s).toLowerCase().replace(/\.$/, '')) : null)
58  const hide = new Set(ids(u.hide) ?? [])
59  const isRow = (r: string): r is RowId => (ROW_IDS as readonly string[]).includes(r)
60  const picked = ids(u.rows)?.filter((r, i, a) => isRow(r) && a.indexOf(r) === i) as RowId[] | undefined
61  const max = Number(u.maxRows)
62  return {
63    rows: (picked?.length ? picked : [...ROW_IDS]).filter((r) => !hide.has(r)),
64    hide,
65    labels: u.labels !== false,
66    bars: u.bars !== false,
67    pace: u.pace !== false,
68    clock12: String(u.clock ?? '').toLowerCase() === '12h',
69    usageApi: u.usageApi !== false,
70    pr: u.pr !== false,
71    maxRows: Number.isFinite(max) && max > 0 ? Math.floor(max) : Infinity,
72    expanded: (ids(u.expanded) ?? []).filter(isRow),
73  }
74}
75
76// ── glyphs ───────────────────────────────────────────────────────────────────────────────────────
77// Nerd Font codepoints (NF names in the comments). On the user's Windows font some Font Awesome
78// glyphs render as tofu, so Material Design (nf-md-*, 0xf0xxx) is the safer pick. The desktop
79// surface has no Nerd Font: there `glyphs` is false and every icon is dropped.
80
81const cp = (n: number) => String.fromCodePoint(n)
82export const G = {
83  model: cp(0xf06a9), // nf-md-robot
84  effort: cp(0xf0e7), // nf-fa-bolt
85  ctx: cp(0xe28c), // nf-fae-brain
86  cost: cp(0xf0d6), // nf-fa-money
87  clock: cp(0xf0150), // nf-md-clock
88  gauge: cp(0xf029a), // nf-md-gauge — Limits lead (a quota meter; the clock stays unique to the Host row)
89  duration: cp(0xf252), // nf-fa-hourglass_half
90  dir: cp(0xf07c), // nf-fa-folder_open
91  tree: cp(0xe5fb), // nf-custom-folder_git_branch
92  branch: cp(0xf126), // nf-fa-code_branch
93  pr: cp(0xf407), // nf-oct-git_pull_request
94  lines: cp(0xeb7e), // nf-cod-edit
95  key: cp(0xf084), // nf-fa-key
96  tools: cp(0xf013), // nf-fa-gear
97  todo: cp(0xf14a), // nf-fa-check_square
98  speed: cp(0xf0e7), // nf-fa-bolt
99  api: cp(0xf2f2), // nf-fa-stopwatch
100  style: cp(0xf1fc), // nf-fa-paint_brush
101  think: cp(0xf06e8), // nf-md-lightbulb_on — extended thinking (distinct from the ctx brain)
102  version: cp(0xf121), // nf-fa-code
103  win: cp(0xf05b3), // nf-md-microsoft_windows
104  apple: cp(0xf0035), // nf-md-apple
105  linux: cp(0xf17c), // nf-fa-linux — only ever renders on Linux, so the Windows FA-tofu gotcha doesn't apply
106  person: cp(0xf0004), // nf-md-account (the FA user glyph is tofu in the Windows font)
107  user: cp(0xf007), // nf-fa-user
108  fleet: cp(0xf037a), // nf-md-monitor_multiple — OTHER live Claude Code sessions on this machine
109  command: cp(0xf120), // nf-fa-terminal
110  team: cp(0xf0849), // nf-md-account_group
111  tag: cp(0xf02b), // nf-fa-tag
112  check: cp(0xf00c), // nf-fa-check
113  x: cp(0xf00d), // nf-fa-times
114  ahead: cp(0xf062), // nf-fa-arrow_up
115  behind: cp(0xf063), // nf-fa-arrow_down
116  in: cp(0xf090), // nf-fa-sign_in (input tokens)
117  out: cp(0xf08b), // nf-fa-sign_out (output tokens)
118  write: cp(0xf0ee), // nf-fa-cloud_upload (cache write)
119  read: cp(0xf0ed), // nf-fa-cloud_download (cache read)
120  promptCache: cp(0xf00e8), // nf-md-cached
121  cfgDoc: cp(0xf0219), // nf-md-file_document — instruction files
122  cfgAgents: cp(0xf167a), // nf-md-robot_outline
123  cfgCmds: cp(0xf018d), // nf-md-console
124  cfgSkills: cp(0xf0068), // nf-md-auto_fix
125  cfgRules: cp(0xf0565), // nf-md-shield_check
126  cfgMcp: cp(0xf048b), // nf-md-server
127  cfgHooks: cp(0xf06e2), // nf-md-hook
128  cfgPlugins: cp(0xf1288), // nf-md-toy_brick (LEGO)
129  cfgConn: cp(0xf015f), // nf-md-cloud — claude.ai connectors
130  cfgDirs: cp(0xf0257), // nf-md-folder_plus
131  cfgLsp: cp(0xf0761), // nf-md-code_braces
132  cfgMonitor: cp(0xf0437), // nf-md-radar
133  cfgTheme: cp(0xf03d8), // nf-md-palette
134  cfgBin: cp(0xf0614), // nf-md-application
135  cfgChannel: cp(0xf028c), // nf-md-forum
136  cfgChrome: cp(0xf268), // nf-fa-chrome (FA exception; if blank use nf-md-google_chrome 0xf02af)
137  cfgWorkflows: cp(0xf04aa), // nf-md-sitemap
138  cfgRoutines: cp(0xf00f0), // nf-md-calendar_clock
139  cfgMemory: cp(0xf09d1), // nf-md-brain — auto-memory files
140  github: cp(0xf02a4), // nf-md-github
141  gitlab: cp(0xf0ba0), // nf-md-gitlab
142  bitbucket: cp(0xf00a8), // nf-md-bitbucket
143  repoHost: cp(0xf0ccf), // nf-md-source_repository
144}
145export const OK = '✔︎' // ✔︎ + text-presentation selector (never emoji)
146export const NO = '✘︎' // ✘︎
147
148// Decorative hues (the status line's 256-colour picks; semantic colours are theme keys).
149const PINK = c256(211)
150const PENCIL = c256(220)
151const ORANGE = c256(216)
152const SILVER = c256(250)
153const CYAN = c256(44)
154const BBLUE = c256(75)
155const MAGENTA = c256(170)
156
157// ── inputs ───────────────────────────────────────────────────────────────────────────────────────
158
159export type Inputs = {
160  usage: Usage | null
161  breakdown: Breakdown | null
162  model: Model | null
163  repo: Repo | null
164  host: Host | null
165  info: Info | null
166  counts: Counts | null
167  api: ApiUsage | null
168  activity: Activity | null
169  now: number
170}
171
172/** One icon + space, or nothing where the surface has no Nerd Font. */
173type Icon = (glyph: string, color?: string) => Run[]
174
175const plain = (runs: Run[]): Item => ({ wide: runs, narrow: runs })
176const pair = (wide: Run[], narrow: Run[]): Item => ({ wide, narrow })
177const v = (t: string, c?: string): Run => ({ t, c })
178const d = (t: string): Run => ({ t, d: true })
179
180/**
181 * Builds every row. `room` is the cells an open row's detail lines get (past the toggle and
182 * label); each detail line is cut to it.
183 */
184export function buildSections(x: Inputs, cfg: Config, glyphs: boolean, room = 80): Section[] {
185  const on = (id: string) => !cfg.hide.has(id)
186  const icon: Icon = (glyph, color) => (glyphs ? [{ t: glyph, c: color }, { t: ' ' }] : [])
187  const build: Record<RowId, () => Section> = {
188    model: () => modelRow(x, cfg, on, icon),
189    limits: () => limitsRow(x, cfg, on, icon),
190    usage: () => usageRow(x, on, icon),
191    turn: () => turnRow(x, on, icon),
192    activity: () => activityRow(x, on, icon),
193    repo: () => repoRow(x, on, icon),
194    config: () => configRow(x, on, icon),
195    exts: () => extsRow(x, on, icon),
196    host: () => hostRow(x, cfg, on, icon),
197    info: () => infoRow(x, on, icon),
198  }
199  const details: Partial<Record<RowId, () => Run[][]>> = {
200    turn: () => turnDetail(x, on),
201    activity: () => activityDetail(x, on),
202    repo: () => repoDetail(x, on),
203  }
204  return cfg.rows.map((id) => {
205    const s = build[id]()
206    const extra = s.items.length ? details[id]?.() ?? [] : []
207    return extra.length ? { ...s, detail: extra.map((l) => truncateRuns(l, room)) } : s
208  })
209}
210
211// ── open-row detail: the history a closed row has no room for ──────────────────────────────────
212
213const ago = (ms: number) => (ms < 60e3 ? 'now' : `${formatDuration(ms)} ago`)
214
215// The last few turns, newest last: how long, what went out, how much came from the cache.
216function turnDetail(x: Inputs, on: (id: string) => boolean): Run[][] {
217  const turns = x.activity?.turns ?? []
218  if (!turns.length || !on('turn.history')) return []
219  return turns.map((t, i) => {
220    const hit = t.input > 0 ? Math.round((t.cacheRead / t.input) * 100) : 0
221    return [
222      d(i === turns.length - 1 ? 'last ' : `-${turns.length - 1 - i}   `),
223      v(`${Math.max(1, Math.round(t.ms / 1000))}s`),
224      d('  out '),
225      v(formatTokens(t.output)),
226      d('  in '),
227      v(formatTokens(t.input)),
228      d('  '),
229      v(`${hit}%`, hit >= 80 ? GOOD : hit < 50 ? WARN : undefined),
230      d(' cached'),
231    ]
232  })
233}
234
235const TODO_MARK: Record<string, [string, string | undefined]> = {
236  completed: [OK, GOOD],
237  in_progress: ['▸', WARN],
238  pending: ['○', undefined],
239}
240
241// The todo list, the last few tool calls (✔ answered, ✘ failed, … running), and the sub-agents.
242function activityDetail(x: Inputs, on: (id: string) => boolean): Run[][] {
243  const a = x.activity
244  if (!a) return []
245  const lines: Run[][] = []
246  const tasks = Object.values(a.tasks)
247  const todos = tasks.length ? tasks : a.todoItems ?? []
248  if (todos.length && on('activity.todo')) {
249    // Around the current item: the last done, the current, and what comes next.
250    const cur = Math.max(0, todos.findIndex((t) => t.status !== 'completed'))
251    const from = Math.max(0, Math.min(cur - 1, todos.length - 4))
252    for (const t of todos.slice(from, from + 4)) {
253      const [mark, col] = TODO_MARK[t.status] ?? ['○', undefined]
254      lines.push([v(`${mark} `, col), t.status === 'completed' ? d(t.subject) : v(t.subject)])
255    }
256  }
257  if (a.recentTools?.length && on('activity.tool')) {
258    // Repeats of the same call collapse into one with a count: `Read(a.ts) ✔︎ ×3`.
259    const runs: Array<{ label: string; ok?: boolean; n: number }> = []
260    for (const t of a.recentTools) {
261      const label = t.target ? `${t.name}(${truncate(t.target, 24)})` : t.name
262      const last = runs[runs.length - 1]
263      if (last && last.label === label && last.ok === t.ok) last.n++
264      else runs.push({ label, ok: t.ok, n: 1 })
265    }
266    const calls = runs.slice(-5).map((c): Run[] => [
267      v(c.label),
268      c.ok === undefined ? v(' …', WARN) : c.ok ? v(` ${OK}`, GOOD) : v(` ${NO}`, BAD),
269      ...(c.n > 1 ? [d(` ×${c.n}`)] : []),
270    ])
271    lines.push(calls.flatMap((c, i) => (i ? [d('  '), ...c] : c)))
272  }
273  // The session's sub-agents in the order they started, newest last: what each was asked to do
274  // and how it ended.
275  if (a.agents.list?.length && on('activity.agents')) {
276    for (const ag of a.agents.list) {
277      const col = ag.status === 'completed' ? GOOD : ag.status === 'failed' || ag.status === 'killed' ? BAD : CYAN
278      const mark = ag.status === 'completed' ? OK : ag.status === 'failed' || ag.status === 'killed' ? NO : '●'
279      lines.push([v(`${mark} `, col), v(ag.type), d(`  ${ag.description}`)])
280    }
281  }
282  return lines
283}
284
285// The last commit and the first changed paths, coloured like the branch counts.
286function repoDetail(x: Inputs, on: (id: string) => boolean): Run[][] {
287  const r = x.repo
288  if (!r || !on('repo.files')) return []
289  const lines: Run[][] = []
290  if (r.lastCommit) lines.push([v(r.lastCommit.sha, MAGENTA), v(` ${r.lastCommit.subject}`), d(`  ${ago(x.now - r.lastCommit.at)}`)])
291  for (const f of r.files ?? []) {
292    const [mark, col] = f.xy === '??' ? ['?', BBLUE] : f.xy.includes('U') ? ['!', BAD] : f.xy[0] !== '.' ? ['+', GOOD] : ['~', WARN]
293    lines.push([v(`${mark} `, col), v(f.path)])
294  }
295  const more = (r.changedTotal ?? 0) - (r.files?.length ?? 0)
296  if (more > 0) lines.push([d(`… ${more} more`)])
297  return lines
298}
299
300// ── Model ────────────────────────────────────────────────────────────────────────────────────────
301
302// Effort, styled to echo Claude Code's /effort menu. The menu animates the top tiers; a band
303// redraws on events, so they get a static gradient instead: high = violet, xhigh = a
304// violet→lavender→violet sheen, max = a coral→pink→violet→indigo sweep, ultra = a filled chip.
305const EFFORT_AMBER = [240, 176, 80]
306const EFFORT_GREEN = [122, 201, 132]
307const EFFORT_VIOLET = [167, 139, 250]
308const EFFORT_SHEEN = [EFFORT_VIOLET, [224, 208, 255], EFFORT_VIOLET]
309const EFFORT_GRADIENT = [[240, 138, 110], [236, 107, 160], [214, 108, 190], [168, 130, 226], [124, 142, 232]]
310const hex = (rgb: number[]) => `#${rgb.map((n) => Math.round(n).toString(16).padStart(2, '0')).join('')}`
311
312export function gradient(text: string, stops: number[][]): Run[] {
313  const chars = [...text]
314  const seg = Math.max(1, stops.length - 1)
315  return chars.map((ch, i) => {
316    const t = chars.length <= 1 ? 0 : i / (chars.length - 1)
317    const p = t * seg
318    const k = Math.min(seg - 1, Math.floor(p))
319    const f = p - k
320    const a = stops[k] ?? stops[0] ?? [255, 255, 255]
321    const b = stops[k + 1] ?? a
322    return { t: ch, c: hex(a.map((av, j) => av + ((b[j] ?? av) - av) * f)) }
323  })
324}
325
326export function effortRuns(level: string, icon: Icon): Run[] {
327  const bolt = icon(G.effort, SILVER) // the bolt stays a neutral anchor so the TEXT carries the level
328  switch (level.toLowerCase()) {
329    case 'low':
330      return [...bolt, v(level, hex(EFFORT_AMBER))]
331    case 'medium':
332      return [...bolt, v(level, hex(EFFORT_GREEN))]
333    case 'high':
334      return [...bolt, v(level, hex(EFFORT_VIOLET))]
335    case 'xhigh':
336      return [...bolt, ...gradient(level, EFFORT_SHEEN)]
337    case 'max':
338      return [...bolt, ...gradient(level, EFFORT_GRADIENT)]
339    case 'ultra':
340    case 'ultracode':
341      return [{ t: ` ${level} `, c: '#e0d0ff', bg: '#4a3a8c', b: true }]
342    default:
343      return [...bolt, v(level)]
344  }
345}
346
347function modelRow(x: Inputs, cfg: Config, on: (id: string) => boolean, icon: Icon): Section {
348  const items: Item[] = []
349  const m = x.model
350  if (m) items.push(plain([...icon(G.model, CYAN), { t: m.name, b: true }, ...(m.fast ? [v(' '), ...icon(G.speed, WARN)] : [])]))
351  const think = m?.thinking && on('model.thinking') ? icon(G.think, c256(183)) : []
352  if (m?.effort && on('model.effort')) items.push(plain([...effortRuns(m.effort, icon), ...(think.length ? [v(' '), ...think] : [])]))
353  else if (think.length) items.push(plain(think))
354  const u = x.usage
355  if (u && on('model.context')) {
356    const pct = Math.round(u.ctxPercent ?? (u.ctxTokens && u.ctxWindow ? (u.ctxTokens / u.ctxWindow) * 100 : 0))
357    const col = contextColor(pct)
358    const nums: Run[] = [v(`${pct}%`, col), d(` ${formatTokens(u.ctxTokens ?? 0)}/${formatTokens(u.ctxWindow)}`)]
359    const compact = [...icon(G.ctx, PINK), ...nums]
360    items.push(pair(cfg.bars ? [...icon(G.ctx, PINK), ...bar(pct, col), v(' '), ...nums] : compact, compact))
361  }
362  return { id: 'model', label: 'Model', items }
363}
364
365// ── Limits ───────────────────────────────────────────────────────────────────────────────────────
366
367function gauge(label: string, pct: number, resetsAt: string | undefined, fmt: 'auto' | 'week' | 'date', windowMs: number, x: Inputs, cfg: Config, extra: Run[] = []): Item {
368  const p = Math.round(pct)
369  const col = usageColor(p)
370  const rt = formatReset(resetsAt, fmt, x.now, cfg.clock12)
371  const bal = cfg.pace ? pace(p, resetsAt, windowMs, x.now) : null
372  // Kept to a few chars on purpose — the user wants it terse.
373  const paceRuns: Run[] = bal === null ? [] : [v(' '), bal > 2 ? v(`+${bal}%`, GOOD) : bal < -2 ? v(`${bal}%`, WARN) : d(`${bal >= 0 ? '+' : ''}${bal}%`)]
374  const compact: Run[] = [d(label), v(' '), v(`${p}%`, col), ...extra, ...(rt ? [d(` (${rt})`)] : []), ...paceRuns]
375  if (!cfg.bars) return plain(compact)
376  return pair([d(label), v(' '), ...bar(p, col), v(' '), v(`${p}%`, col), ...extra, ...(rt ? [d(` ${rt}`)] : []), ...paceRuns], compact)
377}
378
379function limitsRow(x: Inputs, cfg: Config, on: (id: string) => boolean, icon: Icon): Section {
380  const items: Item[] = []
381  const pcts: number[] = []
382  const add = (it: Item, p: number) => (items.push(it), pcts.push(p))
383  for (const l of x.usage?.limits ?? []) {
384    // five_hour and seven_day are the subscription windows; spend_limit is a Claude apps gateway's
385    // cap, which keeps counting past 100% once over (shown verbatim; the bar clamps itself).
386    if (l.kind === 'five_hour' && on('limits.5h')) add(gauge('5h', l.percentUsed, l.resetsAt, 'auto', 5 * 3600e3, x, cfg), l.percentUsed)
387    else if (l.kind === 'seven_day' && on('limits.7d')) add(gauge('7d', l.percentUsed, l.resetsAt, 'week', 7 * 864e5, x, cfg), l.percentUsed)
388    else if (l.kind === 'spend_limit' && on('limits.spend')) add(gauge('Sp', l.percentUsed, l.resetsAt, 'date', 0, x, cfg), l.percentUsed)
389  }
390  // Model-scoped weekly caps, each normalized by the API to its OWN 0-100% (7dF 15% = 15% of the
391  // Fable allowance, not of 7d).
392  if (on('limits.scoped')) for (const s of x.api?.scoped ?? []) add(gauge(s.label, s.percent, s.resetsAt, 'week', 7 * 864e5, x, cfg), s.percent)
393  // Usage credits: only once something has actually been spent — a standing $0 gauge is noise,
394  // but the moment credits kick in is exactly the event worth surfacing. No pace (no budget line).
395  const cr = x.api?.credits
396  if (cr && on('limits.credits')) {
397    const money: Run[] = [v(' '), v(formatCost(cr.used)), ...(cr.limit > 0 ? [d(`/$${Math.round(cr.limit)}`)] : [])]
398    add(gauge('Cr', cr.percent, undefined, 'auto', 0, x, { ...cfg, pace: false }, money), cr.percent)
399  }
400  const maxP = pcts.length ? Math.max(...pcts) : 0
401  return { id: 'limits', label: 'Limits', lead: items.length ? icon(G.gauge, maxP >= 70 ? usageColor(maxP) : undefined) : [], items }
402}
403
404// ── Usage ────────────────────────────────────────────────────────────────────────────────────────
405
406function usageRow(x: Inputs, on: (id: string) => boolean, icon: Icon): Section {
407  const items: Item[] = []
408  const u = x.usage
409  const a = x.activity
410  if (!u) return { id: 'usage', label: 'Usage', items }
411  const cost = u.costUsd ?? 0
412  const elapsed = Math.max(0, x.now - u.startedAt)
413  if (u.costUsd !== undefined && on('usage.cost')) items.push(plain([...icon(G.cost, GOOD), v(formatCost(cost))]))
414  if (cost > 0 && elapsed >= 60e3 && on('usage.burn')) {
415    const hourly = (cost / elapsed) * 3600e3
416    items.push(plain([...icon(G.cost, ORANGE), v(`~${formatCost(hourly)}/h`, hourly > 10 ? BAD : hourly > 5 ? WARN : GOOD)]))
417  }
418  // Model-request time is measured around each of the main loop's model requests (turn.step), so
419  // tok/s divides output by time the model was actually streaming, not tool or idle time.
420  if (a && a.apiMs > 0 && a.outputTokens > 0 && on('usage.speed')) {
421    const tps = a.outputTokens / (a.apiMs / 1000)
422    if (Number.isFinite(tps) && tps > 0) items.push(plain([...icon(G.speed, PENCIL), v(String(Math.round(tps))), d(' tok/s')]))
423  }
424  if (a && a.apiMs > 0 && elapsed > 0 && on('usage.api')) {
425    const share = Math.min(100, Math.round((a.apiMs / elapsed) * 100))
426    items.push(plain([...icon(G.api, CYAN), v(`API ${formatDuration(a.apiMs)}`, share > 70 ? WARN : undefined)]))
427  }
428  if (on('usage.duration')) items.push(plain([...icon(G.duration, SILVER), v(formatDuration(elapsed))]))
429  return { id: 'usage', label: 'Usage', items }
430}
431
432// ── Turn ─────────────────────────────────────────────────────────────────────────────────────────
433
434function turnRow(x: Inputs, on: (id: string) => boolean, icon: Icon): Section {
435  const items: Item[] = []
436  const c = x.breakdown?.lastCall
437  if (c && on('turn.tokens')) {
438    const parts: Run[][] = []
439    if (c.input > 0) parts.push([...icon(G.in, CYAN), v(formatTokens(c.input))])
440    if (c.output > 0) parts.push([...icon(G.out, PENCIL), v(formatTokens(c.output))])
441    if (c.cacheWrite > 0) parts.push([...icon(G.write, WARN), v(formatTokens(c.cacheWrite))])
442    if (c.cacheRead > 0) parts.push([...icon(G.read, GOOD), v(formatTokens(c.cacheRead))])
443    if (parts.length) items.push(plain(parts.flatMap((p, i) => (i ? [d(' | '), ...p] : p))))
444  }
445  // Prompt-cache hit ratio over the session: cache reads ÷ all input. A long session stuck low is
446  // paying full price for its own history.
447  const a = x.activity
448  if (a && a.inputTotal > 0 && on('turn.cache')) {
449    const hp = Math.round((a.cacheRead / a.inputTotal) * 100)
450    items.push(plain([...icon(G.promptCache, c256(45)), v(`${hp}%`, hp >= 80 ? GOOD : hp < 50 ? WARN : undefined), d(' cached')]))
451  }
452  return { id: 'turn', label: 'Turn', items }
453}
454
455// ── Activity ─────────────────────────────────────────────────────────────────────────────────────
456
457export function todoProgress(a: Activity): Activity['todos'] {
458  const tasks = Object.values(a.tasks)
459  if (tasks.length) {
460    const current = tasks.find((t) => t.status === 'in_progress') ?? tasks.find((t) => t.status === 'pending')
461    return { completed: tasks.filter((t) => t.status === 'completed').length, total: tasks.length, current: current?.subject }
462  }
463  return a.todos
464}
465
466function activityRow(x: Inputs, on: (id: string) => boolean, icon: Icon): Section {
467  const items: Item[] = []
468  const a = x.activity
469  if (!a) return { id: 'activity', label: 'Activity', items }
470  if (a.command && on('activity.command')) items.push(plain([...icon(G.command, WARN), v(a.command)]))
471  const todo = todoProgress(a)
472  if (todo && todo.total > 0 && on('activity.todo')) {
473    const pct = Math.round((todo.completed / todo.total) * 100)
474    const col = pct === 100 ? GOOD : pct >= 50 ? WARN : BAD
475    const count = v(`[${todo.completed}/${todo.total}]`, col)
476    items.push(todo.current ? pair([...icon(G.todo, col), v(truncate(todo.current, 26)), v(' '), count], [...icon(G.todo, col), count]) : plain([...icon(G.todo, col), count]))
477  }
478  if (a.toolCalls > 0 && on('activity.tool')) {
479    const lt = a.lastTool
480    const label = lt ? (lt.target ? `${lt.name}(${lt.target})` : lt.name) : 'tool'
481    const col = a.running > 0 ? WARN : undefined // yellow while a tool is mid-run
482    items.push(pair([...icon(G.tools, col), v(label), d(` (${a.toolCalls} call${a.toolCalls === 1 ? '' : 's'})`)], [...icon(G.tools, col), v(truncate(label, 24))]))
483  }
484  // Counts only: how many sub-agents run now and a green tally of finished ones (failed in red).
485  const ag = a.agents
486  if ((ag.running > 0 || ag.completed > 0 || ag.failed > 0) && on('activity.agents')) {
487    const bits: Run[][] = []
488    if (ag.running > 0) bits.push([v(`${ag.running} running`)])
489    if (ag.completed > 0) bits.push([v(`${OK} ${ag.completed}`, GOOD)])
490    if (ag.failed > 0) bits.push([v(`${NO} ${ag.failed}`, BAD)])
491    items.push(plain([...icon(G.model, ag.running > 0 ? CYAN : undefined), ...bits.flatMap((b, i) => (i ? [d(' · '), ...b] : b))]))
492  }
493  if (ag.teammates.length && on('activity.team')) {
494    const head = [...icon(G.team, c256(43)), v(String(ag.teammates.length))]
495    const list = ag.teammates.slice(0, 3).join(', ') + (ag.teammates.length > 3 ? ', …' : '')
496    items.push(pair([...head, d(` (${truncate(list, 40)})`)], head))
497  }
498  if ((a.linesAdded > 0 || a.linesRemoved > 0) && on('activity.lines')) {
499    const diff: Run[] = []
500    if (a.linesAdded > 0) diff.push(v(`+${a.linesAdded}`, GOOD))
501    if (a.linesRemoved > 0) diff.push(...(diff.length ? [v(' ')] : []), v(`-${a.linesRemoved}`, BAD))
502    items.push(plain([...icon(G.lines, PENCIL), ...diff]))
503  }
504  return { id: 'activity', label: 'Activity', items }
505}
506
507// ── Repo ─────────────────────────────────────────────────────────────────────────────────────────
508
509function repoRow(x: Inputs, on: (id: string) => boolean, icon: Icon): Section {
510  const items: Item[] = []
511  const r = x.repo
512  if (!r) return { id: 'repo', label: 'Repo', items }
513  items.push(plain([...icon(G.tree, BBLUE), v(r.name)]))
514  if (r.remote && on('repo.remote')) {
515    const h = r.remote.host.toLowerCase()
516    const [g, col] = h.includes('github') ? [G.github, SILVER] : h.includes('gitlab') ? [G.gitlab, ORANGE] : h.includes('bitbucket') ? [G.bitbucket, BBLUE] : [G.repoHost, undefined]
517    // owner alone when the remote's name matches the folder; owner/name when it differs (fork/rename)
518    const same = r.remote.name.toLowerCase() === r.name.toLowerCase()
519    items.push(plain([...icon(g, col), v(r.remote.owner), ...(same ? [] : [d('/'), v(r.remote.name)])]))
520  }
521  if (r.worktree && on('repo.worktree')) items.push(plain([...icon(G.tree, MAGENTA), v(r.worktree)]))
522  if (on('repo.branch')) {
523    const st: Run[] = [v(r.branch)]
524    if (r.ahead > 0) st.push(v(` ${glyphOr(icon, G.ahead, '↑')}${r.ahead}`, GOOD))
525    if (r.behind > 0) st.push(v(` ${glyphOr(icon, G.behind, '↓')}${r.behind}`, WARN))
526    if (r.staged > 0) st.push(v(` +${r.staged}`, GOOD))
527    if (r.modified > 0) st.push(v(` ~${r.modified}`, WARN))
528    if (r.untracked > 0) st.push(v(` ?${r.untracked}`, BBLUE))
529    if (r.conflicts > 0) st.push(v(` !${r.conflicts}`, BAD))
530    items.push(plain([...icon(G.branch, CYAN), ...st]))
531  }
532  if (r.pr && on('repo.pr')) {
533    const s = r.pr.state
534    const [col, mark]: [string | undefined, Run[]] =
535      s === 'approved' ? [GOOD, [v(` ${OK}`, GOOD)]] : s === 'changes' ? [BAD, [v(` ${NO}`, BAD)]] : s === 'draft' ? [undefined, [d(' draft')]] : s === 'pending' ? [WARN, [v(' ●', WARN)]] : [undefined, []]
536    items.push(plain([...icon(G.pr, MAGENTA), { t: `#${r.pr.number}`, c: col, href: r.pr.url }, ...mark]))
537  }
538  if (r.tag && on('repo.tag')) items.push(plain([...icon(G.tag, MAGENTA), v(r.tag.tag), ...(r.tag.count > 0 ? [v(`+${r.tag.count}`, WARN)] : [])]))
539  return { id: 'repo', label: 'Repo', items }
540}
541
542const glyphOr = (icon: Icon, glyph: string, fallback: string) => (icon(glyph).length ? glyph : fallback)
543
544// ── Config / Exts. ───────────────────────────────────────────────────────────────────────────────
545// `icon total (m/u/p/l/x)`: a count, then a dim parenthetical saying WHERE those items come from.
546// Five fixed slots, broad → narrow: managed / user / project / local / plugin. A digit (0 too)
547// means the item type CAN come from that scope; `-` means it never can. The parens appear only
548// once the total is > 0, and are the first thing dropped when the row runs out of width.
549
550const SCOPES = ['m', 'u', 'p', 'l', 'x'] as const
551const total = (c: Scopes) => (c.m ?? 0) + (c.u ?? 0) + (c.p ?? 0) + (c.l ?? 0) + (c.x ?? 0)
552
553function scoped(head: Run[], c: Scopes, caps: string, showScopes: boolean): Item {
554  const n = total(c)
555  const base = [...head, v(String(n), n > 0 ? GOOD : undefined)]
556  if (!caps || n === 0 || !showScopes) return plain(base)
557  return pair([...base, d(` (${SCOPES.map((k) => (caps.includes(k) ? String(c[k] ?? 0) : '-')).join('/')})`)], base)
558}
559
560function configRow(x: Inputs, on: (id: string) => boolean, icon: Icon): Section {
561  const items: Item[] = []
562  const c = x.counts
563  if (!c) return { id: 'config', label: 'Config', items, preferDetail: true }
564  const scopes = on('config.scopes')
565  const show = (id: string, glyph: string, color: string, s: Scopes, caps = '') => {
566    if (on(`config.${id}`)) items.push(scoped(icon(glyph, color), s, caps, scopes))
567  }
568  show('instructions', G.cfgDoc, c256(36), c.instructions, 'mupl')
569  show('memory', G.cfgMemory, c256(177), c.memory)
570  show('agents', G.cfgAgents, c256(211), c.agents, 'mupx')
571  show('commands', G.cfgCmds, c256(32), c.commands, 'upx')
572  show('skills', G.cfgSkills, c256(141), c.skills, 'mupx')
573  show('workflows', G.cfgWorkflows, c256(78), c.workflows, 'upx')
574  show('routines', G.cfgRoutines, c256(173), c.routines, 'up')
575  show('rules', G.cfgRules, c256(33), c.rules, 'up')
576  show('mcp', G.cfgMcp, c256(216), c.mcp, 'muplx')
577  show('connectors', G.cfgConn, c256(117), c.connectors)
578  if (on('config.chrome')) items.push(plain([...icon(G.cfgChrome, c256(75)), v(c.chrome ? OK : NO)]))
579  show('hooks', G.cfgHooks, c256(220), c.hooks, 'muplx')
580  show('plugins', G.cfgPlugins, c256(196), c.plugins, 'mupl')
581  show('themes', G.cfgTheme, c256(213), c.themes, 'ux')
582  show('dirs', G.cfgDirs, c256(250), c.dirs)
583  return { id: 'config', label: 'Config', items, preferDetail: true }
584}
585
586function extsRow(x: Inputs, on: (id: string) => boolean, icon: Icon): Section {
587  const items: Item[] = []
588  const c = x.counts
589  if (!c) return { id: 'exts', label: 'Exts.', items }
590  const show = (id: string, glyph: string, color: string, s: Scopes, caps = '') => {
591    if (on(`exts.${id}`)) items.push(scoped(icon(glyph, color), s, caps, on('config.scopes')))
592  }
593  show('lsp', G.cfgLsp, c256(81), c.lsp)
594  show('monitors', G.cfgMonitor, c256(209), c.monitors)
595  show('bin', G.cfgBin, c256(108), c.bin)
596  show('channels', G.cfgChannel, c256(116), c.channels, 'ux')
597  return { id: 'exts', label: 'Exts.', items }
598}
599
600// ── Host ─────────────────────────────────────────────────────────────────────────────────────────
601
602function hostRow(x: Inputs, cfg: Config, on: (id: string) => boolean, icon: Icon): Section {
603  const items: Item[] = []
604  if (on('host.clock')) items.push(plain([...icon(G.clock), v(formatClock(x.now, cfg.clock12))]))
605  const h = x.host
606  if (h) {
607    if (on('host.os')) {
608      const [g, col] = h.os.kind === 'windows' ? [G.win, c256(39)] : h.os.kind === 'macos' ? [G.apple, undefined] : [G.linux, c256(208)]
609      items.push(plain([...icon(g, col), v(h.os.label)]))
610    }
611    if ((h.user || h.host) && on('host.user')) {
612      const id: Run[] = h.user && h.host ? [v(h.user), d('@'), v(h.host)] : [v(h.user || h.host)]
613      items.push(plain([...icon(G.person, c256(114)), ...id]))
614    }
615    // Other live Claude Code sessions on this machine — the population behind the `← N agent`
616    // count in Claude Code's own footer. Hidden at 0: an always-on "none" is noise.
617    if (h.fleet.length && on('host.fleet')) {
618      const head = [...icon(G.fleet, c256(110)), v(String(h.fleet.length))]
619      const list = h.fleet.slice(0, 2).join(', ') + (h.fleet.length > 2 ? ', …' : '')
620      items.push(pair([...head, d(` (${list})`)], head))
621    }
622  }
623  return { id: 'host', label: 'Host', items }
624}
625
626// ── Info. ────────────────────────────────────────────────────────────────────────────────────────
627
628function infoRow(x: Inputs, on: (id: string) => boolean, icon: Icon): Section {
629  const items: Item[] = []
630  const i = x.info
631  if (!i) return { id: 'info', label: 'Info.', items }
632  const cwd = shortenPath(homeRelative(i.cwd, i.home))
633  if (cwd && on('info.cwd')) items.push(plain([...icon(G.dir, BBLUE), v(cwd)]))
634  if (i.outputStyle && i.outputStyle.toLowerCase() !== 'default' && on('info.style')) items.push(plain([...icon(G.style, PENCIL), v(i.outputStyle)]))
635  if (i.version && on('info.version')) items.push(plain([...icon(G.version), v(`v${i.version}`)]))
636  if (i.agent && on('info.agent')) items.push(plain([...icon(G.user, SILVER), v(i.agent)]))
637  // The id's first segment is enough to recognise it; `claude --resume` takes the whole id.
638  if (i.sessionId && on('info.session')) items.push(plain([...icon(G.key, SILVER), v(i.sessionId.split('-')[0] ?? i.sessionId)]))
639  return { id: 'info', label: 'Info.', items }
640}
641
types/index.d.ts 189 lines
1// The values this mod keeps in $.state (claude plugin validate checks every key the module names
2// against this contract) and the shapes they hold. Everything a row draws from lives here, written
3// by the event hooks in hooks/register.tsx; the band's render hook only reads.
4
5/** One rate-limit window as `$.session.usage()` reports it. */
6export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
7
8/** The status line's live figures: `$.session.usage()` without the breakdown. */
9export type Usage = {
10  startedAt: number
11  /** Input tokens the last response was answered over (uncached + cache-written + cache-read). */
12  ctxTokens?: number
13  ctxWindow: number
14  ctxPercent?: number
15  limits: Limit[]
16  costUsd?: number
17}
18
19/** One /context category, minus what the bar doesn't need. */
20export type Segment = {
21  name: string
22  tokens: number
23  /** A theme colour key (`promptBorder`, `inactive`, …): what /context draws the row in. */
24  color: string
25  kind: 'used' | 'free' | 'buffer' | 'deferred'
26}
27
28/** Where a loaded item came from: managed, user, project, local, plugin. */
29export type Scopes = { m?: number; u?: number; p?: number; l?: number; x?: number }
30
31/** The context breakdown (`$.session.usage({ breakdown: 'summary' })`), boiled down. */
32export type Breakdown = {
33  categories: Segment[]
34  totalTokens: number
35  /** The window the breakdown measures against (the compaction window, which may be under the model's). */
36  maxTokens: number
37  percentage: number
38  /** Token makeup of the live window's last API response; null before one came back. */
39  lastCall: { input: number; output: number; cacheWrite: number; cacheRead: number } | null
40  /** Instruction files in context by scope (CLAUDE.md, AGENTS.md, rules), from the engine's own list. */
41  instructions: Scopes
42  /** The auto-memory index's directory, when one loaded. */
43  memoryDir?: string
44  agents: Scopes
45  skills: Scopes
46  /** Connected MCP servers by name; `claude.ai …` connectors among them. */
47  mcpServers: string[]
48}
49
50/** What the model row shows beyond the model name. */
51export type Model = {
52  name: string
53  /** The last model request's effort level (turn.step reports it); absent on models without one. */
54  effort?: string
55  thinking: boolean
56  fast: boolean
57}
58
59export type Repo = {
60  /** The folder name of the main working tree (a worktree shows its parent repository). */
61  name: string
62  branch: string
63  ahead: number
64  behind: number
65  staged: number
66  modified: number
67  untracked: number
68  conflicts: number
69  worktree?: string
70  remote?: { host: string; owner: string; name: string }
71  tag?: { tag: string; count: number }
72  pr?: { number: number; url: string; state: 'approved' | 'changes' | 'pending' | 'draft' | 'open' }
73  /** `$.clock.now()` of the last `gh` lookup, so it runs at most every few minutes. */
74  prCheckedAt?: number
75  prBranch?: string
76  /** For the open view: the first changed paths with their two-letter status, and the last commit. */
77  files?: Array<{ xy: string; path: string }>
78  changedTotal?: number
79  lastCommit?: { sha: string; subject: string; at: number }
80}
81
82export type Host = {
83  os: { kind: 'windows' | 'macos' | 'linux'; label: string }
84  user: string
85  host: string
86  /** Names of the other live Claude Code sessions on this machine. */
87  fleet: string[]
88}
89
90export type Info = {
91  cwd: string
92  home: string
93  version: string
94  sessionId: string
95  outputStyle?: string
96  agent?: string
97}
98
99/** Config and plugin-component counts, each broken down by scope (see the README's table). */
100export type Counts = {
101  trusted: boolean
102  instructions: Scopes
103  memory: Scopes
104  agents: Scopes
105  commands: Scopes
106  skills: Scopes
107  workflows: Scopes
108  routines: Scopes
109  rules: Scopes
110  mcp: Scopes
111  connectors: Scopes
112  chrome: boolean
113  hooks: Scopes
114  plugins: Scopes
115  themes: Scopes
116  dirs: Scopes
117  lsp: Scopes
118  monitors: Scopes
119  bin: Scopes
120  channels: Scopes
121}
122
123/** The OAuth usage API's extra gauges: model-scoped weekly caps and usage credits. */
124export type ApiUsage = {
125  at: number
126  scoped: Array<{ label: string; percent: number; resetsAt?: string }>
127  credits?: { percent: number; used: number; limit: number }
128}
129
130export type Task = { subject: string; status: string }
131
132/** What the session has been doing, accumulated from tool calls and turns. */
133export type Activity = {
134  toolCalls: number
135  lastTool?: { name: string; target?: string }
136  /** Tool calls started and not yet answered (main loop). */
137  running: number
138  todos?: { completed: number; total: number; current?: string }
139  /** TaskCreate/TaskUpdate tasks by their id, in creation order. */
140  tasks: Record<string, Task>
141  linesAdded: number
142  linesRemoved: number
143  command?: string
144  /** Output tokens and model-request time summed over the main loop's turns, for tok/s and API time. */
145  outputTokens: number
146  apiMs: number
147  /** Session-long prompt-cache counts, for the hit ratio. */
148  cacheRead: number
149  inputTotal: number
150  agents: {
151    running: number
152    completed: number
153    failed: number
154    teammates: string[]
155    /** For the open view: the session's sub-agents, running ones first. */
156    list?: Array<{ type: string; description: string; status: string }>
157  }
158  /** For the open view: the last few tool calls (newest last); `ok` once answered. */
159  recentTools?: Array<{ name: string; target?: string; ok?: boolean }>
160  /** For the open view: the TodoWrite list as last written. */
161  todoItems?: Task[]
162  /** For the open view: the last few main-loop turns (newest last). */
163  turns?: Array<{ ms: number; output: number; input: number; cacheRead: number }>
164}
165
166/** The person's view of the band: shown or not, and which rows are open (oldest first). */
167export type View = { isVisible: boolean; expanded: string[] }
168
169declare module 'claude-code' {
170  interface PluginState {
171    'statusline-dashboard': {
172      usage: Usage | null
173      breakdown: Breakdown | null
174      model: Model | null
175      repo: Repo | null
176      host: Host | null
177      info: Info | null
178      counts: Counts | null
179      api: ApiUsage | null
180      activity: Activity | null
181      view: View
182      /** The user config file's parsed JSON (hooks/sections.ts parseConfig reads it); null when absent. */
183      config: unknown
184      /** Bumped every minute so the clock, countdowns and pace redraw with nothing else changing. */
185      tick: number
186    }
187  }
188}
189