SLOPSHOPPER

statuspane

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

newbandguardcommandtoastprocess
★ 7v1.3.3MITupdated 2026-10-08xuanji86/claude-statuspane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · statuspane
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /statuspane ⎿ statuspane: Status card hidden. ⟨Claude Code's own drawing⟩ [ ◂ status ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ [ ◂ status ]
README

statuspane

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.

Version Claude Code mod License: MIT validates

English · 中文

<img src="assets/card.svg" alt="The status card above the Claude Code prompt" width="720">

Why

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.

Features

Model · effortThe model in use, whether Claude is working, and its reasoning effort on a gauge (▮▮▮▯▯ high)
ContextA gauge with tokens used / window (▰▰▰▱▱▱ 42% 222k/1M), and ⟲ compact: press, then confirm, to run /compact (after the turn, if Claude is working)
Rate limits5-hour and 7-day use, each on a small gauge, with reset countdowns (↻2h41m)
Where you areDirectory and git branch, shortened so the card stays narrow
Session costWhat this session has cost so far
GitHub CIThe branch's latest Actions run and the runs a push sets off, deploys included (GitHub CI)
Progress rowsBars any script or mod can feed, refreshed every second (Progress API)
ClickableHide, show and settings are buttons; everything works with the mouse
SettingsPick 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 %).

Install

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.

Use

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

GitHub CI

Two switches on the settings page, both off until you turn them on. They need the GitHub CLI signed in (gh auth login).

SwitchShows
CI · this branchThe latest Actions run of the branch the session is on: checked every minute, every 10 seconds while it runs
CI · after a pushWhen 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 · 1m20saccent: under way, with the jobs running now and the time so far (ticks every second between checks)
⟳ deploying · 1m20sa job whose name has deploy in it is running
✓ deployed · 3m agogreen: done, and a deploy job succeeded (✓ passed when none ran)
✗ test failed · 3m agored: the job (or workflow) that failed
⊘ cancelled · 3m agoevery run was cancelled or skipped

All of one commit's workflows make one row; scheduled runs are left out.

Progress API

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.

From any script: a JSON file

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" }
FieldType
labelstringShown before the bar, up to 24 characters (defaults to the id)
percentnumber, optional0–100. Leave it out for a text-only row
textstring, optionalShown after the bar, up to 60 characters
ttlseconds, optionalHow long the row stays after the file was last written (default 300)
statestring, optionalrunning (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")

From another mod: $.statuspane

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

Already reporting

  • ainiee-translate v1.14+ — progress --watch / --line show translation progress.

Privacy and safety

Its footprint, as the awesome-claude-code-mods scan reads it from claude plugin validate:

reach

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.

Develop

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.

License

MIT © Anji Xu

Source 2 files
hooks/register.tsx 795 lines
1import { 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}
795
types/index.d.ts 81 lines
1/** 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