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

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
/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.
Click a row's ▸/▾ label (fullscreen), or ctrl+x tab then Tab and Enter | Open or close that row. Only rows with more to show have the arrow; a row without one is already whole. |
/dash | Show 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 all | Open or close every row |
/dash cleanup | Remove the 1.x statusLine / subagentStatusLine blocks from settings.json (the agent panel's rows are now the agent-panel plugin) |
ctrl+x ctrl+a | Claude 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.
… when even that won't fit.(m/u/p/l/x) breakdowns. Some rows add what a single line has no room for:/context shows it — a stacked bar, one colour per category, and a legend.×n), and the session's sub-agents in the order they started, each with its task and how it ended.▹). The row you opened last wins.+ Turn Activity …). They stay one click away./clear, or Repo outside a git repository./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.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.$/h burn rate, throughput (tok/s over the time the model was actually streaming), API time, and wall-clock.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.bin/ executables, and message channels.user@host, and how many other Claude Code sessions are live on this machine.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.
(0/1/1/0/-) scope breakdownicon total (m/u/p/l/x) is a count followed by where those items come from. The slots are fixed, broadest to narrowest:
| Slot | Scope | Where it lives |
|---|---|---|
m | managed | Enterprise/org policy: managed-settings.json, managed-mcp.json, the Windows registry policy. |
u | user | ~/.claude/ and ~/.claude.json. Loads in every project. |
p | project | Committed in the repo: .claude/, .mcp.json, CLAUDE.md. |
l | local | Your uncommitted overrides: .claude/settings.local.json, CLAUDE.local.md, ~/.claude.json projects[…]. |
x | plugin | Bundled 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.
| Item | Slots that can hold a count |
|---|---|
| Instruction files, Plugins | m u p l |
| MCP servers, Hooks | m u p l x |
| Agents, Skills | m u p x |
| Commands, Workflows | u p x |
| Rules, Routines | u p |
| Themes, Channels | u x |
claude -p run the hooks but draw nothing.MonoLisa, SymbolsNerdFont. The Desktop app draws no icons.git for the Repo row, and an authed gh for its PR badge.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
}
| Key | Default | Effect |
|---|---|---|
rows | all rows | Which 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. |
labels | true | false drops the row labels. The ▸/▾ toggles stay. |
bars | true | false renders every gauge as a number only. |
pace | true | false drops the +N% / -N% pace balance. |
clock | "24h" | "12h" for a 12-hour clock and reset times. |
usageApi | true | false never calls the usage API: no model-scoped weekly gauges and no Cr gauge. |
pr | true | false never runs gh for the Repo row's PR badge. |
maxRows | none | Caps the dashboard's height. It can only lower the built-in budget. |
Widget ids for hide:
| Row | Widgets |
|---|---|
| Model | model.effort model.thinking model.context |
| Limits | limits.5h limits.7d limits.scoped limits.spend limits.credits |
| Usage | usage.cost usage.burn usage.speed usage.api usage.duration |
| Turn | turn.tokens turn.cache turn.history |
| Activity | activity.command activity.todo activity.tool activity.agents activity.team activity.lines |
| Repo | repo.remote repo.worktree repo.branch repo.pr repo.tag repo.files |
| Config | config.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 |
| Host | host.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.
/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.
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.
| Event | What it refreshes |
|---|---|
session.start, /clear, /resume, compaction | Everything. 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.complete | Tokens, cache ratio, then git and the config counts |
tool.call | Activity: last tool, todos and tasks, edit lines |
| Every minute | Clock, 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.
hooks/register.tsx 528 lines1// 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}
528hooks/collect.ts 637 lines1// 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}
637hooks/layout.ts 501 lines1// 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}
501hooks/sections.ts 641 lines1// 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}
641types/index.d.ts 189 lines1// 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