A floating status card above the Claude Code prompt — model, effort, context, 5h/week limits, cost, directory and branch, GitHub CI and deploys — with…

A floating status card for Claude Code.<br> Model, context, rate limits, cost and branch at a glance, your GitHub CI and deploys, plus progress bars any script can feed.
English · 中文
<img src="assets/card.svg" alt="The status card above the Claude Code prompt" width="720">
Claude Code's statusLine is one line of text from a shell script. statuspane is a mod: a small card that lives just above the prompt, reads Claude Code's own session figures, and can be clicked — no script to write.
| Model · effort | The model in use, whether Claude is working, and its reasoning effort on a gauge (▮▮▮▯▯ high) |
| Context | A gauge with tokens used / window (▰▰▰▱▱▱ 42% 222k/1M), and ⟲ compact: press, then confirm, to run /compact (after the turn, if Claude is working) |
| Rate limits | 5-hour and 7-day use, each on a small gauge, with reset countdowns (↻2h41m) |
| Where you are | Directory and git branch, shortened so the card stays narrow |
| Session cost | What this session has cost so far |
| GitHub CI | The branch's latest Actions run and the runs a push sets off, deploys included (GitHub CI) |
| Progress rows | Bars any script or mod can feed, refreshed every second (Progress API) |
| Clickable | Hide, show and settings are buttons; everything works with the mouse |
| Settings | Pick the lines you want and the bar width; one set for every session, a change shows in the others within 2 seconds |
It is drawn in Claude Code's own theme colors, so it follows dark, light and colorblind themes: gauges in Claude's accent, turning to the theme's warning color from 60 % and its error color from 85 % (context: 50 % / 80 %).
Needs Claude Code 2.1.287 or later, the release that brought mods (function hooks); tested on 2.1.288. Mods are early access: their API may change between releases, and a release that breaks the card gets a fix here.
/plugin marketplace add xuanji86/claude-statuspane
/plugin install statuspane@claude-statuspane
claude plugin marketplace add xuanji86/claude-statuspane
claude plugin install statuspane@claude-statuspane
The card appears in terminals at least 70 columns wide; the desktop app shows none, since it has its own. It adds to your setup and replaces nothing: a configured statusLine keeps showing; delete it from ~/.claude/settings.json if you want the card alone.
<table> <tr> <td width="50%" valign="top">
Settings — click ⚙
<img src="assets/settings.svg" alt="The settings page" width="100%">
Click a line to switch it, [ - ] / [ + ] for the bar width (6–24), then ✓ Done.
</td> <td width="50%" valign="top">
Hidden — click ▾ hide
<img src="assets/hidden.svg" alt="The card folded to one button" width="100%">
One button stays at the right edge; click ◂ status to bring the card back. /statuspane does the same from the prompt.
</td> </tr> </table>
The - at the top right of the panel is Claude Code's own "hide plugin panel". It hides the whole panel, and only its keybinding brings it back (default ctrl+x ctrl+a). If your terminal doesn't pass ctrl+x chords through, bind it to one key in ~/.claude/keybindings.json:
{
"bindings": [
{ "context": "Chat", "bindings": { "ctrl+s": "abovePrompt:toggle", "ctrl+q": "chat:stash" } }
]
}
ctrl+s is "stash" by default, so the example moves stash to ctrl+q.
Two switches on the settings page, both off until you turn them on. They need the GitHub CLI signed in (gh auth login).
| Switch | Shows |
|---|---|
| CI · this branch | The latest Actions run of the branch the session is on: checked every minute, every 10 seconds while it runs |
| CI · after a push | When Claude runs git push or gh pr merge, the runs that set off (for a merge, on the base branch) until they finish; the result stays for 10 minutes |
Each row reads <repo> <branch> and then:
⟳ test · 1m20s | accent: under way, with the jobs running now and the time so far (ticks every second between checks) |
⟳ deploying · 1m20s | a job whose name has deploy in it is running |
✓ deployed · 3m ago | green: done, and a deploy job succeeded (✓ passed when none ran) |
✗ test failed · 3m ago | red: the job (or workflow) that failed |
⊘ cancelled · 3m ago | every run was cancelled or skipped |
All of one commit's workflows make one row; scheduled runs are left out.
Show your own progress on the card, from any language or from another mod. Rows are sorted by id, at most five show, and each one goes away by itself once its source stops reporting. The card reads the 20 most recently written files, so old files left behind never crowd out a new one.
Write ~/.claude/statuspane/progress/<id>.json (the folder can be moved with STATUSPANE_PROGRESS_DIR, an absolute path or one starting with ~/):
{ "label": "build", "percent": 42.5, "text": "3/7 · 1.2/min", "ttl": 300, "state": "running" }
| Field | Type | |
|---|---|---|
label | string | Shown before the bar, up to 24 characters (defaults to the id) |
percent | number, optional | 0–100. Leave it out for a text-only row |
text | string, optional | Shown after the bar, up to 60 characters |
ttl | seconds, optional | How long the row stays after the file was last written (default 300) |
state | string, optional | running (accent), ok (green) or error (red): colors the gauge and the text |
<id> uses letters, digits, ., _ and -. Write to a temporary file and rename it over the target so the card never reads half a file; delete the file to remove the row at once.
Ready-made helpers in examples/ (they check the id, cut fields to length and write atomically; the shell one needs python3):
examples/report-progress.sh build "build" 42.5 "3/7" # id label [percent] [text] [ttl] [state]
from report_progress import report, clear
report("my-job", "my job", 40, "4/10", state="running")
clear("my-job")
$.statuspaneList statuspane under dependencies in your mod's plugin.json (its types are then laid into your .claude-plugin/types/statuspane/), and call:
await $.statuspane.progress({ id: 'my-job', label: 'my job', percent: 40, text: '4/10', ttl: 120, state: 'running' })
await $.statuspane.clear('my-job')
Same fields as the file. Rows from a mod live in memory, so report again after statuspane reloads.
progress --watch / --line show translation progress.Its footprint, as the awesome-claude-code-mods scan reads it from claude plugin validate:
Everything stays on your machine. statuspane reads Claude Code's own session figures, runs git branch --show-current in the session's directory, and lists the progress folder. Only with a CI switch on does it reach out: it reads the origin remote and runs gh run list / gh run view (and gh pr view after a merge) for that repository, through gh and your own sign-in. From that folder it reads only *.json files of at most 64 KB, strips control, bidi and zero-width characters from their text, cuts every field to length, and never runs anything they contain.
git clone https://github.com/xuanji86/claude-statuspane
cd claude-statuspane
claude plugin validate .
claude plugin test .
claude --plugin-dir . # or add the folder to CLAUDE_CODE_PLUGIN_DIRS
A session that loaded the mod from its folder reloads it each time you save a file. Issues and pull requests are welcome.
MIT © Anji Xu
hooks/register.tsx 795 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren, RenderElement, SessionUsage } from 'claude-code'
3
4import type {
5 Statuspane,
6 StatuspaneCompact as Compact,
7 StatuspaneFigures as Figures,
8 StatuspaneLimit as Limit,
9 StatuspanePrefs as Prefs,
10 StatuspaneRow as Row,
11 StatuspaneState as State,
12} from '../types'
13
14const EMPTY: Figures = { dir: null, branch: null, model: null, effort: null, progress: [] }
15export const DEFAULT_PREFS: Prefs = {
16 model: true, ctx: true, limits: true, eta: true, location: true, cost: true, progress: true,
17 ciBranch: false, ciPush: false, barWidth: 12, // CI calls GitHub through `gh`: off until asked for
18}
19const figures = atom({ plugin: 'statuspane', key: 'figures' } as const, EMPTY)
20const isHidden = atom({ plugin: 'statuspane', key: 'isHidden' } as const, false)
21const prefs = atom({ plugin: 'statuspane', key: 'prefs' } as const, DEFAULT_PREFS)
22const isSettingsOpen = atom({ plugin: 'statuspane', key: 'isSettingsOpen' } as const, false)
23const compactAsk = atom({ plugin: 'statuspane', key: 'compact' } as const, null)
24
25const CARD_WIDTH = 34 // inside the border, at least; a wider line widens the card
26const MAX_CARD_WIDTH = 50
27const MIN_COLUMNS = 70 // narrower than this the card would cover too much; draw nothing
28// Right of the first line, after a space: '● working', then the ' ⚙ ' and '▾ hide' buttons, each with room to click.
29const HEAD_RIGHT = '● working'
30const BUTTONS_WIDTH = HEAD_RIGHT.length + 2 + 3 + 1 + 6
31const LIMIT_GAUGE = 5 // cells of each limit's gauge
32const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max']
33const PENDING = '—'
34const CONFIRM_MS = 5_000 // a pressed ⟲ compact waits this long for the press that confirms it
35const COMPACT_MAX_MS = 600_000 // a compaction still marked running after this was lost (a reload mid-run)
36const HIDDEN_RIGHT_PAD = 5 // clear of Claude Code's own [-] panel toggle, drawn at the band's top right
37const BAR_MIN = 6
38const BAR_MAX = 24
39const MAX_PROGRESS_ROWS = 5
40const MAX_PROGRESS_FILES = 20 // the newest by mtime; older, stale files fall out of the window
41const MAX_PROGRESS_BYTES = 65_536 // fields are cut to length after reading, so a long text only shortens
42const MAX_API_REPORTS = 20
43const DEFAULT_TTL = 300
44const PREFS_POLL_MS = 2_000 // the ⚙ page of another session wrote the store: no watcher, so poll
45const PROGRESS_POLL_MS = 1_000 // no file watcher in the mod API: poll, re-reading only files whose mtime or size moved
46const PROGRESS_DIR_DEFAULT = '.claude/statuspane/progress' // under the home folder
47const CI_TICK_MS = 5_000 // how often the CI poller looks for a target that is due
48const CI_IDLE_MS = 60_000 // a target whose runs are done
49const CI_BUSY_MS = 10_000 // a target whose runs are under way, or a push waiting for its runs
50const CI_WAIT_MS = 180_000 // a push whose runs have not shown by then is let go
51const CI_LINGER_MS = 600_000 // a push's finished result stays this long
52const CI_SKEW_MS = 60_000 // GitHub's clock against ours, when telling a push's runs from older ones
53
54export const STRINGS = {
55 settings: 'Status settings', model: 'Model · effort', ctx: 'Context bar', limits: '5h / week limits',
56 eta: 'Reset countdowns', location: 'Directory · branch', cost: 'Session cost', progress: 'Progress rows',
57 ciBranch: 'CI · this branch', ciPush: 'CI · after a push',
58 bar: 'Bar width', done: '✓ Done', hide: '▾ hide', show: '◂ status',
59 hidden: 'Status card hidden.', shown: 'Status card shown.',
60 tooNarrow: 'Status card shown; it draws once the terminal is at least 70 columns wide.',
61} as const
62
63type Switch = Exclude<keyof Prefs, 'barWidth'>
64// The settings page's switches, in the card's order.
65export const SWITCHES: Switch[] = ['model', 'ctx', 'limits', 'eta', 'location', 'cost', 'progress', 'ciBranch', 'ciPush']
66
67// `press` makes the part a button: the one the card has in its lines, ⟲ compact.
68export type Part = { text: string; color?: string; dim?: boolean; bold?: boolean; press?: 'compact' }
69
70// Claude Code's own colors, by theme key, so the card follows the person's theme: the accent, then the
71// theme's warning and error past a classic statusline script's thresholds.
72const levelColor = (pct: number, warnAt: number, errAt: number) => (pct >= errAt ? 'error' : pct >= warnAt ? 'warning' : 'claude')
73export const usedColor = (pct: number) => levelColor(pct, 60, 85)
74const ctxColor = (pct: number) => levelColor(pct, 50, 80)
75
76// Emoji below the emoji block that terminals draw two columns wide (East Asian Width W): ⌛ ⚡ ✅ ❌ ⭐ …
77const EMOJI_WIDE: [number, number][] = [
78 [0x231a, 0x231b], [0x23e9, 0x23ec], [0x23f0, 0x23f0], [0x23f3, 0x23f3], [0x25fd, 0x25fe], [0x2614, 0x2615],
79 [0x2648, 0x2653], [0x267f, 0x267f], [0x2693, 0x2693], [0x26a1, 0x26a1], [0x26aa, 0x26ab], [0x26bd, 0x26be],
80 [0x26c4, 0x26c5], [0x26ce, 0x26ce], [0x26d4, 0x26d4], [0x26ea, 0x26ea], [0x26f2, 0x26f3], [0x26f5, 0x26f5],
81 [0x26fa, 0x26fa], [0x26fd, 0x26fd], [0x2705, 0x2705], [0x270a, 0x270b], [0x2728, 0x2728], [0x274c, 0x274c],
82 [0x274e, 0x274e], [0x2753, 0x2755], [0x2757, 0x2757], [0x2795, 0x2797], [0x27b0, 0x27b0], [0x27bf, 0x27bf],
83 [0x2b1b, 0x2b1c], [0x2b50, 0x2b50], [0x2b55, 0x2b55],
84]
85
86// Terminal columns a string takes: East Asian wide and fullwidth characters and emoji take two.
87// lazy: range table, not full Unicode East Asian Width; upgrade to a generated table if a script draws wrong.
88export const cols = (s: string) => {
89 let n = 0
90 for (const ch of s) {
91 const c = ch.codePointAt(0) ?? 0
92 const wide =
93 (c >= 0x1100 && c <= 0x115f) || (c >= 0x2e80 && c <= 0xa4cf) || (c >= 0xac00 && c <= 0xd7a3) ||
94 (c >= 0xf900 && c <= 0xfaff) || (c >= 0xfe30 && c <= 0xfe4f) || (c >= 0xff00 && c <= 0xff60) ||
95 (c >= 0xffe0 && c <= 0xffe6) || (c >= 0x1f300 && c <= 0x1faff) || (c >= 0x20000 && c <= 0x3fffd) ||
96 (c >= 0x231a && c <= 0x2b55 && EMOJI_WIDE.some(([lo, hi]) => c >= lo && c <= hi))
97 n += wide ? 2 : 1
98 }
99 return n
100}
101
102// Cut text to `max` columns, keeping its end ("…tail") or its start ("head…").
103export const fit = (s: string, max: number, keep: 'end' | 'start' = 'start') => {
104 if (cols(s) <= max) return s
105 const chars = [...s]
106 const out: string[] = []
107 let n = 1 // the ellipsis
108 for (const ch of keep === 'end' ? chars.reverse() : chars) {
109 if (n + cols(ch) > max) break
110 n += cols(ch)
111 out.push(ch)
112 }
113 return keep === 'end' ? `…${out.reverse().join('')}` : `${out.join('')}…`
114}
115
116// A long directory keeps its last segments: "~/Desktop/a/b/project" -> "…/b/project".
117export const shortDir = (dir: string, max = 20) => {
118 if (cols(dir) <= max) return dir
119 const segs = dir.split('/')
120 let tail = segs.pop() ?? ''
121 if (cols(tail) + 2 > max) return fit(tail, max, 'end')
122 while (segs.length && cols(`…/${segs[segs.length - 1]}/${tail}`) <= max) tail = `${segs.pop()}/${tail}`
123 return `…/${tail}`
124}
125
126// Untrusted text from progress files and other mods: no control, escape or bidi characters, bounded length.
127// C0/C1 controls (escape included), bidi marks and isolates, zero-width and line/paragraph separators.
128const UNSAFE = /[\u0000-\u001f\u007f-\u009f\u061c\u200b-\u200f\u2028-\u202e\u2060-\u2069\ufeff]/g
129export const clean = (v: unknown, max: number) =>
130 typeof v === 'string' ? [...v.replace(UNSAFE, '')].slice(0, max).join('').trim() : ''
131
132const ID = /^[A-Za-z0-9._-]{1,64}$/
133const STATE_COLOR = { running: 'claude', ok: 'success', error: 'error' } as const
134const isState = (v: unknown): v is State => typeof v === 'string' && Object.hasOwn(STATE_COLOR, v)
135
136// A progress report as a row, or null when it is not one; `seenAt` is when it was written.
137export const toRow = (raw: unknown, seenAt: number, fallbackId?: string): Row | null => {
138 if (!raw || typeof raw !== 'object') return null
139 const o = raw as Record<string, unknown>
140 const id = typeof o.id === 'string' && ID.test(o.id) ? o.id : fallbackId && ID.test(fallbackId) ? fallbackId : null
141 if (!id) return null
142 const label = fit(clean(o.label, 64), 24) || id.slice(0, 24)
143 const percent = typeof o.percent === 'number' && Number.isFinite(o.percent) ? Math.min(100, Math.max(0, o.percent)) : undefined
144 const text = clean(o.text, 60) || undefined
145 if (percent === undefined && !text) return null
146 const ttl = typeof o.ttl === 'number' && Number.isFinite(o.ttl) ? Math.min(86_400, Math.max(5, o.ttl)) : DEFAULT_TTL
147 const state = isState(o.state) ? o.state : undefined
148 return { id, label, ...(percent !== undefined && { percent }), ...(text && { text }), ...(state && { state }), expiresAt: seenAt + ttl * 1000 }
149}
150
151// Stored prefs over the defaults: a key a later version added keeps its default, a bad value is dropped.
152export const loadPrefs = (stored: unknown): Prefs => {
153 const out: Prefs = { ...DEFAULT_PREFS }
154 if (stored && typeof stored === 'object')
155 for (const [k, v] of Object.entries(stored)) {
156 if (k === 'barWidth' && typeof v === 'number') out.barWidth = Math.min(BAR_MAX, Math.max(BAR_MIN, Math.round(v)))
157 else if ((SWITCHES as string[]).includes(k) && typeof v === 'boolean') out[k as Switch] = v
158 }
159 return out
160}
161
162export const fmtEta = (resetsAt: string | undefined, now: number) => {
163 if (!resetsAt) return ''
164 const s = Math.floor((Date.parse(resetsAt) - now) / 1000)
165 if (!(s > 0)) return 'now'
166 const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60)
167 return d > 0 ? `${d}d${h}h` : `${h}h${m}m`
168}
169
170// A gauge as Claude Code draws one: ▰ used, ▱ left.
171export const gauge = (pct: number, width: number) => {
172 const on = Math.min(width, Math.max(0, Math.round((pct * width) / 100)))
173 return { on: '▰'.repeat(on), off: '▱'.repeat(width - on) }
174}
175const gaugeParts = (pct: number, width: number, color: string): Part[] => {
176 const g = gauge(pct, width)
177 return [{ text: g.on, color }, { text: g.off, color: 'subtle' }]
178}
179
180// "claude-opus-5-5[1m]" -> "Opus 5.5 (1M)", "claude-opus-4-20250514" -> "Opus 4"; anything else as given.
181export const prettyModel = (id: string) => {
182 const m = /^claude-([a-z]+)-(\d{1,2})(?:-(\d{1,2}))?(?:-\d{8})?(\[1m\])?$/.exec(id)
183 if (!m || !m[1]) return id
184 return `${m[1][0]?.toUpperCase()}${m[1].slice(1)} ${m[2]}${m[3] ? `.${m[3]}` : ''}${m[4] ? ' (1M)' : ''}`
185}
186
187// The usage figures, from session.measure's input or $.session.usage() alike.
188export const fromUsage = (u: Pick<SessionUsage, 'context' | 'rateLimits' | 'cost'>): Partial<Figures> => {
189 const find = (kind: string) => {
190 const r = u.rateLimits.find(l => l.kind === kind)
191 return r ? { pct: r.percentUsed, resetsAt: r.resetsAt } : undefined
192 }
193 return {
194 ctxPct: u.context.percent,
195 ctxTokens: u.context.tokens,
196 ctxWindow: u.context.window,
197 fiveHour: find('five_hour'),
198 week: find('seven_day'),
199 costUsd: u.cost?.usd,
200 }
201}
202
203const limit = (label: string, l: Limit | undefined, now: number, showEta: boolean): Part[] => {
204 if (!l) return [{ text: `${label} `, dim: true }, { text: PENDING, dim: true }]
205 const eta = showEta ? fmtEta(l.resetsAt, now) : ''
206 const color = usedColor(l.pct)
207 return [
208 { text: `${label} `, dim: true },
209 ...gaugeParts(l.pct, LIMIT_GAUGE, color),
210 { text: ` ${Math.round(l.pct)}%`, ...(color === 'claude' ? { dim: true } : { color }) },
211 ...(eta ? [{ text: ` ↻${eta}`, dim: true }] : []),
212 ]
213}
214
215const progressLine = (r: Row, barWidth: number): Part[] => {
216 const color = r.state && STATE_COLOR[r.state]
217 // The gauge gives way before ' 100%' would pass the widest card.
218 const width = Math.max(4, Math.min(barWidth, MAX_CARD_WIDTH - cols(r.label) - 1 - 5))
219 return [
220 { text: `${r.label} `, dim: true },
221 ...(r.percent !== undefined ? [...gaugeParts(r.percent, width, color ?? 'claude'), { text: ` ${Math.round(r.percent)}%` }] : []),
222 ...(r.text ? [{ text: r.percent !== undefined ? ` ${r.text}` : r.text, color }] : []),
223 ]
224}
225
226// The button's state as it stands at `now`: an arming or a run past its limit (lost to a reload, or a clock
227// set back) counts as none, so the button never sticks and a lone press never compacts.
228export const compactNow = (c: Compact, now: number): 'armed' | 'running' | null => {
229 if (!c || now < c.at) return null
230 return now - c.at < (c.state === 'armed' ? CONFIRM_MS : COMPACT_MAX_MS) ? c.state : null
231}
232
233// The ⟲ compact button after the context gauge: a press arms it, a second runs /compact.
234const compactParts = (state: 'armed' | 'running' | null): Part[] =>
235 state === 'running' ? [{ text: ' ⟲ compacting…', color: 'claude' }]
236 : state === 'armed' ? [{ text: ' ' }, { text: '⟲ ', color: 'warning' }, { text: 'confirm', press: 'compact' }]
237 : [{ text: ' ' }, { text: '⟲ compact', dim: true, press: 'compact' }]
238
239// "effort ▮▮▮▯▯ high"; a level it does not know, as a word alone.
240const effortLine = (effort: string): Part[] => {
241 const n = EFFORTS.indexOf(effort) + 1
242 return [
243 { text: 'effort ', dim: true },
244 ...(n ? [{ text: `${'▮'.repeat(n)}${'▯'.repeat(EFFORTS.length - n)} `, color: 'claude' }] : []),
245 { text: effort, color: 'claude', bold: true },
246 ]
247}
248
249// Pure: the card's lines, each a run of colored parts, as the prefs pick them.
250export const cardLines = (f: Figures, now: number, p: Prefs = DEFAULT_PREFS, compact: Compact = null): Part[][] => {
251 const sep: Part = { text: ' · ', dim: true }
252 const lines: Part[][] = []
253 if (p.model) {
254 lines.push([{ text: f.model ? prettyModel(f.model) : PENDING, color: 'claude', bold: true }])
255 if (f.effort) lines.push(effortLine(f.effort))
256 }
257 if (p.ctx) {
258 if (f.ctxPct !== undefined) {
259 const k = (n: number) => (n >= 1_000_000 ? `${+(n / 1_000_000).toFixed(1)}M` : `${Math.round(n / 1000)}k`)
260 const tokens = f.ctxTokens !== undefined && f.ctxWindow ? ` ${k(f.ctxTokens)}/${k(f.ctxWindow)}` : ''
261 const ctx: Part[] = [{ text: 'ctx ', dim: true }, ...gaugeParts(f.ctxPct, p.barWidth, ctxColor(f.ctxPct)), { text: ` ${Math.round(f.ctxPct)}%`, bold: true }]
262 const button = compactParts(compactNow(compact, now))
263 // The button stays whole: past the widest card, the tokens give way.
264 const withTokens = [...ctx, { text: tokens, dim: true }, ...button]
265 lines.push(lineWidth(withTokens) <= MAX_CARD_WIDTH ? withTokens : [...ctx, ...button])
266 } else lines.push([{ text: 'ctx ', dim: true }, { text: gauge(0, p.barWidth).off, color: 'subtle' }, { text: ` ${PENDING}`, dim: true }])
267 }
268 if (p.limits) lines.push([...limit('5h', f.fiveHour, now, p.eta), { text: ' ' }, ...limit('7d', f.week, now, p.eta)])
269 const place: Part[][] = [
270 ...(p.location ? [[{ text: f.dir ? shortDir(f.dir) : PENDING }]] : []),
271 ...(p.location && f.branch ? [[{ text: `⎇ ${fit(f.branch, 16)}` }]] : []),
272 ...(p.cost && f.costUsd !== undefined ? [[{ text: `$${f.costUsd.toFixed(2)}` }]] : []),
273 ]
274 if (place.length) lines.push(place.flatMap((parts, n) => (n > 0 ? [sep, ...parts] : parts)))
275 if (p.progress)
276 for (const r of f.progress.filter(r => r.expiresAt > now).slice(0, MAX_PROGRESS_ROWS)) lines.push(progressLine(r, p.barWidth))
277 return lines
278}
279
280export const lineWidth = (parts: Part[]) => parts.reduce((n, p) => n + cols(p.text), 0)
281
282// The card's inner width and where the buttons go: beside the first line, or on a row of their own
283// above it when that line and the buttons together would pass the widest card.
284export const layout = (lines: Part[][]) => {
285 const first = lineWidth(lines[0] ?? [])
286 const buttonsOwnRow = lines.length > 0 && first + BUTTONS_WIDTH > MAX_CARD_WIDTH
287 const width = Math.min(MAX_CARD_WIDTH, Math.max(CARD_WIDTH, ...lines.map(lineWidth), buttonsOwnRow ? BUTTONS_WIDTH : first + BUTTONS_WIDTH))
288 return { width, buttonsOwnRow }
289}
290export const cardWidth = (lines: Part[][]) => layout(lines).width
291
292type Entry = { name: string; kind: string; size: number; mtimeMs: number }
293
294// The progress files worth reading: *.json, not oversized, newest first, at most MAX_PROGRESS_FILES.
295export const pickFiles = <E extends Entry>(entries: readonly E[]): E[] =>
296 entries
297 .filter(f => f.kind === 'file' && f.name.endsWith('.json') && f.size <= MAX_PROGRESS_BYTES)
298 .sort((a, b) => b.mtimeMs - a.mtimeMs || a.name.localeCompare(b.name))
299 .slice(0, MAX_PROGRESS_FILES)
300
301// Live rows from both sources (a mod's report wins over a file of the same id), by id, at most MAX_PROGRESS_ROWS.
302export const mergeRows = (fileRows: readonly Row[], apiRows: readonly Row[], now: number): Row[] => {
303 const rows = new Map<string, Row>()
304 for (const r of [...fileRows, ...apiRows]) if (r.expiresAt > now) rows.set(r.id, r)
305 return [...rows.values()].sort((a, b) => a.id.localeCompare(b.id)).slice(0, MAX_PROGRESS_ROWS)
306}
307
308// The progress folder: STATUSPANE_PROGRESS_DIR (absolute, or starting with ~), else ~/.claude/statuspane/progress.
309export const resolveDir = (custom: string | undefined, home: string | undefined) => {
310 const expand = (p: string) => (p === '~' || p.startsWith('~/') ? (home ? `${home}${p.slice(1)}` : null) : p)
311 const isAbsolute = (p: string) => p.startsWith('/') || /^[A-Za-z]:[\\/]/.test(p) || p.startsWith('\\\\')
312 const dir = custom ? expand(custom) : null
313 if (dir && isAbsolute(dir)) return dir
314 return home ? `${home}/${PROGRESS_DIR_DEFAULT}` : null
315}
316
317// Rows other mods reported through $.statuspane, stamped with an expiry on the next poll.
318// lazy: module state, so a reload of this mod forgets them until their sources report again.
319const apiReports = new Map<string, { raw: unknown; row: Row | null }>()
320const fileCache = new Map<string, { mtimeMs: number; size: number; row: Row | null }>()
321// What the band last drew at, so /statuspane can say when the card cannot show.
322let lastBand = { columns: Infinity, hasSurvey: false }
323
324// One set of prefs for every session: the store holds it, each press re-reads it before changing one key,
325// and each session polls it. Reads and presses take turns, so a poll never puts back a value a press replaced.
326let prefsTurns: Promise<unknown> = Promise.resolve()
327const prefsTurn = (job: () => Promise<unknown>) => (prefsTurns = prefsTurns.then(job).catch(() => undefined))
328
329const syncPrefs = ($: EngineInterface) =>
330 prefsTurn(async () => {
331 const p = loadPrefs(await $.store.get('prefs'))
332 if (JSON.stringify(p) !== JSON.stringify(await read($, prefs))) await update($, prefs, () => p)
333 })
334
335const changePrefs = ($: EngineInterface, fn: (q: Prefs) => Prefs) =>
336 prefsTurn(async () => {
337 const p = fn(loadPrefs(await $.store.get('prefs')))
338 await $.store.set('prefs', p)
339 await update($, prefs, () => p)
340 })
341
342async function progressDir($: EngineInterface) {
343 const home = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE'))
344 return resolveDir(await $.env.get('STATUSPANE_PROGRESS_DIR'), home)
345}
346
347// Never rejects: a missing, unreadable or vanishing folder just means no file rows this poll.
348async function readProgress($: EngineInterface) {
349 try {
350 const now = await $.clock.now()
351 const fileRows: Row[] = []
352 const dir = await progressDir($)
353 const exists = dir ? await $.fs.exists(dir).catch(() => false) : false // a missing folder is the usual case: no log line each second
354 const entries = dir && exists ? await $.fs.list(dir).catch(() => []) : []
355 const files = pickFiles(entries)
356 for (const f of files) {
357 let hit = fileCache.get(f.name)
358 if (!hit || hit.mtimeMs !== f.mtimeMs || hit.size !== f.size) {
359 const raw = await $.fs.read(`${dir}/${f.name}`).then(t => JSON.parse(t) as unknown).catch(() => null)
360 hit = { mtimeMs: f.mtimeMs, size: f.size, row: toRow(raw, f.mtimeMs, f.name.slice(0, -'.json'.length)) }
361 fileCache.set(f.name, hit)
362 }
363 if (hit.row) fileRows.push(hit.row)
364 }
365 for (const name of fileCache.keys()) if (!files.some(f => f.name === name)) fileCache.delete(name)
366 const apiRows: Row[] = []
367 for (const [id, report] of apiReports) {
368 report.row ??= toRow(report.raw, now, id)
369 if (report.row && report.row.expiresAt > now) apiRows.push(report.row)
370 else apiReports.delete(id)
371 }
372 // CI rows keep time between polls: their text is re-made from the clock each second.
373 const ci = [...ciRows].map(([k, r]) => {
374 const c = ciClocks.get(k)
375 return c ? { ...r, text: clean(ciLine(c, now), 60) } : r
376 })
377 const progress = mergeRows(fileRows, [...apiRows, ...ci], now)
378 const before = (await read($, figures)).progress
379 if (JSON.stringify(before) !== JSON.stringify(progress)) await update($, figures, f => ({ ...f, progress }))
380 } catch {
381 // lazy: swallowed silently; a debug line would help a user whose rows never show.
382 }
383}
384
385// Never rejects; runs off the turn's path (callers do not await it).
386// ⟲ compact: the first press arms it for CONFIRM_MS, the second runs /compact as if typed (queued until the
387// turn ends, when Claude is working). Clearing the context gauge is the session.compact hook's. One press is
388// handled at a time, so a double press never compacts twice.
389let compactBusy = false
390async function pressCompact($: EngineInterface) {
391 if (compactBusy) return
392 compactBusy = true
393 try {
394 const now = await $.clock.now()
395 const state = compactNow(await read($, compactAsk), now)
396 if (state === 'running') return
397 if (state !== 'armed') {
398 await update($, compactAsk, () => ({ state: 'armed', at: now }))
399 // Redraws the button once the arming lapses; were this timer lost, the arming still reads as lapsed.
400 $.clock.after(CONFIRM_MS, () => void update($, compactAsk, c => (c?.state === 'armed' && c.at === now ? null : c)))
401 return
402 }
403 await update($, compactAsk, () => ({ state: 'running', at: now }))
404 try {
405 await $.command.run({ command: 'compact', args: '' })
406 } catch (err) {
407 $.ui.toast(`Could not compact: ${clean(err instanceof Error ? err.message : String(err), 120)}`)
408 } finally {
409 await update($, compactAsk, () => null)
410 }
411 } finally {
412 compactBusy = false
413 }
414}
415
416async function refresh($: EngineInterface) {
417 try {
418 const home = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE'))
419 const cwd = await $.session.cwd()
420 const dir = clean(home && (cwd === home || cwd.startsWith(`${home}/`)) ? `~${cwd.slice(home.length)}` : cwd, 400)
421 const repo = await $.session.repo()
422 const b = repo ? await $.process.run(['git', 'branch', '--show-current'], { cwd, timeoutMs: 3000 }).catch(() => null) : null
423 const branch = (b && b.exitCode === 0 && clean(b.stdout, 200)) || null
424 await update($, figures, f => ({ ...f, dir, branch }))
425 const repoSlug = branch ? await repoHere($) : null
426 ciHere = repoSlug && branch ? { repo: repoSlug, branch } : null
427 } catch {
428 // keep the last directory and branch
429 }
430}
431
432// ---- CI: GitHub Actions runs, read through `gh` ----
433
434export type Run = {
435 databaseId: number; status: string; conclusion: string; workflowName: string
436 headSha: string; createdAt: string; updatedAt: string; event: string
437}
438export type Job = { name: string; status: string; conclusion: string }
439type Target = { repo: string; branch: string }
440type Watch = Target & { since: number; endsAt: number; finishedAt?: number }
441
442const RUN_FIELDS = 'databaseId,status,conclusion,workflowName,headSha,createdAt,updatedAt,event'
443const FAILED = new Set(['failure', 'timed_out', 'startup_failure', 'action_required'])
444const isDeploy = (job: string) => /deploy/i.test(job)
445
446// "1m20s" for a run under way, "3m" for how long ago one finished.
447export const fmtSpan = (ms: number, precise: boolean) => {
448 const s = Math.max(0, Math.floor(ms / 1000)), m = Math.floor(s / 60), h = Math.floor(m / 60), d = Math.floor(h / 24)
449 if (s < 60) return `${s}s`
450 if (m < 60) return precise ? `${m}m${String(s % 60).padStart(2, '0')}s` : `${m}m`
451 if (h < 24) return precise ? `${h}h${String(m % 60).padStart(2, '0')}m` : `${h}h`
452 return `${d}d`
453}
454
455// "owner/repo" for a github.com remote, "host/owner/repo" for another host; null when it is not one.
456export const repoSlug = (url: string) => {
457 const m = /^(?:https?:\/\/|ssh:\/\/)?(?:[^@/\s]+@)?([^/:\s]+)[:/]([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/.exec(url.trim())
458 if (!m) return null
459 const [, host, owner, name] = m
460 return host === 'github.com' ? `${owner}/${name}` : `${host}/${owner}/${name}`
461}
462
463// The refs a `git push` updated, from its report: "abc..def main -> main", "* [new branch] x -> x".
464export const pushedRefs = (output: string) =>
465 // [ \t], never \s: \s spans lines, and on output of blank lines the pattern backtracks for minutes.
466 [...output.matchAll(/^[ \t]*[+*]?[ \t]*(?:[0-9a-f]{4,}\.\.\.?[0-9a-f]{4,}|\[new (?:branch|tag)\])[ \t]+\S+[ \t]+->[ \t]+(\S+)/gm)].map(m => m[1] as string)
467
468// The commit's runs on a branch: the newest commit's, scheduled and bot-dispatched runs left out.
469// lazy: looks at the 40 newest runs; a branch where schedules crowd out a push needs a per-event query.
470export const latestGroup = (runs: readonly Run[]) => {
471 const own = runs.filter(r => r.event !== 'schedule' && r.event !== 'dynamic')
472 const sha = own[0]?.headSha
473 return own.filter(r => r.headSha === sha)
474}
475
476// A CI row's text from its clock: "⟳ deploying · 1m20s" (time under way) or "✓ deployed · 3m ago".
477// Re-made each second from the clock, so the time moves between polls of GitHub.
478export type CiClock = { head: string; at: number; running: boolean }
479export const ciLine = (c: CiClock, now: number) => `${c.head} · ${fmtSpan(now - c.at, c.running)}${c.running ? '' : ' ago'}`
480
481// Pure: one commit's runs, and their jobs, as a row's text, state and clock.
482export const ciSummary = (runs: readonly Run[], jobs: readonly Job[], now: number): { text: string; state?: State; busy: boolean; clock: CiClock } => {
483 if (runs.some(r => r.status !== 'completed')) {
484 const active = jobs.filter(j => j.status === 'in_progress').map(j => j.name)
485 const what = active.some(isDeploy) ? 'deploying' : active.join(', ') || 'queued'
486 const clock = { head: `⟳ ${what}`, at: Math.min(...runs.map(r => Date.parse(r.createdAt))), running: true }
487 return { text: ciLine(clock, now), state: 'running', busy: true, clock }
488 }
489 const done = (head: string, state?: State) => {
490 const clock = { head, at: Math.max(...runs.map(r => Date.parse(r.updatedAt))), running: false }
491 return { text: ciLine(clock, now), ...(state && { state }), busy: false, clock }
492 }
493 const failed = jobs.find(j => FAILED.has(j.conclusion))?.name ?? runs.find(r => FAILED.has(r.conclusion))?.workflowName
494 if (failed) return done(`✗ ${failed} failed`, 'error')
495 if (runs.every(r => r.conclusion === 'cancelled' || r.conclusion === 'skipped')) return done('⊘ cancelled')
496 const deployed = jobs.some(j => isDeploy(j.name) && j.conclusion === 'success')
497 return done(`✓ ${deployed ? 'deployed' : 'passed'}`, 'ok')
498}
499
500const ciKey = (t: Target) => `ci.${t.repo}.${t.branch}`.replace(/[^A-Za-z0-9._-]/g, '-').slice(0, 64)
501
502// The session's repo and branch (refresh sets it), pushes being followed, and the rows they draw.
503let ciHere: Target | null = null
504const ciWatches = new Map<string, Watch>()
505const ciRows = new Map<string, Row>()
506const ciClocks = new Map<string, CiClock>() // each row's clock, so its time moves every second
507const ciDue = new Map<string, number>()
508const jobsCache = new Map<string, Job[]>() // finished runs only, by id and update time
509let ciBusy = false
510
511// `gh … --json` as parsed JSON; null when gh is missing, signed out, offline or the repo is not on GitHub.
512async function gh($: EngineInterface, args: string[]) {
513 const r = await $.process.run(['gh', ...args], { timeoutMs: 15_000 }).catch(() => null)
514 if (!r || r.exitCode !== 0) return null
515 try {
516 return JSON.parse(r.stdout) as unknown
517 } catch {
518 return null
519 }
520}
521
522async function repoHere($: EngineInterface) {
523 const r = await $.process.run(['git', 'remote', 'get-url', 'origin'], { timeoutMs: 3000 }).catch(() => null)
524 return r && r.exitCode === 0 ? repoSlug(r.stdout) : null
525}
526
527async function runJobs($: EngineInterface, repo: string, runs: readonly Run[]) {
528 const jobs: Job[] = []
529 for (const run of runs) {
530 const key = `${run.databaseId}:${run.updatedAt}`
531 let got = run.status === 'completed' ? jobsCache.get(key) : undefined
532 if (!got) {
533 const view = (await gh($, ['run', 'view', String(run.databaseId), '-R', repo, '--json', 'jobs'])) as { jobs?: Job[] } | null
534 got = Array.isArray(view?.jobs) ? view.jobs : []
535 if (run.status === 'completed' && view) {
536 if (jobsCache.size >= 50) jobsCache.delete(jobsCache.keys().next().value as string)
537 jobsCache.set(key, got)
538 }
539 }
540 jobs.push(...got)
541 }
542 return jobs
543}
544
545// Never rejects. Each target is polled when due: the branch the session is on (`ciBranch`) and the
546// branches a push or merge in the session set off (`ciPush`), until their runs finish and linger.
547async function pollCi($: EngineInterface) {
548 if (ciBusy) return
549 ciBusy = true
550 try {
551 const now = await $.clock.now()
552 const p = await read($, prefs)
553 if (!p.ciPush) ciWatches.clear()
554 for (const [k, w] of ciWatches) if (w.endsAt <= now) ciWatches.delete(k)
555 const here = p.ciBranch && ciHere ? ciHere : null
556 const hereKey = here ? ciKey(here) : null
557 const targets = new Map<string, Target>(ciWatches)
558 if (here && hereKey) targets.set(hereKey, here)
559 for (const k of [...ciRows.keys()]) if (!targets.has(k)) ciRows.delete(k)
560 for (const k of [...ciClocks.keys()]) if (!ciRows.has(k)) ciClocks.delete(k)
561 for (const [k, t] of targets) {
562 if ((ciDue.get(k) ?? 0) > now) continue
563 const w = ciWatches.get(k)
564 const listed = await gh($, ['run', 'list', '-R', t.repo, '--branch', t.branch, '-L', '40', '--json', RUN_FIELDS])
565 if (!Array.isArray(listed)) {
566 ciDue.set(k, now + CI_IDLE_MS)
567 continue
568 }
569 const runs = latestGroup(listed as Run[])
570 const fresh = !!w && runs.some(r => Date.parse(r.createdAt) >= w.since - CI_SKEW_MS)
571 if (!runs.length || (k !== hereKey && !fresh)) {
572 ciRows.delete(k) // a push whose runs have not shown yet: nothing until they do
573 ciClocks.delete(k)
574 ciDue.set(k, now + (w ? CI_BUSY_MS : CI_IDLE_MS))
575 continue
576 }
577 const s = ciSummary(runs, await runJobs($, t.repo, runs), now)
578 if (w && fresh) w.endsAt = s.busy ? now + CI_LINGER_MS : (w.finishedAt ??= now) + CI_LINGER_MS
579 ciDue.set(k, now + (s.busy || (w && !fresh) ? CI_BUSY_MS : CI_IDLE_MS))
580 const label = fit(`${t.repo.split('/').pop()} ${t.branch}`, 24)
581 const row = toRow({ label, text: s.text, state: s.state, ttl: CI_LINGER_MS / 1000 }, now, k)
582 if (row) {
583 ciRows.set(k, row)
584 ciClocks.set(k, s.clock)
585 }
586 }
587 } catch {
588 // keep the last rows; they expire on their own if gh stays unreachable
589 } finally {
590 ciBusy = false
591 }
592}
593
594// A Bash call that pushed or merged: follow the branches it set off. Never rejects.
595async function followPush($: EngineInterface, command: string, output: string) {
596 try {
597 if (!(await read($, prefs)).ciPush) return
598 const url = /github\.com\/([^/\s]+\/[^/\s]+)\/pull\/(\d+)/.exec(command)
599 // The repo the push reported ("To github.com:o/r.git"), not the session's folder: a push may run elsewhere.
600 const pushedTo = /^To (\S+)/m.exec(output)?.[1]
601 const repo =
602 url?.[1] ?? /(?:^|\s)(?:-R|--repo)[=\s]+(\S+)/.exec(command)?.[1] ?? (pushedTo && repoSlug(pushedTo)) ?? (await repoHere($))
603 if (!repo) return
604 const branches = /\bgit\b[^;&|]*\bpush\b/.test(command) ? pushedRefs(output) : []
605 if (/\bgh\s+pr\s+merge\b/.test(command)) {
606 const pr = url?.[2] ?? /\bgh\s+pr\s+merge\s+#?(\d+)/.exec(command)?.[1] ?? /#(\d+)/.exec(output)?.[1]
607 const view = pr ? ((await gh($, ['pr', 'view', pr, '-R', repo, '--json', 'baseRefName'])) as { baseRefName?: string } | null) : null
608 // lazy: an --auto merge lands after its checks, often past CI_WAIT_MS; then only ciBranch shows it.
609 if (typeof view?.baseRefName === 'string') branches.push(view.baseRefName)
610 }
611 const now = await $.clock.now()
612 for (const branch of new Set(branches)) {
613 const k = ciKey({ repo, branch })
614 ciWatches.set(k, { repo, branch, since: now, endsAt: now + CI_WAIT_MS })
615 ciDue.delete(k)
616 }
617 } catch {
618 // nothing followed
619 }
620}
621
622export const register: Register = on => {
623 // $.statuspane for other mods: progress({ id, label, percent?, text?, ttl? }) and clear(id).
624 on('engine.create', async (_, e, next) => {
625 const built = await next(e)
626 const statuspane: Statuspane = {
627 progress: async item => {
628 const id = typeof item?.id === 'string' ? item.id : ''
629 if (!ID.test(id)) return
630 apiReports.delete(id) // re-insert last, so the oldest report is the one that goes at the cap
631 if (apiReports.size >= MAX_API_REPORTS) apiReports.delete(apiReports.keys().next().value as string)
632 apiReports.set(id, { raw: item, row: null })
633 },
634 clear: async id => {
635 apiReports.delete(String(id))
636 },
637 }
638 return { ...built, statuspane }
639 })
640
641 on('session.start', async ($, e, next) => {
642 await $.command.register({ name: 'statuspane', description: 'Hide or show the status card' })
643 await update($, compactAsk, () => null) // a press or a run the last module left behind (state outlives a reload)
644 await syncPrefs($)
645 const model = await $.session.model()
646 const usage = fromUsage(await $.session.usage())
647 await update($, figures, f => ({ ...f, ...usage, model: model || null }))
648 $.clock.every(30_000, () => void refresh($))
649 $.clock.every(PROGRESS_POLL_MS, () => void readProgress($))
650 $.clock.every(PREFS_POLL_MS, () => void syncPrefs($))
651 $.clock.every(CI_TICK_MS, () => void pollCi($))
652 void refresh($).then(() => pollCi($))
653 void readProgress($)
654 return next(e)
655 })
656
657 on('command.run', { command: 'statuspane' }, async $ => {
658 const hidden = await update($, isHidden, h => !h)
659 if (hidden) return { text: STRINGS.hidden }
660 return { text: lastBand.columns < MIN_COLUMNS ? STRINGS.tooNarrow : STRINGS.shown }
661 })
662
663 on('turn.complete', async ($, e, next) => {
664 const result = await next(e)
665 if (!e.agentId) void refresh($) // the branch may have moved; subagents' turns leave it be
666 return result
667 })
668
669 // A push or merge Claude ran: its CI runs show until they finish (the `ciPush` switch).
670 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
671 const result = await next(e)
672 if (e.tool === 'Bash' && /\bgit\b[^;&|]*\bpush\b|\bgh\s+pr\s+merge\b/.test(e.command)) {
673 const out = (result as { result?: { stdout?: unknown; stderr?: unknown } }).result
674 void followPush($, e.command, `${out?.stdout ?? ''}\n${out?.stderr ?? ''}`).then(() => pollCi($))
675 }
676 return result
677 })
678
679 on('turn.step', async function* ($, e, next) {
680 if (!e.agentId) {
681 const effort = e.effort === undefined ? null : String(e.effort)
682 await update($, figures, f => ({ ...f, model: e.model, effort }))
683 }
684 return yield* next(e)
685 })
686
687 on('session.measure', async ($, e, next) => {
688 const usage = fromUsage(e)
689 await update($, figures, f => ({ ...f, ...usage }))
690 return next(e)
691 })
692
693 // A compaction empties the live window, and no measurement follows until the next response.
694 on('session.compact', async ($, e, next) => {
695 const result = await next(e)
696 if (!e.agentId && e.trigger !== 'precompute' && !result.skip)
697 await update($, figures, f => ({ ...f, ctxPct: undefined, ctxTokens: undefined }))
698 return result
699 })
700
701 // The band's rows hold the card at the right edge, just above the prompt.
702 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
703 // The desktop app shows model, effort and context on its own, so the card is terminal-only there.
704 if (e.surface === 'desktop') return next(e)
705 lastBand = { columns: e.props.bodyColumns, hasSurvey: e.props.hasSurvey }
706 if (e.props.hasSurvey || e.props.bodyColumns < MIN_COLUMNS) return next(e)
707 const { Box, Button, Text } = $.ui.resolve(e)
708 // What the plugins beneath drew here stays, above the card. Never pass the band itself: a band handed
709 // back is not drawn again when the card's state changes.
710 const below = await next(e).catch(() => null)
711 const withBelow = (ours: RenderElement): RenderElement => (below ? <Box flexDirection="column">{below}{ours}</Box> : ours)
712 const p = await read($, prefs)
713 const setHidden = (hidden: boolean) => update($, isHidden, () => hidden)
714 const setSettingsOpen = (open: boolean) => update($, isSettingsOpen, () => open)
715 const change = (fn: (q: Prefs) => Prefs) => changePrefs($, fn)
716 // Hidden: a one-row button stays at the right edge.
717 if (await read($, isHidden))
718 return withBelow(
719 <Box justifyContent="flex-end" paddingRight={HIDDEN_RIGHT_PAD}>
720 <Button key="show" label={STRINGS.show} dimColor onPress={() => setHidden(false)} />
721 </Box>,
722 )
723 const frame = (width: number, children: RenderChildren[]) =>
724 withBelow(
725 <Box justifyContent="flex-end" paddingRight={1}>
726 <Box width={width + 4} flexDirection="column" borderStyle="round" borderColor="claude" paddingX={1}>
727 {children}
728 </Box>
729 </Box>,
730 )
731
732 if (await read($, isSettingsOpen))
733 return frame(CARD_WIDTH, [
734 <Text color="claude" bold>{STRINGS.settings}</Text>,
735 SWITCHES.map(k => (
736 <Button key={`pref-${k}`} label={`${p[k] ? '☑' : '☐'} ${STRINGS[k]}`} plain onPress={() => change(q => ({ ...q, [k]: !q[k] }))} />
737 )),
738 <Box>
739 <Text>{STRINGS.bar} </Text>
740 <Button key="bar-minus" label="-" onPress={() => change(q => ({ ...q, barWidth: Math.max(BAR_MIN, q.barWidth - 2) }))} />
741 <Text> {String(p.barWidth).padStart(2)} </Text>
742 <Button key="bar-plus" label="+" onPress={() => change(q => ({ ...q, barWidth: Math.min(BAR_MAX, q.barWidth + 2) }))} />
743 </Box>,
744 <Box justifyContent="flex-end">
745 <Button key="settings-close" label={STRINGS.done} variant="primary" onPress={() => setSettingsOpen(false)} />
746 </Box>,
747 ])
748
749 const lines = cardLines(await read($, figures), await $.clock.now(), p, await read($, compactAsk))
750 const { width, buttonsOwnRow } = layout(lines)
751 const runs = (parts: Part[]) => parts.map(q => <Text color={q.color} dimColor={q.dim} bold={q.bold}>{q.text}</Text>)
752 const pad = (parts: Part[], room: number) => ' '.repeat(Math.max(0, room - lineWidth(parts)))
753 // A line holding a button is a row of its own; the others one Text, cut at the card's edge.
754 const row = (parts: Part[], room: number) =>
755 parts.some(q => q.press) ? (
756 <Box key="ctx-line">
757 {parts.map(q =>
758 q.press ? (
759 <Button key={q.press} label={q.text} plain dimColor={q.dim} hover={{ color: 'claude' }} onPress={() => pressCompact($)} />
760 ) : (
761 <Text color={q.color} dimColor={q.dim} bold={q.bold}>{q.text}</Text>
762 ),
763 )}
764 <Text>{pad(parts, room)}</Text>
765 </Box>
766 ) : (
767 <Text wrap="truncate-end">
768 {runs(parts)}
769 {pad(parts, room)}
770 </Text>
771 )
772 // Whether Claude is working, as the reference's main box says it, then the buttons.
773 const working = e.props.isWorking
774 const buttons = [
775 <Text color={working ? 'claude' : undefined} dimColor={!working}>{(working ? HEAD_RIGHT : '○ idle').padStart(HEAD_RIGHT.length)}</Text>,
776 <Text> </Text>,
777 <Button key="settings" label=" ⚙ " plain dimColor hover={{ color: 'claude' }} onPress={() => setSettingsOpen(true)} />,
778 <Text> </Text>,
779 <Button key="hide" label={STRINGS.hide} plain dimColor hover={{ color: 'claude' }} onPress={() => setHidden(true)} />,
780 ]
781 const first = lines[0] ?? []
782 const top = buttonsOwnRow ? (
783 <Box key="head" justifyContent="flex-end">{buttons}</Box>
784 ) : (
785 <Box key="head">
786 {row(first, width - BUTTONS_WIDTH)}
787 {buttons}
788 </Box>
789 )
790 const rest = buttonsOwnRow ? lines : lines.slice(1)
791
792 return frame(width, [top, rest.map(parts => row(parts, width))])
793 })
794}
795types/index.d.ts 81 lines1/** The ⟲ compact button: pressed once (armed) or compacting, and since when. */
2export type StatuspaneCompact = { state: 'armed' | 'running'; at: number } | null
3
4/** How a row is going, which colors it: running Claude's accent, ok green, error red. */
5export type StatuspaneState = 'running' | 'ok' | 'error'
6
7/**
8 * One progress row on the card, from a progress file or from another mod's
9 * `$.statuspane.progress(...)` call.
10 */
11export type StatuspaneProgress = {
12 /** Unique per source; a later report under the same id replaces the row. */
13 id: string
14 /** Short name shown before the bar (cut to 24 characters). */
15 label: string
16 /** 0–100; leave it out for a text-only row. */
17 percent?: number
18 /** Detail after the bar, e.g. "ch12/30 · 3.1/min" (cut to 60 characters). */
19 text?: string
20 /** Seconds the row stays without a fresh report (default 300). */
21 ttl?: number
22 /** Colors the gauge and the text; leave it out for a gauge in Claude's accent and plain text. */
23 state?: StatuspaneState
24}
25
26/** What other mods call: `$.statuspane.progress({...})`, `$.statuspane.clear(id)`. */
27export type Statuspane = {
28 progress: (item: StatuspaneProgress) => Promise<void>
29 clear: (id: string) => Promise<void>
30}
31
32export type StatuspaneLimit = { pct: number; resetsAt?: string }
33
34/** A progress row as the card draws it: sanitized, with its expiry. */
35export type StatuspaneRow = { id: string; label: string; percent?: number; text?: string; state?: StatuspaneState; expiresAt: number }
36
37export type StatuspaneFigures = {
38 dir: string | null
39 branch: string | null
40 model: string | null
41 effort: string | null
42 ctxPct?: number
43 ctxTokens?: number
44 ctxWindow?: number
45 fiveHour?: StatuspaneLimit
46 week?: StatuspaneLimit
47 costUsd?: number
48 progress: StatuspaneRow[]
49}
50
51export type StatuspanePrefs = {
52 model: boolean
53 ctx: boolean
54 limits: boolean
55 eta: boolean
56 location: boolean
57 cost: boolean
58 progress: boolean
59 /** The current branch's latest CI runs on GitHub, through `gh`. */
60 ciBranch: boolean
61 /** The runs a `git push` or `gh pr merge` in the session set off, until they finish. */
62 ciPush: boolean
63 barWidth: number
64}
65
66declare module 'claude-code' {
67 interface EngineInterface {
68 statuspane: Statuspane
69 }
70 interface PluginState {
71 statuspane: {
72 figures: StatuspaneFigures
73 isHidden: boolean
74 prefs: StatuspanePrefs
75 isSettingsOpen: boolean
76 /** The ⟲ compact button: pressed once and waiting for the confirming press, or compacting. */
77 compact: StatuspaneCompact
78 }
79 }
80}
81