A pill and a pane showing the main session and every subagent with model, effort, context fill, tokens, time and gate progress, plus the background shells and…

<!-- mod-progress / README.md, by v-amorim --> <!-- Generated from the root README, plugin.json and showcase.json; edit those. -->
Part of [moonlight-claude-mods][doc-root].
The main session, every subagent and every agent of a workflow run: model, effort, context fill, tokens, time and gate progress, plus the background shells and monitors each one started, with their live output.
Inside Claude Code:
/plugin marketplace add v-amorim/moonlight-claude-mods
/plugin install mod-progress@claude-mods
/reload-plugins
/plugin install asks where to install the mod. Keep Install for you (user scope) and press Enter, then press Esc to leave the plugin screen before /reload-plugins.
To install every mod at once, see [Install][doc-install]. For sharper icons, see [Icons and Nerd Fonts][doc-icons].
Progress pill.
| The pill | The panel | A shell's command and output |
|---|---|---|
| ![][img-progress-pill] | ![][img-progress-panel] | ![][img-progress-shell] |
| An agent's transcript |
|---|
| ![][img-progress-transcript] |
| Two agents running, opened from the pill |
|---|
| https://github.com/user-attachments/assets/248259f2-7cc2-4b5c-b237-a68fc09490c4 |
GATES.md files written by your own script or tool, not from the model. The rest of the mod works without them.GATES.md plus every gates/<name>.md in ~/.claude/gates/<repo>/<session id>/, where <repo> is the folder name of the session's repo (or of its working folder). Set GATES_HOME to read <GATES_HOME>/<repo>/<session id>/ instead. A gate is a line - [ ] G1: what must hold, with an indented EVIDENCE: line under it. It counts as met only when its box is [x] and its evidence is not pending. A line starting ABANDON: G1 reason gives that gate up. A gates/<name>.md file also belongs to the agent of that name.~/.claude/mods-data/mod-progress/bridge/<session id>.jsonl (under $CLAUDE_CONFIG_DIR when that is set), and the mod reads the file every 250 ms. A record can hold the session id, the working folder and the transcript path, and for Stop-type events the last assistant message. SessionEnd deletes the session's file, and SessionStart deletes files older than 7 days. The hooks need a POSIX shell (Git Bash on Windows). Without one they write nothing, and the mod falls back to what it can find on its own. Some plans block plugin hooks on these events; the mod then falls back the same way.Change them with /mod-progress settings or the gear pill.
| Setting | Default | What it does |
|---|---|---|
| Icons | auto | auto picks Nerd Font or safe glyphs. It uses Nerd Font icons when the terminal's font is a Nerd Font (read from Windows Terminal, iTerm2, VS Code, Ghostty, kitty, WezTerm, Alacritty, GNOME Terminal or Konsole) and safe Unicode glyphs elsewhere. nerd and safe force one |
| Animations | on | Entries shimmer while they wait. Hover colors also fade. Off, everything draws still |
Made by [Vinicius Amorim][doc-author], [MIT license][doc-license].
[doc-author]: https://github.com/v-amorim [doc-icons]: https://github.com/v-amorim/moonlight-claude-mods/blob/main/README.md#icons-and-nerd-fonts [doc-install]: https://github.com/v-amorim/moonlight-claude-mods/blob/main/README.md#install [doc-license]: https://github.com/v-amorim/moonlight-claude-mods/blob/main/LICENSE [doc-root]: https://github.com/v-amorim/moonlight-claude-mods/blob/main/README.md
[img-progress-panel]: https://github.com/user-attachments/assets/cd40f976-d96c-4b20-ac20-d2403bd1d822 [img-progress-pill]: https://github.com/user-attachments/assets/3b89b0e0-f4ee-4203-965b-4c467bd466b1 [img-progress-shell]: https://github.com/user-attachments/assets/c749c7e6-db29-4000-a403-2e59d3d1db5b [img-progress-transcript]: https://github.com/user-attachments/assets/9ed57916-9da3-4fbb-accc-08e86335ae47
hooks/register.tsx 993 lines1// mod-progress / hooks/register.tsx, by v-amorim
2// Registers the mod: wires the band pill, pane, settings, stop line and loop tracking.
3import type { ConfigValue, EngineInterface, Register } from 'claude-code'
4
5import { bridgeOn, endBridge, startBridge, type BridgeRecord } from './bridge'
6import { entryCells, isTipMessage, tipText, type EntryMessage, type EntryProps, type Tip, type TipMessage } from './entry'
7import { cardFill, detectNerd, FOOTER_ROWS, lookFor, paneFooter, section, withMark, type IconMode } from './look'
8import { foldTip, layoutLoops, type LoopsMessage, type LoopsProps } from './loops'
9import { labelsOf, menuEntry, PILL, pill, pillRow, splitRow, tabRow, tipLine } from './pills'
10import { addBg, appendTail, applyCall, applyStep, applyTask, bandSummary, bgIdOf, bgOutputOf, bgStartOf, buildRows, emptySnap, endBg, endOfStatus, finishAgent, findBg, firstLine, gatesStemIn, cardWidth, HELD_LINES, hasRunningBg, isGatesPath, MAIN_GATES, mergeStamps, outputRoom, pageOf, parseGates, resyncBg, settle, stemOf, TAIL_LINES, waitingCount, wipeBg, type AgentLite, type BgTask, type Gate, type Snap, type Tail, type Usage, type UsageLite } from './progress'
11import { stopLine, stopOf } from './stop'
12import { choiceBar, settingChoice, settingsBody } from './settings'
13import text from './text'
14import type { TimelineProps } from './timeline'
15import { fill, MARK, mix, statusColor, THEME, WASH } from './theme'
16import { TOAST_MS, ToastList, toastCards } from './toasts'
17
18// The icons setting (userConfig), fixed per activation: a change reloads the mod.
19let iconMode: IconMode = 'auto'
20let look = lookFor(false)
21
22// Nerd glyphs when the setting asks for them, or on auto when the terminal's font is a Nerd Font.
23async function detectLook($: EngineInterface) {
24 if (iconMode !== 'auto') return lookFor(iconMode === 'nerd')
25 const env = {
26 WT_SESSION: await $.env.get('WT_SESSION'),
27 TERM_PROGRAM: await $.env.get('TERM_PROGRAM'),
28 ITERM_PROFILE: await $.env.get('ITERM_PROFILE'),
29 KITTY_WINDOW_ID: await $.env.get('KITTY_WINDOW_ID'),
30 ALACRITTY_WINDOW_ID: await $.env.get('ALACRITTY_WINDOW_ID'),
31 GNOME_TERMINAL_SCREEN: await $.env.get('GNOME_TERMINAL_SCREEN'),
32 KONSOLE_VERSION: await $.env.get('KONSOLE_VERSION'),
33 }
34 const host = {
35 localAppData: ((await $.env.get('LOCALAPPDATA')) ?? '').replace(/\\/g, '/'),
36 home: ((await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '').replace(/\\/g, '/'),
37 read: (path: string) => $.fs.read(path).then(String),
38 run: async (argv: string[]) => {
39 const result = await $.process.run(argv, { timeoutMs: 5000 })
40 if (result.exitCode !== 0) throw new Error(result.stderr)
41 return result.stdout
42 },
43 }
44 return lookFor(await detectNerd(env, host))
45}
46
47// Reading the terminal's settings is slow, so on auto the last answer in the store draws until it returns.
48async function savedLook($: EngineInterface) {
49 if (iconMode !== 'auto') return look
50 try {
51 const nerd = await $.store.get('look-nerd')
52 return typeof nerd === 'boolean' ? lookFor(nerd) : look
53 } catch {
54 return look
55 }
56}
57
58async function saveLook($: EngineInterface) {
59 try {
60 await $.store.set('look-nerd', look.nerd)
61 } catch {
62 // Without a store the mod still draws; it only starts from safe again next time.
63 }
64}
65
66// Hooks of one event run in turn, so the slow font check runs beside them instead of holding up every mod after this one.
67let lookLoad: Promise<void> | null = null
68
69function loadLook($: EngineInterface) {
70 lookLoad ??= (async () => {
71 look = await savedLook($)
72 void detectLook($).then(async found => {
73 const isChanged = found.nerd !== look.nerd
74 look = found
75 await saveLook($)
76 if (isChanged) $.ui.invalidate('ui.render')
77 }).catch(() => {
78 // A failed check keeps the icons drawn now.
79 })
80 })()
81 return lookLoad
82}
83
84const PANE = 'progress'
85const OUTPUT_MORE = 'output-more'
86const VIEW_CARDS = 'view-cards'
87const VIEW_TIMELINE = 'view-timeline'
88// Lines the open page hides while closed, kept from its last draw for the tip that replaces the control on a click.
89let outputClosedLabel = ''
90const SETTINGS = 'progress-settings'
91
92let note = ''
93// The control under the pointer, from its tip post: the band's tip row shows a band control's, the pane footer a pane control's.
94let bandTip: TipMessage | null = null
95let paneTip: TipMessage | null = null
96const hoveredTip = (): Tip | null => (paneTip?.id && paneTip.label ? { glyph: paneTip.glyph ?? '', label: paneTip.label, text: paneTip.text ?? '' } : null)
97
98// A band redraw can replace a hovered entry, whose leave and quiet timer then never come, so a band tip ends on its own.
99const BAND_TIP_MS = 5000
100let bandTipAt = 0
101
102function expireBandTip($: EngineInterface) {
103 const at = ++bandTipAt
104 $.clock.after(BAND_TIP_MS, () => {
105 if (at !== bandTipAt || !bandTip) return
106 bandTip = null
107 $.ui.invalidate('ui.render')
108 })
109}
110const toasts = new ToastList('progress')
111// The animations setting, fixed per activation: off, every entry draws still.
112let still = false
113
114// The figures survive a reload in the store, under the session's id; this is only their cache.
115let snap: Snap | null = null
116// The load in flight, shared, so two hooks never build two snaps and save over each other's changes.
117let snapLoad: Promise<Snap> | null = null
118
119// A /resume or /clear keeps the process and changes the id, but asking on every render costs a round trip, so calls ask at most this often.
120const SESSION_CHECK_MS = 500
121let sessionCheckedAt = 0
122
123async function loadSnap($: EngineInterface) {
124 const now = Date.now()
125 if (snap && now - sessionCheckedAt < SESSION_CHECK_MS) return snap
126 sessionCheckedAt = now
127 const sid = String(await $.session.id())
128 if (snap?.sid === sid) return snap
129 // No session.start follows a /clear, so a snap replacing another session's takes its start time here.
130 const isSwitch = snap !== null
131 snapLoad ??= (async () => {
132 let stored: Snap | undefined
133 try {
134 const raw = (await $.store.get(`snap:${sid}`)) ?? (await $.store.get('snap'))
135 if (raw && typeof raw === 'object' && (raw as Snap).sid === sid) stored = JSON.parse(JSON.stringify(raw)) as Snap
136 } catch {
137 // An unreadable store starts the figures over.
138 }
139 snap = stored ?? (isSwitch ? { ...emptySnap(sid), startedAt: await $.clock.now() } : emptySnap(sid))
140 // A /clear keeps the process, so the shells and monitors it started still run: the session that ended left them in the store.
141 const carried = isSwitch && !stored ? await $.store.get('carry-bg').catch(() => undefined) : undefined
142 if (Array.isArray(carried)) {
143 snap.main.bg = JSON.parse(JSON.stringify(carried)) as BgTask[]
144 await $.store.set('carry-bg', null).catch(() => undefined)
145 await $.state.set(BOOT_KEY, true).catch(() => undefined)
146 }
147 return snap
148 })().finally(() => {
149 snapLoad = null
150 })
151 return snapLoad
152}
153
154async function saveSnap($: EngineInterface) {
155 try {
156 await $.store.set(`snap:${snap?.sid}`, { ...JSON.parse(JSON.stringify(snap)), savedAt: await $.clock.now() })
157 } catch {
158 // The figures still draw from memory.
159 }
160}
161
162const SNAP_KEEP_MS = 30 * 24 * 60 * 60 * 1000
163
164// A session's figures untouched for 30 days go, and so does the one shared key an older version kept.
165async function pruneSnaps($: EngineInterface) {
166 try {
167 const now = await $.clock.now()
168 for (const key of await $.store.keys()) {
169 if (key === 'snap') await $.store.delete(key)
170 if (key.startsWith('bridge-last:') && Number(((await $.store.get(key)) as { at?: unknown } | null)?.at) < now - REPLAY_KEPT_MS) await $.store.delete(key)
171 if (!key.startsWith('snap:')) continue
172 const savedAt = Number(((await $.store.get(key)) as { savedAt?: unknown } | null)?.savedAt)
173 if (Number.isFinite(savedAt) && savedAt < now - SNAP_KEEP_MS) await $.store.delete(key)
174 }
175 } catch {
176 // Old entries stay until the next start.
177 }
178}
179
180// The folder found for a session, kept so later refreshes skip the search.
181let foundGates = { session: '', dir: '' }
182
183// This session's gates folder: <GATES_HOME or ~/.claude/gates>/<repo>/<session id>.
184async function gatesDir($: EngineInterface) {
185 const session = String(await $.session.id())
186 if (foundGates.session === session) return foundGates.dir
187 const home = ((await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '').replace(/\\/g, '/')
188 const base = ((await $.env.get('GATES_HOME')) ?? `${home}/.claude/gates`).replace(/\\/g, '/')
189 const where = ((await $.session.repo())?.root ?? String(await $.session.cwd())).replace(/\\/g, '/').replace(/\/+$/, '')
190 const own = `${base}/${where.split('/').pop()}/${session}`
191 // A checker run from another cwd can leave an empty folder for the same session, so only a folder holding a ledger counts.
192 const holdsLedger = async (dir: string) =>
193 (await $.fs.exists(`${dir}/GATES.md`).catch(() => false)) || (await $.fs.list(`${dir}/gates`).catch(() => [])).some(entry => entry.kind === 'file' && entry.name.toLowerCase().endsWith('.md'))
194 // The checker names the repo its shell is in, which drifts once the agent cds; a session id is unique, so any repo folder holding it is this session's.
195 const others = async () => (await $.fs.list(base).catch(() => [])).filter(entry => entry.kind === 'dir').map(entry => `${base}/${entry.name}/${session}`)
196 for (const dir of [own, ...(await others())]) {
197 if (!(await holdsLedger(dir))) continue
198 foundGates = { session, dir }
199 return dir
200 }
201 return own
202}
203
204// GATES.md and every gates/<name>.md make up the main ledger; gates/<name>.md also belongs to the agent of that name.
205async function readGates($: EngineInterface, target: Snap) {
206 const dir = await gatesDir($)
207 const found: Record<string, Gate[]> = {}
208 const now = await $.clock.now()
209 const root = await $.fs.read(`${dir}/GATES.md`).catch(() => null)
210 if (root !== null) found[MAIN_GATES] = mergeStamps(target.gates[MAIN_GATES], parseGates(String(root)), now)
211 for (const entry of await $.fs.list(`${dir}/gates`).catch(() => [])) {
212 if (entry.kind !== 'file' || !entry.name.toLowerCase().endsWith('.md')) continue
213 const stem = stemOf(entry.name)
214 found[stem] = mergeStamps(target.gates[stem], parseGates(String(await $.fs.read(`${dir}/gates/${entry.name}`).catch(() => ''))).map(gate => ({ ...gate, file: stem })), now)
215 }
216 const isChanged = JSON.stringify(found) !== JSON.stringify(target.gates)
217 target.gates = found
218 return isChanged
219}
220
221async function refreshGates($: EngineInterface) {
222 const current = await loadSnap($)
223 if (await readGates($, current)) await saveSnap($)
224 $.ui.invalidate('ui.render')
225}
226
227// The rows both panes draw, from the engine's agent list and the session's own usage.
228async function currentRows($: EngineInterface) {
229 const current = await loadSnap($)
230 const list = (await $.agent.list()) as AgentLite[]
231 const now = await $.clock.now()
232 const usage = await $.session.usage().catch(() => undefined)
233 const figures: UsageLite | undefined = usage ? { window: usage.context.window, startedAt: usage.startedAt, ...(usage.context.percent === undefined ? {} : { percent: usage.context.percent }) } : undefined
234 if (settle(current, list, now)) await saveSnap($)
235 const model = await $.session.model().catch(() => undefined)
236 return { current, rows: buildRows(current, list, figures, now, text.mainName, text.mainTask, model), now }
237}
238
239// The stop's "ago" would freeze between redraws, so while a stop shows a timer redraws it every minute.
240const AGE_MS = 60000
241let ageTimer: { cancel: () => void } | null = null
242function ageStop($: EngineInterface, current: Snap) {
243 ageTimer?.cancel()
244 ageTimer = current.stop ? $.clock.every(AGE_MS, () => $.ui.invalidate('ui.render')) : null
245}
246
247// A running background task's clock keeps its seconds, so while one runs and the pane is open a timer redraws it every second.
248const TASK_TICK_MS = 1000
249let taskTimer: { cancel: () => void } | null = null
250function tickTasks($: EngineInterface, current: Snap) {
251 if (!hasRunningBg(current)) {
252 taskTimer?.cancel()
253 taskTimer = null
254 return
255 }
256 taskTimer ??= $.clock.every(TASK_TICK_MS, async () => {
257 if (snap && !hasRunningBg(snap)) return tickTasks($, snap)
258 if (await isOpen($)) $.ui.invalidate('ui.render')
259 })
260}
261
262async function saveBackground($: EngineInterface, current: Snap) {
263 tickTasks($, current)
264 // A /clear resets $.state: a task still running proves the process is the same one.
265 await $.state.set(BOOT_KEY, true)
266 await saveSnap($)
267 await syncOutput($)
268 $.ui.invalidate('ui.render')
269}
270
271// The task whose page is open, kept in $.state so a reload keeps it; empty is the list.
272const BOOT_KEY = { plugin: 'mod-progress', key: 'booted' } as const
273const TASK_KEY = { plugin: 'mod-progress', key: 'task' } as const
274// The task whose page shows every held line instead of the last ones.
275const OUTPUT_KEY = { plugin: 'mod-progress', key: 'output-open' } as const
276const isOutputOpen = async ($: EngineInterface, id: string) => (await $.state.get(OUTPUT_KEY)).value === id
277const openTask = async ($: EngineInterface) => String((await $.state.get(TASK_KEY)).value ?? '')
278
279// What each task's output holds, read from its file; a reload reads it again from the start.
280const tails = new Map<string, Tail>()
281const unreadable = new Set<string>()
282const finalRead = new Set<string>()
283const OUTPUT_TICK_MS = 1000
284// fs.read takes a file up to this size.
285const WHOLE_READ_BYTES = 4 * 1024 * 1024
286let outputTimer: { cancel: () => void } | null = null
287
288// Reads what the file gained since the last read (the file's size says whether it grew); true when the tail changed.
289async function pumpOutput($: EngineInterface, id: string, path: string) {
290 let tail = tails.get(id) ?? { from: 0, lines: [], partial: '' }
291 const size = (await $.fs.stat(path).catch(() => null))?.size
292 if (size === undefined) return false
293 if (size < tail.from) tail = { from: 0, lines: [], partial: '' }
294 if (size === tail.from) return false
295 if (size > WHOLE_READ_BYTES) {
296 unreadable.add(id)
297 return false
298 }
299 const whole = await $.fs.read(path).then(String, () => null)
300 if (whole === null) return false
301 const bytes = new TextEncoder().encode(whole)
302 tails.set(id, appendTail(tail, new TextDecoder().decode(bytes.subarray(tail.from)), bytes.length, HELD_LINES))
303 return true
304}
305
306// While a task's page is open and the task runs, its output is read every second; once it ends, one last read, then the timer stops.
307async function syncOutput($: EngineInterface) {
308 try {
309 const id = await openTask($)
310 const task = id ? findBg(await loadSnap($), id) : undefined
311 if (id && !task) await $.state.set(TASK_KEY, '')
312 if (!task || !(await isOpen($))) {
313 outputTimer?.cancel()
314 outputTimer = null
315 return
316 }
317 const isRunning = task.endedAt === undefined
318 if (isRunning) outputTimer ??= $.clock.every(OUTPUT_TICK_MS, () => syncOutput($))
319 else {
320 outputTimer?.cancel()
321 outputTimer = null
322 }
323 const isFinal = !isRunning && !finalRead.has(id)
324 if (!isRunning) finalRead.add(id)
325 const isChanged = task.output && (isRunning || isFinal) ? await pumpOutput($, id, task.output) : false
326 if (isChanged || isRunning) $.ui.invalidate('ui.render')
327 } catch {
328 // The page keeps what it has read.
329 }
330}
331
332// A background Bash or Monitor adds a task once its result names the id; TaskStop and KillShell end one.
333async function watchBackground($: EngineInterface, agentId: string | undefined, tool: string, input: Record<string, unknown>, result: unknown, calledAt: number) {
334 const refused = (result as { deny?: string; isError?: boolean } | undefined)?.deny || (result as { isError?: boolean } | undefined)?.isError
335 if (refused) return
336 const current = await loadSnap($)
337 const now = await $.clock.now()
338 const isMoved = tool === 'Bash' && typeof (result as { result?: { backgroundTaskId?: unknown } } | undefined)?.result?.backgroundTaskId === 'string'
339 const start = bgStartOf(tool, input, isMoved)
340 const id = start ? bgIdOf(result) : undefined
341 const output = start?.kind === 'shell' ? bgOutputOf(String((result as { text?: unknown }).text ?? '')) : undefined
342 if (start && id && addBg(current, agentId, id, { ...start, ...(output ? { output } : {}) }, calledAt)) await saveBackground($, current)
343 if (tool === 'TaskStop' || tool === 'KillShell') {
344 const stopped = String(input.task_id ?? input.shell_id ?? input.taskId ?? input.id ?? '')
345 if (stopped && endBg(current, stopped, 'failed', now)) await saveBackground($, current)
346 }
347}
348
349async function resync($: EngineInterface, agentId: string | undefined, listed: { id: string; status: string }[] | undefined) {
350 if (!listed) return
351 try {
352 const current = await loadSnap($)
353 if (resyncBg(current, agentId, listed, await $.clock.now())) await saveBackground($, current)
354 } catch {
355 // The figures stay as they were.
356 }
357}
358
359// A reload makes the bridge replay the last record of each event once. The first record of an event after this module started is skipped when it is the one already handled, so a replay never sets a stale stop line or ends a task that began after it.
360const replayChecked = new Set<string>()
361const REPLAY_KEPT_MS = 30 * 24 * 60 * 60 * 1000
362const BRIDGE_EVENTS = ['Stop', 'SubagentStop', 'StopFailure']
363const replayKey = (session: string, event: string) => `bridge-last:${session}:${event}`
364
365async function isReplay($: EngineInterface, record: BridgeRecord) {
366 const key = replayKey(String(record.session_id ?? ''), String(record.hook_event_name ?? ''))
367 const raw = JSON.stringify(record)
368 const isFirst = !replayChecked.has(key)
369 replayChecked.add(key)
370 const kept = (await $.store.get(key).catch(() => undefined)) as { raw?: unknown } | undefined
371 await $.store.set(key, { raw, at: await $.clock.now() }).catch(() => undefined)
372 return isFirst && kept?.raw === raw
373}
374
375const tasksOf = (record: BridgeRecord) => (Array.isArray(record.background_tasks) ? (record.background_tasks as { id: string; status: string }[]) : undefined)
376const agentOf = (record: BridgeRecord) => (typeof record.agent_id === 'string' && record.agent_id ? record.agent_id : undefined)
377
378// The main turn's Stop lists the background work still running; whatever it no longer lists has finished.
379async function mainStopped($: EngineInterface, record: BridgeRecord) {
380 try {
381 if (await isReplay($, record)) return
382 await resync($, undefined, tasksOf(record))
383 await refreshGates($)
384 } catch {
385 // The figures stay as they were.
386 }
387}
388
389async function subagentStopped($: EngineInterface, record: BridgeRecord) {
390 try {
391 if (await isReplay($, record)) return
392 await resync($, agentOf(record), tasksOf(record))
393 await refreshGates($)
394 } catch {
395 // The figures stay as they were.
396 }
397}
398
399// The turn ended on an API error. A subagent's failure is its own turn, so it leaves the main line alone.
400async function stopFailed($: EngineInterface, record: BridgeRecord) {
401 try {
402 if (await isReplay($, record)) return
403 const agentId = agentOf(record)
404 const current = await loadSnap($)
405 if (agentId) {
406 if (finishAgent(current, agentId, await $.clock.now(), true)) {
407 await saveSnap($)
408 $.ui.invalidate('ui.render')
409 }
410 } else {
411 const usage = await $.session.usage().catch(() => undefined)
412 current.stop = stopOf(String(record.error ?? ''), await $.clock.now(), usage?.rateLimits ?? [])
413 ageStop($, current)
414 await saveSnap($)
415 $.ui.invalidate('ui.render')
416 }
417 } catch {
418 // The figures stay as they were.
419 }
420}
421
422const isOpen = async ($: EngineInterface, id = PANE) => (await $.ui.panes()).some(pane => pane.id === id && pane.isPlaced)
423
424// The command and the bar entry both toggle. An unplaced pane is waiting, not open.
425async function togglePane($: EngineInterface) {
426 if (await isOpen($)) {
427 await $.ui.close({ id: PANE })
428 await $.state.set({ plugin: 'mod-progress', key: 'pane' }, false)
429 return
430 }
431 if (await isOpen($, SETTINGS)) await $.ui.close({ id: SETTINGS })
432 note = ''
433 const opened = await $.ui.open({ id: PANE, title: text.paneTitle, closeOnEscape: true })
434 if (!opened.isPlaced) $.ui.toast(fill(text.progressWaiting, { reason: opened.reason }))
435 await $.state.set({ plugin: 'mod-progress', key: 'pane' }, opened.isPlaced)
436}
437
438// `/mod-progress settings` and the title row's pill toggle the settings panel, which takes the sidebar's place.
439async function toggleSettings($: EngineInterface) {
440 if (await isOpen($, SETTINGS)) {
441 await $.ui.close({ id: SETTINGS })
442 await $.state.set({ plugin: 'mod-progress', key: 'pane' }, false)
443 return
444 }
445 if (await isOpen($)) await $.ui.close({ id: PANE })
446 note = ''
447 const opened = await $.ui.open({ id: SETTINGS, title: text.settingsPaneTitle, closeOnEscape: true })
448 if (!opened.isPlaced) $.ui.toast(fill(text.settingsWaiting, { reason: opened.reason }))
449 await $.state.set({ plugin: 'mod-progress', key: 'pane' }, opened.isPlaced)
450}
451
452// A saved change reloads this mod, which redraws the panel with the new value.
453async function saveSetting($: EngineInterface, key: string, value: ConfigValue) {
454 const row = (await $.config.list()).find(each => each.key === key)
455 const result = await $.config.set({ key, value })
456 note = 'deny' in result && result.deny ? `${MARK.warn} ${result.deny}` : `${MARK.ok} ${fill(text.settingSaved, { label: row?.label ?? key })}`
457 $.ui.invalidate('ui.render')
458}
459
460const agentIcon = () => look.glyph.icon.Agent ?? look.glyph.mark.agent ?? look.glyph.title
461
462// The panel's title line: the list and the settings as tabs, the shown one open.
463const tabs = (open: 'list' | 'settings', count: number) => [
464 { id: 'progress-tab', label: text.paneTitle, icon: agentIcon(), isOpen: open === 'list', count, tip: { glyph: agentIcon(), label: text.paneTitle, text: text.tipTabList } },
465 { id: 'progress-settings', label: text.settingsTab, icon: look.glyph.gear, isOpen: open === 'settings', tip: { glyph: look.glyph.gear, label: text.settingsTab, text: text.tipTabSettings } },
466]
467
468export const register: Register = (on, options) => {
469 still = options.animations === false
470 iconMode = (options.icons as IconMode | undefined) ?? 'auto'
471 look = lookFor(iconMode === 'nerd')
472 toasts.isStill = still
473 // This mod's toasts draw as cards in the band; Claude Code's own box is narrow and cuts them.
474 on('ui.toast', async ($, e) => {
475 const id = toasts.add(e.text)
476 $.ui.invalidate('ui.render')
477 $.clock.after(e.timeoutMs ?? TOAST_MS, () => {
478 toasts.drop(id)
479 $.ui.invalidate('ui.render')
480 })
481 return { value: undefined }
482 })
483
484 on('session.start', async ($, e, next) => {
485 try {
486 // The classic Stop events never reach a mod, so the command hooks in hooks.json hand them over through the bridge.
487 bridgeOn('Stop', record => mainStopped($, record))
488 bridgeOn('SubagentStop', record => subagentStopped($, record))
489 bridgeOn('StopFailure', record => stopFailed($, record))
490 await startBridge($, 'mod-progress')
491 await loadLook($)
492 await $.command.register({ name: 'mod-progress', description: text.commandDescription, argumentHint: '[settings]' })
493 const current = await loadSnap($)
494 // $.state lives as long as the process and survives a plugin reload, so an unset marker means a new process, whose shells are gone.
495 if (!(await $.state.get(BOOT_KEY)).value) wipeBg(current)
496 await $.state.set(BOOT_KEY, true)
497 const usage = await $.session.usage().catch(() => undefined)
498 if (current.startedAt === undefined) current.startedAt = usage?.startedAt ?? (await $.clock.now())
499 await readGates($, current)
500 ageStop($, current)
501 tickTasks($, current)
502 await saveSnap($)
503 await pruneSnaps($)
504 await syncOutput($)
505 // The bar entry reads this; an unload drops the pane without raising ui.close here.
506 await $.state.set({ plugin: 'mod-progress', key: 'pane' }, await isOpen($))
507 // A hot reload leaves an open pane on its old drawing until something redraws it.
508 $.ui.invalidate('ui.render')
509 } catch {
510 // The figures stay as they were.
511 }
512
513 return next(e)
514 })
515
516 // A /resume or /clear ends this session; the next call asks for the id at once.
517 on('session.end', async ($, e, next) => {
518 // What the ended session's file still holds is delivered before the session id changes under it.
519 await endBridge($, e.sessionId)
520 sessionCheckedAt = 0
521 try {
522 for (const event of BRIDGE_EVENTS) await $.store.delete(replayKey(e.sessionId, event)).catch(() => undefined)
523 // A /clear leaves the shells running in the same process; any other end takes them with it.
524 if (snap && e.reason === 'clear') await $.store.set('carry-bg', JSON.parse(JSON.stringify((snap.main.bg ?? []).filter(task => task.endedAt === undefined))))
525 else if (snap && wipeBg(snap)) await saveBackground($, snap)
526 } catch {
527 // The figures stay as they were.
528 }
529
530 return next(e)
531 })
532
533 on('command.run', { command: 'mod-progress' }, async ($, e) => {
534 if ((e.args ?? '').trim() === 'settings') await toggleSettings($)
535 else await togglePane($)
536
537 return { text: '' }
538 })
539
540 on('ui.close', async ($, e, next) => {
541 if (e.id === PANE || e.id === SETTINGS) paneTip = null
542 try {
543 if (e.id === PANE || e.id === SETTINGS) await $.state.set({ plugin: 'mod-progress', key: 'pane' }, await isOpen($, e.id === PANE ? SETTINGS : PANE))
544 if (e.id === PANE) await syncOutput($)
545 } catch {
546 // The figures stay as they were.
547 }
548
549 return next(e)
550 })
551
552 // One panel at a time: another mod's pane opening closes this one.
553 on('state.set', async ($, e, next) => {
554 // The declared state names no other mod's pane, so the types narrow this event to never.
555 // Deferred past this dispatch, so this mod's own state write is not nested in another plugin's.
556 if (e.key === 'pane' && e.plugin !== 'mod-progress' && (e as { value?: unknown }).value === true) {
557 $.clock.after(0, async () => {
558 const wasOpen = (await isOpen($)) || (await isOpen($, SETTINGS))
559 if (!wasOpen) return
560 await $.ui.close({ id: PANE })
561 await $.ui.close({ id: SETTINGS })
562 await $.state.set({ plugin: 'mod-progress', key: 'pane' }, false)
563 })
564 }
565
566 return next(e)
567 })
568
569 on('agent.spawn', async ($, e, next) => {
570 const result = await next(e)
571 try {
572 if ('agentId' in result) {
573 const current = await loadSnap($)
574 current.agents[result.agentId!] = {
575 model: result.model,
576 tokens: 0,
577 context: 0,
578 spawnedAt: await $.clock.now(),
579 gatesStem: gatesStemIn(e.prompt),
580 ...(e.workflow ? { workflow: { runId: e.workflow.runId, index: e.workflow.agentIndex, name: e.name ?? fill(text.workflowAgent, { n: e.workflow.agentIndex }), description: firstLine(e.description ?? e.prompt) } } : {}),
581 }
582 await saveSnap($)
583 $.ui.invalidate('ui.render')
584 }
585 } catch {
586 // The figures stay as they were.
587 }
588 return result
589 })
590
591 // A gates file handed to a running agent by message, after a brief that named none.
592 on('session.send', async ($, e, next) => {
593 try {
594 const stem = gatesStemIn(e.text)
595 const current = stem ? await loadSnap($) : undefined
596 const agent = current?.agents[e.to]
597 if (agent && !agent.gatesStem) {
598 agent.gatesStem = stem
599 await saveSnap($)
600 $.ui.invalidate('ui.render')
601 }
602 } catch {
603 // The figures stay as they were.
604 }
605 return next(e)
606 })
607
608 on('turn.start', async ($, e, next) => {
609 try {
610 if (!(e as { agentId?: string }).agentId) {
611 const current = await loadSnap($)
612 current.busy = true
613 delete current.main.endedAt
614 delete current.stop
615 ageStop($, current)
616 await saveSnap($)
617 $.ui.invalidate('ui.render')
618 }
619 } catch {
620 // The figures stay as they were.
621 }
622 return next(e)
623 })
624
625 on('turn.step', async function* ($, e, next) {
626 const result = yield* next(e)
627 try {
628 const current = await loadSnap($)
629 applyStep(current, e.agentId, e.model, e.effort, (result as { usage?: Usage | null }).usage)
630 await saveSnap($)
631 $.ui.invalidate('ui.render')
632 } catch {
633 // The figures stay as they were.
634 }
635 return result
636 })
637
638 on('turn.complete', async ($, e, next) => {
639 try {
640 const current = await loadSnap($)
641 const now = await $.clock.now()
642 if (!(e as { agentId?: string }).agentId) {
643 current.busy = false
644 current.main.endedAt = now
645 delete current.main.step
646 }
647 // A workflow agent is in no list, so its own turn ending is the only sign it is over.
648 if (e.agentId) finishAgent(current, e.agentId, now, e.reason !== 'answer')
649 settle(current, (await $.agent.list()) as AgentLite[], now)
650 await readGates($, current)
651 await saveSnap($)
652 $.ui.invalidate('ui.render')
653 } catch {
654 // The figures stay as they were.
655 }
656 return next(e)
657 })
658
659 // Tasks come from each loop's own TaskCreate and TaskUpdate; gates files change through Write, Edit or the gate checker run by Bash.
660 on('tool.call', async ($, e, next) => {
661 try {
662 const current = await loadSnap($)
663 applyCall(current, e.agentId, String(e.tool), { ...e })
664 await saveSnap($)
665 $.ui.invalidate('ui.render')
666 } catch {
667 // The figures stay as they were.
668 }
669 const calledAt = await $.clock.now()
670 const result = await next(e)
671 // The observers never fail the call they watch.
672 try {
673 const input: Record<string, unknown> = { ...e }
674 const tool = String(e.tool)
675 const current = await loadSnap($)
676 const owner = e.agentId ? current.agents[e.agentId] : current
677 if (owner && (tool === 'TaskCreate' || tool === 'TaskUpdate')) {
678 applyTask(owner, tool, input, (result as { result?: { task?: { id?: string } } }).result?.task?.id)
679 await saveSnap($)
680 $.ui.invalidate('ui.render')
681 }
682 const touchesGates = /^(Write|Edit|MultiEdit)$/.test(tool) ? isGatesPath(String(input.file_path ?? '')) : tool === 'Bash' && /(^|[^a-z])gates?(?![a-z])/i.test(String(input.command ?? ''))
683 if (touchesGates) await refreshGates($)
684 await watchBackground($, e.agentId, tool, input, result, calledAt)
685 } catch {
686 // The figures stay as they were.
687 }
688 return result
689 })
690
691 // A task's notification reaches the session as a prompt of its own, whether or not anything draws it.
692 on('prompt.submit', async ($, e, next) => {
693 try {
694 if (e.origin?.kind === 'task-notification') {
695 const id = /<task-id>\s*([^<\s]+)\s*<\/task-id>/.exec(e.text)?.[1]
696 const status = /<status>\s*([^<\s]+)\s*<\/status>/.exec(e.text)?.[1]
697 const current = await loadSnap($)
698 // The closing notice names the output file, which a monitor's start did not.
699 const known = id ? findBg(current, id) : undefined
700 const file = bgOutputOf(e.text)
701 if (known && file && !known.output) known.output = file
702 // A monitor's event rows carry no status; only the closing notice does.
703 if (id && status && endBg(current, id, endOfStatus(status), await $.clock.now())) await saveBackground($, current)
704 }
705 } catch {
706 // The figures stay as they were.
707 }
708 return next(e)
709 })
710
711 // A task stopped from /tasks reaches no tool call, Stop or prompt hook; only its drawn notification row carries the status.
712 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
713 const { task } = e.props as { task?: { id?: string; status?: string } }
714 if (task?.id && task.status) {
715 const { id, status } = task
716 $.clock.after(0, async () => {
717 const current = await loadSnap($)
718 if (endBg(current, id, endOfStatus(status), await $.clock.now())) await saveBackground($, current)
719 })
720 }
721 return next(e)
722 })
723
724 // The bar entry lives here, not in mod-menu: only a press in this mod counts as the person asking for this pane.
725 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
726 await loadLook($)
727 const hidden = await $.state.get({ plugin: 'mod-menu', key: 'hidden' })
728 if (e.surface !== 'terminal' || e.props.hasSurvey) return next(e)
729 if (hidden.value === true) {
730 if (toasts.items.length === 0) return next(e)
731 const { Box, Client, Text } = $.ui.resolve(e)
732 return <Box key="toasts-progress" flexDirection="column">{toastCards({ Box, Client, Text }, toasts, look, text.tipToast)}{await next(e)}</Box>
733 }
734 const nerd = look.nerd
735 const labels = labelsOf((await $.state.get({ plugin: 'mod-menu', key: 'labels' })).value)
736
737 const { Box, Client, Text } = $.ui.resolve(e)
738 // Reading the value subscribes the bar to redraws; the look trusts the pane list itself.
739 await $.state.get({ plugin: 'mod-progress', key: 'pane' })
740 const shown = (await isOpen($)) || (await isOpen($, SETTINGS))
741 const { current, rows, now } = await currentRows($)
742 const summary = bandSummary(rows)
743 const stop = stopLine(current.stop, now)
744 const { group, row, rest, tip } = splitRow(await next(e))
745 const badge = summary.count > 0 ? { badge: { text: String(summary.count), color: THEME.accent, bg: mix(THEME.accent, shown ? mix(THEME.ok, PILL.primary, WASH) : PILL.primary, WASH) } } : {}
746 const waiting = waitingCount(rows)
747 const pillTip = stop?.line ?? (summary.total > 0 ? `${fill(text.tipPillGates, summary)}${waiting > 0 ? ` ${fill(text.tipPillWaiting, { sep: MARK.sep, n: waiting })}` : ''}` : text.tipPill)
748 const entry = menuEntry({ Box, Client, Text }, nerd, 'progress', { label: text.pillLabel, labels, isOpen: shown, idle: THEME.pill, still, nerd, tip: { glyph: look.glyph.icon.Agent ?? look.glyph.mark.agent ?? look.glyph.title, label: text.pillLabel, text: pillTip }, ...badge })
749 // A pill tip is posted once on enter, so its text is redrawn from the figures here, the minute the stop's age ticks.
750 const shownTip = bandTip?.id === 'progress' ? { ...bandTip, text: pillTip } : bandTip
751
752 return pillRow({ Box, Client, Text }, nerd, [entry, ...group], row, [...toastCards({ Box, Client, Text }, toasts, look, text.tipToast), ...rest], shownTip?.id ? tipLine({ Box, Text }, tipText(shownTip)) : tip, e.props.bodyColumns)
753 })
754
755 on('ui.message', async ($, e, next) => {
756 if (isTipMessage(e.data)) {
757 // The band's own controls are the pill and its toasts; every other id is a pane control. An empty id clears both.
758 const isBand = e.data.id === 'progress' || (e.data.id ? toasts.idOf(e.data.id) !== undefined : false)
759 if (!e.data.id) {
760 bandTip = null
761 paneTip = null
762 } else if (isBand) {
763 bandTip = e.data
764 expireBandTip($)
765 } else paneTip = e.data
766 $.ui.invalidate('ui.render')
767 return next(e)
768 }
769 const clicked = e.data as EntryMessage
770 const dismissed = clicked?.type === 'menu-entry' ? toasts.idOf(clicked.id) : undefined
771 if (dismissed !== undefined) {
772 toasts.drop(dismissed)
773 $.ui.invalidate('ui.render')
774 return next(e)
775 }
776 if (clicked?.type === 'menu-entry' && clicked.id === 'progress') await togglePane($)
777 // The title line's tabs swap the list and the settings; the open tab does nothing.
778 if (clicked?.type === 'menu-entry' && clicked.id === 'progress-settings' && !(await isOpen($, SETTINGS))) await toggleSettings($)
779 if (clicked?.type === 'menu-entry' && clicked.id === 'progress-tab' && !(await isOpen($))) await togglePane($)
780 if (clicked?.type === 'menu-entry' && clicked.id === OUTPUT_MORE) {
781 const id = await openTask($)
782 const isOpening = !(await isOutputOpen($, id))
783 await $.state.set(OUTPUT_KEY, isOpening ? id : '')
784 // The click swaps the control for its opposite under the pointer, and the old one never posts its leave.
785 const label = isOpening ? text.showLess : outputClosedLabel
786 paneTip = { type: 'tip', id: OUTPUT_MORE, glyph: isOpening ? look.glyph.less : look.glyph.more, label, text: isOpening ? text.tipShowLess : text.tipShowAll }
787 $.ui.invalidate('ui.render')
788 }
789 if (clicked?.type === 'menu-entry' && (clicked.id === 'fold-all' || clicked.id === 'unfold-all')) {
790 const current = await loadSnap($)
791 const isFolding = clicked.id === 'fold-all'
792 const { rows } = await currentRows($)
793 current.folded = isFolding ? rows.filter(each => each.tasks.length > 0).map(each => each.id) : []
794 await saveSnap($)
795 paneTip = isFolding ? { type: 'tip', id: 'unfold-all', glyph: look.glyph.more, label: text.unfoldAll, text: text.tipUnfoldAll } : { type: 'tip', id: 'fold-all', glyph: look.glyph.less, label: text.foldAll, text: text.tipFoldAll }
796 $.ui.invalidate('ui.render')
797 }
798 if (clicked?.type === 'menu-entry' && (clicked.id === VIEW_CARDS || clicked.id === VIEW_TIMELINE)) {
799 const current = await loadSnap($)
800 current.view = clicked.id === VIEW_TIMELINE ? 'timeline' : 'cards'
801 await saveSnap($)
802 $.ui.invalidate('ui.render')
803 }
804 if (clicked?.type === 'menu-entry' && clicked.id === 'progress-back') {
805 await $.state.set(OUTPUT_KEY, '')
806 await $.state.set(TASK_KEY, '')
807 await syncOutput($)
808 // Back gives way to the list without posting its leave.
809 paneTip = null
810 $.ui.invalidate('ui.render')
811 }
812 const change = settingChoice(e.data)
813 if (change) await saveSetting($, change.key, change.value)
814
815 return next(e)
816 })
817
818 // A click on a card opens its gates under it, a second click closes them.
819 on('ui.message', { requestId: 'progress' }, async ($, e, next) => {
820 const message = e.data as LoopsMessage
821 if (message?.type === 'toggle') {
822 const current = await loadSnap($)
823 current.expanded = current.expanded === message.id ? '' : message.id
824 await saveSnap($)
825 $.ui.invalidate('ui.render')
826 }
827 if (message?.type === 'fold') {
828 const current = await loadSnap($)
829 const isFolded = (current.folded ?? []).includes(message.id)
830 current.folded = isFolded ? (current.folded ?? []).filter(id => id !== message.id) : [...(current.folded ?? []), message.id]
831 await saveSnap($)
832 // The card keeps its toggle under the pointer, but its tip now says the opposite.
833 const row = (await currentRows($)).rows.find(each => each.id === message.id)
834 if (row) {
835 const tip = foldTip(row, !isFolded, isFolded ? look.glyph.expanded : look.glyph.collapsed)
836 paneTip = { type: 'tip', id: 'loops', ...tip }
837 }
838 $.ui.invalidate('ui.render')
839 }
840 if (message?.type === 'task') {
841 await $.state.set(OUTPUT_KEY, '')
842 await $.state.set(TASK_KEY, message.id)
843 await syncOutput($)
844 // The list that held the pointer gives way to the shell's page without posting its leave.
845 paneTip = null
846 $.ui.invalidate('ui.render')
847 }
848
849 return next(e)
850 })
851
852 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
853 await loadLook($)
854 if (e.requestId !== PANE && e.requestId !== SETTINGS) return next(e)
855
856 // @ts-expect-error The host's types omit Client from a Pane's elements, yet a Pane draws it.
857 const { Box, Button, Client, Text } = $.ui.resolve(e)
858
859 if (e.requestId === SETTINGS) {
860 const settingRows = (await $.config.list()).filter(row => row.key.startsWith('mod-progress.'))
861 const { rows } = await currentRows($)
862 return (
863 <Box flexDirection="column" paddingX={1} minHeight={e.props.scroll.bodyRows} backgroundColor={THEME.bg}>
864 {tabRow({ Box, Client, Text }, look.nerd, tabs('settings', bandSummary(rows).count), still)}
865 <Text wrap="truncate-end" color={statusColor(note)}>{withMark(look, note) || fill(text.settingsHint, { sep: MARK.sep })}</Text>
866 <Box marginTop={0}>{settingsBody({ Box, Button, Client, Text }, look, { rows: settingRows, actions: [], working: false, run: async () => {}, still })}</Box>
867 <Box key="footer-space" height={FOOTER_ROWS} flexShrink={0} />
868 {paneFooter({ Box, Text }, hoveredTip(), e.props.bodyColumns, e.props.scroll)}
869 </Box>
870 )
871 }
872
873 const pageId = await openTask($)
874 if (pageId) {
875 const page = pageOf(await loadSnap($), pageId, await $.clock.now())
876 if (page) {
877 const tail = tails.get(pageId)
878 // Only as many of the held lines as the pane has room for, so the last one is never cut by the pane's edge.
879 const room = Math.min(TAIL_LINES, outputRoom(e.props.scroll.bodyRows, e.props.bodyColumns, page.command, text.commandLead, FOOTER_ROWS))
880 const held = tail ? [...tail.lines, ...(tail.partial ? [tail.partial] : [])] : []
881 const hidden = Math.max(0, held.length - room)
882 // A line wider than the card counts as hidden too: its control opens the lines wrapped in full.
883 const width = cardWidth(e.props.bodyColumns)
884 const isCut = hidden > 0 || held.slice(-room).some(line => line.length > width)
885 const isFull = isCut && (await isOutputOpen($, pageId))
886 const shown = isFull ? held : held.slice(-room)
887 const closedLabel = hidden > 0 ? fill(text.moreLines, { n: hidden }) : text.showAll
888 const moreLabel = isFull ? text.showLess : closedLabel
889 const moreArrow = isFull ? look.glyph.less : look.glyph.more
890 const moreProps = { id: OUTPUT_MORE, label: moreLabel, lead: moreArrow, leadColor: THEME.accent, isOpen: false, bg: cardFill(), idle: THEME.muted, still, nerd: look.nerd, tip: { glyph: moreArrow, label: moreLabel, text: isFull ? text.tipShowLess : text.tipShowAll } } satisfies EntryProps
891 const moreControl = <Client key="output-more" module="./entry.tsx" width={entryCells(moreProps)} height={1} props={moreProps} />
892 outputClosedLabel = closedLabel
893 const status = fill(text.taskStatus, { kind: page.kind === 'shell' ? text.taskShell : text.taskMonitor, sep: MARK.sep, status: page.status === 'running' ? text.taskRunning : page.status === 'done' ? text.taskDone : text.taskFailed, elapsed: page.elapsed })
894 const back = [{ id: 'progress-back', label: text.paneTitle, icon: look.glyph.back, isOpen: false, tip: { glyph: look.glyph.back, label: text.paneTitle, text: text.tipBack } }]
895 const empty = !page.output ? text.noOutputFile : unreadable.has(pageId) ? text.outputUnreadable : text.noOutputYet
896 return (
897 <Box flexDirection="column" paddingX={1} minHeight={e.props.scroll.bodyRows} backgroundColor={THEME.bg}>
898 <Box key="task-head" flexDirection="row" columnGap={1}>
899 {tabRow({ Box, Client, Text }, look.nerd, back, still)}
900 <Text wrap="truncate-end" color={THEME.muted}>{status}</Text>
901 </Box>
902 {section({ Box, Text }, look, 'task-command', '', text.commandTitle, (
903 <Box key="task-command-lines" flexDirection="column">
904 {page.command.split(/\r?\n/).map((line, at) => (
905 <Text key={`command-${at}`} wrap="wrap" color={THEME.onSurface}>{`${at === 0 ? text.commandLead : ' '} ${line}`}</Text>
906 ))}
907 </Box>
908 ))}
909 {section({ Box, Text }, look, 'task-output', '', text.outputTitle, (
910 <Box key="task-output-lines" flexDirection="column">
911 {shown.length === 0 ? <Text key="output-empty" color={THEME.muted}>{empty}</Text> : shown.map((line, at) => {
912 const row = <Text key={`output-${at}`} wrap={isFull ? 'wrap' : 'truncate-end'} color={THEME.muted}>{line || ' '}</Text>
913 return isCut && at === shown.length - 1 ? (
914 <Box key="output-last-row" flexDirection="row">
915 <Box key="output-last-text" flexShrink={1}>{row}</Box>
916 <Box key="output-last-gap" flexGrow={1} />
917 <Box key="output-last-more" flexShrink={0}>{moreControl}</Box>
918 </Box>
919 ) : row
920 })}
921 </Box>
922 ))}
923 <Box key="footer-space" height={FOOTER_ROWS} flexShrink={0} />
924 {paneFooter({ Box, Text }, hoveredTip(), e.props.bodyColumns, e.props.scroll)}
925 </Box>
926 )
927 }
928 $.clock.after(0, () => $.state.set(TASK_KEY, ''))
929 }
930
931 const { current, rows, now } = await currentRows($)
932 const stop = stopLine(current.stop, now)
933 // Pane padding, then the section's border and padding.
934 const width = e.props.bodyColumns - 2 - 4
935 const viewed = e.props.view.agentId ?? ''
936 const selected = rows.find(each => each.id === current.expanded && !each.isMain)
937 const switchLabel = viewed ? text.backToMain : selected ? text.openTranscript : ''
938 const switchView = async () => {
939 const shown = await $.ui.view(viewed || !selected ? {} : { agentId: selected.id })
940 if (shown.deny) $.ui.toast(shown.deny)
941 $.ui.invalidate('ui.render')
942 }
943 const props = JSON.parse(JSON.stringify({ rows, width, expanded: current.expanded, nerd: look.nerd, viewing: e.props.view.agentId ?? '', folded: current.folded ?? [], now, still })) as LoopsProps
944 const withTasks = rows.filter(each => each.tasks.length > 0)
945 const isAnyOpen = withTasks.some(each => !(current.folded ?? []).includes(each.id))
946 const foldLabel = isAnyOpen ? text.foldAll : text.unfoldAll
947 const foldArrow = isAnyOpen ? look.glyph.less : look.glyph.more
948 const foldProps = { id: isAnyOpen ? 'fold-all' : 'unfold-all', label: foldLabel, lead: foldArrow, leadColor: THEME.accent, isOpen: false, bg: THEME.bg, idle: THEME.muted, still, nerd: look.nerd, tip: { glyph: foldArrow, label: foldLabel, text: isAnyOpen ? text.tipFoldAll : text.tipUnfoldAll } } satisfies EntryProps
949 const isTimeline = current.view === 'timeline'
950 const viewBar = choiceBar({ Box, Button, Client, Text }, look, 'view', THEME.bg, [
951 { key: 'view-cards', isOn: !isTimeline, props: { id: VIEW_CARDS, label: text.viewCards, still, tip: { glyph: look.glyph.cycle, label: text.viewCards, text: text.tipViewCards } } },
952 { key: 'view-timeline', isOn: isTimeline, props: { id: VIEW_TIMELINE, label: text.viewTimeline, still, tip: { glyph: look.glyph.cycle, label: text.viewTimeline, text: text.tipViewTimeline } } },
953 ])
954 const lanes = JSON.parse(JSON.stringify({ rows, width, expanded: current.expanded, nerd: look.nerd, viewing: e.props.view.agentId ?? '', now, still })) as TimelineProps
955 const foldToggle = withTasks.length === 0 || isTimeline ? null : <Client key="fold-toggle" module="./entry.tsx" width={entryCells(foldProps)} height={1} props={foldProps} />
956
957 return (
958 <Box flexDirection="column" paddingX={1} minHeight={e.props.scroll.bodyRows} backgroundColor={THEME.bg}>
959 {tabRow({ Box, Client, Text }, look.nerd, tabs('list', bandSummary(rows).count), still)}
960 <Box key="hint-row" flexDirection="row" columnGap={1}>
961 <Box key="hint-text" flexGrow={1} flexShrink={1}>
962 <Text wrap="truncate-end" color={statusColor(note)}>{withMark(look, note) || text.hint}</Text>
963 </Box>
964 {switchLabel ? (
965 <Box key="view-pill" flexShrink={0}>
966 {/* A Button, not a Client: ui.view is refused unless a real press is behind it. */}
967 {pill({ Box, Text }, look.nerd, THEME.surface3, (
968 <Button key="view-switch" label={look.nerd ? `${MARK.open} ${switchLabel}` : ` ${MARK.open} ${switchLabel} `} plain hover={{ scope: 'view-pill-box', color: THEME.accent, inverse: true }} onPress={switchView} />
969 ), 'view-pill-box', THEME.surface1, THEME.accent)}
970 </Box>
971 ) : null}
972 </Box>
973 <Box key="view-row" flexDirection="row">{viewBar}</Box>
974 {/* A blank row of the same height while no stop shows, so the list below does not move when one appears. */}
975 <Box key="stop-row" flexDirection="row" columnGap={1}>
976 <Box key="stop-text" flexGrow={1} flexShrink={1}>
977 <Text key="stop-line" wrap="truncate-end" color={stop?.tone === 'alert' ? THEME.alert : THEME.warn}>{stop ? withMark(look, `${MARK.warn} ${stop.line}`) : ' '}</Text>
978 </Box>
979 {foldToggle}
980 </Box>
981 {section({ Box, Text }, look, 'loops', '', '', (
982 isTimeline
983 ? <Client key="timeline" module="./timeline.tsx" height={rows.length} props={lanes} />
984 : <Client key="loops" module="./loops.tsx" height={layoutLoops(rows, current.expanded, current.folded ?? []).length} props={props} />
985 ))}
986 {rows.length === 1 ? <Text color={THEME.muted}>{text.noAgents}</Text> : null}
987 <Box key="footer-space" height={FOOTER_ROWS} flexShrink={0} />
988 {paneFooter({ Box, Text }, hoveredTip(), e.props.bodyColumns, e.props.scroll, switchLabel ? [{ scope: 'view-pill-box', tip: { glyph: MARK.open, label: switchLabel, text: viewed ? text.tipBackToMain : text.tipOpenTranscript } }] : [])}
989 </Box>
990 )
991 })
992}
993hooks/bridge.ts 107 lines1// hooks/bridge.ts, by v-amorim: a poll hands the lines a command hook appended to <mods-data>/<mod>/bridge/<session>.jsonl to this mod.
2import type { EngineInterface } from 'claude-code'
3
4export type BridgeRecord = Record<string, unknown>
5export type BridgeHandler = (record: BridgeRecord) => void | Promise<void>
6
7const POLL_MS = 250
8
9// One handler per event; registering again replaces it, so a mod that registers on every session.start holds one.
10const handlers = new Map<string, BridgeHandler>()
11// Characters of each session's file already delivered.
12const cursors = new Map<string, number>()
13let isStarted = false
14let isTicking = false
15let folder = ''
16
17export function bridgeOn(event: string, handler: BridgeHandler) {
18 handlers.set(event, handler)
19}
20
21async function folderOf($: EngineInterface, mod: string) {
22 const set = ((await $.env.get('CLAUDE_CONFIG_DIR')) ?? '').replace(/\\/g, '/').replace(/\/+$/, '')
23 if (set) return `${set}/mods-data/${mod}/bridge`
24 // The command hooks write under $HOME, so it comes before USERPROFILE; a Git Bash `/c/Users/x` becomes `C:/Users/x`, which $.fs can open.
25 const raw = ((await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || '').replace(/\\/g, '/').replace(/\/+$/, '')
26 const home = raw.replace(/^\/([A-Za-z])(?=\/[^/])/, (_, drive: string) => `${drive.toUpperCase()}:`)
27 return home ? `${home}/.claude/mods-data/${mod}/bridge` : ''
28}
29
30async function deliver(record: BridgeRecord) {
31 try {
32 await handlers.get(String(record.hook_event_name ?? ''))?.(record)
33 } catch {
34 // A handler's failure never stops the poll.
35 }
36}
37
38// The complete lines this session's file gained since the last read, and whether this is the first read since the module started.
39async function readNew($: EngineInterface, session: string) {
40 if (!folder || !session) return { lines: [] as string[], isFirst: false }
41 const text = String(await $.fs.read(`${folder}/${session}.jsonl`).catch(() => ''))
42 const isFirst = !cursors.has(session)
43 let cursor = cursors.get(session) ?? 0
44 // A file shorter than what was read was deleted or emptied, so it starts over.
45 if (text.length < cursor) cursor = 0
46 const end = text.lastIndexOf('\n') + 1
47 cursors.set(session, Math.max(end, cursor))
48 return { lines: end > cursor ? text.slice(cursor, end).split('\n').filter(Boolean) : [], isFirst }
49}
50
51async function poll($: EngineInterface, session: string) {
52 const { lines, isFirst } = await readNew($, session)
53 const records: BridgeRecord[] = []
54 for (const line of lines) {
55 try {
56 const parsed: unknown = JSON.parse(line)
57 if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) records.push(parsed as BridgeRecord)
58 } catch {
59 // A corrupt line is skipped.
60 }
61 }
62 for (const record of isFirst ? lastOfEach(records) : records) await deliver(record)
63}
64
65// After a reload the file holds rows delivered before it, so only the last of each event is state worth replaying.
66export function lastOfEach(records: BridgeRecord[]) {
67 const last = new Map(records.map(record => [String(record.hook_event_name ?? ''), record]))
68 return records.filter(record => last.get(String(record.hook_event_name ?? '')) === record)
69}
70
71async function tick($: EngineInterface) {
72 if (isTicking) return
73 isTicking = true
74 try {
75 // Asked every tick, so a /clear or a resume reads the new session's file.
76 await poll($, String(await $.session.id()))
77 } catch {
78 // The next tick tries again.
79 } finally {
80 isTicking = false
81 }
82}
83
84// Starts the poll once; `mod` names the mod's folder under mods-data.
85export async function startBridge($: EngineInterface, mod: string) {
86 if (isStarted) return
87 isStarted = true
88 try {
89 folder = await folderOf($, mod)
90 if (!folder) return
91 $.clock.every(POLL_MS, () => void tick($))
92 await tick($)
93 } catch {
94 // Without the bridge the mod keeps to what it finds on its own.
95 }
96}
97
98// A session ended: what its file still holds is delivered; the SessionEnd command hook deletes the file.
99export async function endBridge($: EngineInterface, session: string) {
100 if (!folder || !session) return
101 try {
102 await poll($, session)
103 } catch {
104 // The next read of the file delivers it.
105 }
106}
107hooks/entry.tsx 241 lines1// hooks/entry.tsx, by v-amorim
2// Shared pill entry; every mod that draws a menu entry carries the same copy.
3import type { ClientModule, ClientSurface } from 'claude-code'
4
5import style from './style'
6import { CAPS, mix, THEME, WASH } from './theme'
7
8// color: open text (a red Run); idle: closed text, muted unless set; isBusy shimmers the label until the click's result lands.
9// still: the mod's animations setting is off, so nothing shimmers and the hover color switches at once.
10// lead: a bare glyph before the label in leadColor (after a space, none when tight: the cap is the gap); subLead: an icon before it as a rounded subpill in subLeadColor;
11// badge: a count after it in its own pill. Subpills are capped where `nerd` has the caps.
12// tight: no label padding; tip: posts `tip` instead of `menu-hover`; rail: a divider (a Text between Clients squeezes them); rule: a full-width hairline.
13// spinner: the braille dots lead the label in accent while work runs (write the label without its …); still holds the first frame.
14export type EntryProps = {
15 id: string
16 label: string
17 isOpen: boolean
18 bg: string
19 color?: string
20 idle?: string
21 isBusy?: boolean
22 still?: boolean
23 lead?: string
24 leadColor?: string
25 subLead?: string
26 subLeadColor?: string
27 badge?: { text: string; color: string; bg: string }
28 nerd?: boolean
29 tight?: boolean
30 tip?: Tip
31 rail?: boolean
32 rule?: boolean
33 spinner?: boolean
34 compact?: boolean // false: the pill row may cut the entry to its lead when the row is wider than the band
35}
36
37const entryText = (props: EntryProps) => {
38 const pad = props.tight ? (props.isOpen && !props.nerd ? ' ' : '') : ' '
39 return `${pad}${props.compact && props.lead ? '' : props.label}${props.tight && props.badge ? ' ' : pad}`
40}
41
42// The cells an entry takes; subpills add their two caps, or their two padding spaces in safe mode.
43export const entryCells = (props: EntryProps) => {
44 if (props.rail) return 1
45 const cells = (text: string) => [...text].length
46 const leadText = props.lead ?? (props.spinner ? SPIN_FRAMES[0]! : '')
47 const lead = leadText ? cells(leadText) + (props.tight ? 0 : 1) : 0
48 return lead + (props.subLead ? cells(props.subLead) + 2 : 0) + cells(entryText(props)) + (props.badge ? cells(props.badge.text) + 2 : 0)
49}
50
51// A hovered control's glyph, label and what it does; the pane footer and the band's tip row both show it.
52export type Tip = { glyph: string; label: string; text: string }
53
54// Posted to the owning mod's ui.message when the pointer enters a control (id set) or leaves it (id null).
55export type TipMessage = { type: 'tip'; id: string | null; glyph?: string; label?: string; text?: string }
56
57export const isTipMessage = (data: unknown): data is TipMessage => (data as TipMessage | undefined)?.type === 'tip'
58
59// The band's tip row text for a tip message: "Label: what it does", or '' once the pointer left.
60export const tipText = (message: TipMessage | null | undefined) => (message?.id && message.label ? `${message.label}: ${message.text ?? ''}` : '')
61
62// A control calls this from onPointer, with its tip on enter and null on leave; never also `menu-hover`, one post per frame lands.
63export const postTip = (surface: { post: (data: any) => void }, id: string, tip: Tip | null) => {
64 surface.post((tip ? { type: 'tip', id, glyph: tip.glyph, label: tip.label, text: tip.text } : { type: 'tip', id: null }) satisfies TipMessage)
65}
66
67// menu-hover tells the owning mod the pointer arrived or left, so it can show its tip under the row.
68export type EntryMessage = { type: 'menu-entry'; id: string } | { type: 'menu-hover'; id: string; isOver: boolean }
69
70// A dialog opening under the pointer can swallow the leave event, so a hover with no pointer event for this long ends on its own.
71const HOVER_QUIET_MS = 5000
72
73export const TICK_MS = 140
74
75// The loading shimmer: a soft primary-70 band sweeps the dim text, then a pause.
76const BAND = style.colors.shimmer
77const SWEEP_PAUSE = 8
78
79export const shimmerColor = (at: number, phase: number, length: number) => {
80 const period = length + BAND.length * 2 + SWEEP_PAUSE
81 const head = (phase % period) - BAND.length
82 return BAND[Math.abs(at - head)] ?? style.colors.pills.readout
83}
84
85// The braille dots spinner, one frame every SPIN_MS, in accent.
86export const SPIN_FRAMES = [...style.marks.spinner]
87export const SPIN_MS = 80
88
89// The pointer lights a control at once and its color fades out over this many steps, one every HOVER_TICK_MS.
90export const HOVER_STEPS = 2
91
92export const HOVER_TICK_MS = 60
93
94export { mix }
95
96// One step of a hover fade: full at once while the pointer is on, one step toward 0 once it left.
97export const stepToward = (level: number, isOver: boolean) => (isOver ? HOVER_STEPS : Math.max(0, level - 1))
98
99// Even steps: an eased last step barely moves the color, so the fade seemed to hang at its end.
100export const hoverMix = (from: string, to: string, level: number) => mix(from, to, level / HOVER_STEPS)
101
102// A click shimmers the entry until the mod answers with new props, or this long at most.
103const PRESS_MS = 1200
104
105// pressed: the props a click saw and when it came, cleared once the mod answers.
106type State = { isOver: boolean; at: number; phase: number; level?: number; spin?: number; pressed?: { props: string; at: number } }
107
108const isPressed = (state: State | undefined, props: EntryProps | undefined) =>
109 Boolean(state?.pressed && Date.now() - state.pressed.at < PRESS_MS && state.pressed.props === JSON.stringify(props))
110
111// Each instance's running fade-out timer, started by the leave event and stopped at 0 or when the pointer returns.
112const fades = new WeakMap<object, () => void>()
113
114const startFade = (surface: ClientSurface<State>) => {
115 if (fades.has(surface)) return
116 const stop = surface.every(HOVER_TICK_MS, () => {
117 const { state } = surface
118 if (!state) return
119 const level = stepToward(state.level ?? 0, state.isOver)
120 if (level !== (state.level ?? 0)) surface.setState({ ...state, level })
121 if (state.isOver || level === 0) {
122 stop()
123 fades.delete(surface)
124 }
125 })
126 fades.set(surface, stop)
127}
128
129// The ticker reads the latest props from here, since its closure keeps the first ones.
130const latest = new WeakMap<object, EntryProps>()
131
132// Each instance's running tick: it runs only while a shimmer moves or a hover is watched, so idle pills cost no wakeups.
133const ticks = new WeakMap<object, () => void>()
134
135const startTick = (surface: ClientSurface<State>) => {
136 if (ticks.has(surface)) return
137 const stop = surface.every(TICK_MS, () => {
138 const { state } = surface
139 if (!state) return
140 const now = latest.get(surface)
141 if (!now?.still && (now?.isBusy || isPressed(state, now))) surface.setState({ ...state, phase: state.phase + 1 })
142 else if (state.pressed) surface.setState({ ...state, pressed: undefined })
143 else if (state.isOver && Date.now() - state.at >= HOVER_QUIET_MS) {
144 surface.setState({ ...state, isOver: false, at: Date.now() })
145 if (!now?.still) startFade(surface)
146 const id = now?.id ?? ''
147 if (now?.tip) postTip(surface, id, null)
148 else surface.post({ type: 'menu-hover', id, isOver: false } satisfies EntryMessage)
149 } else if (!state.isOver) {
150 stop()
151 ticks.delete(surface)
152 }
153 })
154 ticks.set(surface, stop)
155}
156
157// Each spinning instance's frame clock, stopped once the entry stops spinning.
158const spins = new WeakMap<object, () => void>()
159
160const startSpin = (surface: ClientSurface<State>) => {
161 if (spins.has(surface)) return
162 const stop = surface.every(SPIN_MS, () => {
163 const { state } = surface
164 const now = latest.get(surface)
165 if (!state || !now?.spinner || now.still) {
166 stop()
167 spins.delete(surface)
168 return
169 }
170 surface.setState({ ...state, spin: (state.spin ?? 0) + 1 })
171 })
172 spins.set(surface, stop)
173}
174
175// A Button inverts under the pointer, so the entry is plain text that lights up like a sidebar row.
176const Entry: ClientModule<EntryProps, State> = (props, surface) => {
177 const { Box, Text } = surface.elements
178 if (props.rail) return <Text color={style.colors.pills.separator} backgroundColor={props.bg}>{style.marks.rail}</Text>
179 if (props.rule) return <Text color={THEME.border} backgroundColor={props.bg}>{style.marks.rule.repeat(Math.max(1, surface.columns))}</Text>
180 const hovered = surface.state?.isOver === true
181 latest.set(surface, props)
182
183 if (!surface.state) surface.setState({ isOver: false, at: 0, phase: 0 })
184 if (props.isBusy && !props.still) startTick(surface)
185 if (props.spinner && !props.still) startSpin(surface)
186
187 surface.onPointer(event => {
188 startTick(surface)
189 const over = event.type !== 'leave'
190 if (over !== hovered) {
191 if (props.tip) postTip(surface, props.id, over ? props.tip : null)
192 else surface.post({ type: 'menu-hover', id: props.id, isOver: over } satisfies EntryMessage)
193 }
194 const isClick = event.type === 'down' && event.button === 'left'
195 const base = surface.state ?? { phase: 0, isOver: false, at: 0 }
196 const level = over === base.isOver ? base.level : over ? HOVER_STEPS : props.still ? 0 : base.level
197 if (!over && !props.still) startFade(surface)
198 surface.setState({ ...base, isOver: over, at: Date.now(), level, pressed: isClick ? { props: JSON.stringify(props), at: Date.now() } : base.pressed })
199 if (isClick) surface.post({ type: 'menu-entry', id: props.id } satisfies EntryMessage)
200 })
201
202 const text = entryText(props)
203 const isShimmering = !props.still && (props.isBusy || isPressed(surface.state, props))
204 const phase = surface.state?.phase ?? 0
205 const label = isShimmering ? (
206 <Box key="label" flexDirection="row">
207 {[...text].map((char, at) => (
208 <Text key={`c${at}`} color={shimmerColor(at, phase, text.length)} backgroundColor={props.bg}>
209 {char}
210 </Text>
211 ))}
212 </Box>
213 ) : (
214 <Text key="label" color={hoverMix(props.isOpen ? (props.color ?? THEME.onSurface) : (props.idle ?? THEME.muted), THEME.accent, surface.state?.level ?? 0)} bold={props.isOpen && Boolean(props.idle)} backgroundColor={props.bg}>
215 {text.split(style.marks.sep).flatMap((part, at) => (at === 0 ? [part] : [<Text key={`sep-${at}`} color={style.colors.pills.separator}>{style.marks.sep}</Text>, part]))}
216 </Text>
217 )
218 const lead = props.spinner ? SPIN_FRAMES[props.still ? 0 : (surface.state?.spin ?? 0) % SPIN_FRAMES.length] : props.lead
219 if (!lead && !props.subLead && !props.badge) return isShimmering ? <Box flexDirection="row">{label}</Box> : label
220
221 const sub = (key: string, text: string, color: string, bg: string) => (
222 <Box key={key} flexDirection="row">
223 {props.nerd ? <Text key={`${key}-left`} color={bg} backgroundColor={props.bg}>{CAPS.left}</Text> : null}
224 <Text key={`${key}-text`} color={color} backgroundColor={bg} bold>{props.nerd ? text : ` ${text} `}</Text>
225 {props.nerd ? <Text key={`${key}-right`} color={bg} backgroundColor={props.bg}>{CAPS.right}</Text> : null}
226 </Box>
227 )
228 const subHue = props.subLeadColor ?? THEME.accent
229 const { badge } = props
230 return (
231 <Box flexDirection="row">
232 {lead ? <Text key="lead" color={props.spinner ? THEME.accent : (props.leadColor ?? THEME.accent)} backgroundColor={props.bg}>{props.tight ? lead : ` ${lead}`}</Text> : null}
233 {props.subLead ? sub('sub-lead', props.subLead, subHue, mix(subHue, props.bg, WASH)) : null}
234 {label}
235 {badge ? sub('badge', badge.text, badge.color, badge.bg) : null}
236 </Box>
237 )
238}
239
240export default Entry
241hooks/look.tsx 279 lines1// hooks/look.tsx, by v-amorim
2// Shared look; every mod carries the same copy.
3import type { RenderElement } from 'claude-code'
4
5import style from './style'
6import type { Tip } from './entry'
7import { CAPS, decodeAll, MARK, mix, THEME, WASH } from './theme'
8
9type Glyphs = Omit<typeof style.glyphs.nerd, 'icon' | 'mark'> & { icon: Record<string, string>; mark: Record<string, string> }
10
11// Safe mode: common Unicode that every modern terminal font draws. Headings and rows carry no icon.
12const SAFE: Glyphs = decodeAll(style.glyphs.safe)
13
14// Nerd mode: Font Awesome, Material and VS Code glyphs from any Nerd Font, controls padded to the same three cells.
15const NERD: Glyphs = decodeAll(style.glyphs.nerd)
16
17export type Look = { nerd: boolean; glyph: typeof SAFE }
18
19export const lookFor = (nerd: boolean): Look => ({ nerd, glyph: nerd ? NERD : SAFE })
20
21// The icons setting every drawing mod declares in its userConfig.
22export type IconMode = 'auto' | 'nerd' | 'safe'
23
24export const terminalSettings = (localAppData: string) => [
25 `${localAppData}/Packages/Microsoft.WindowsTerminal_8wekyb3d8bbwe/LocalState/settings.json`,
26 `${localAppData}/Microsoft/Windows Terminal/settings.json`,
27]
28
29// Matches "Fira Code Nerd Font Mono" and the patched suffixes NF, NFM and NFP, as in "FiraCodeNFM-Reg".
30export const isNerdName = (face: string) => /nerd|\bNFM?\b/i.test(face) || /NF[MP]?(?![A-Za-z])/.test(face)
31
32export const isNerdFace = (settingsText: string) => [...settingsText.matchAll(/"face"\s*:\s*"([^"]*)"/g)].some(match => isNerdName(match[1]!))
33
34// The variables that name the terminal: WT_SESSION from Windows Terminal, TERM_PROGRAM from programs such as mintty and iTerm2.
35export type TerminalEnv = Partial<
36 Record<'WT_SESSION' | 'TERM_PROGRAM' | 'ITERM_PROFILE' | 'KITTY_WINDOW_ID' | 'ALACRITTY_WINDOW_ID' | 'GNOME_TERMINAL_SCREEN' | 'KONSOLE_VERSION', string | undefined>
37>
38
39// What detection reads: files by path, and the stdout of a command that exits 0 (it throws otherwise).
40export type Host = { localAppData: string; home: string; read: (path: string) => Promise<string>; run: (argv: string[]) => Promise<string> }
41
42// mintty inherits WT_SESSION from a Windows Terminal tab, so only an unset or Windows Terminal TERM_PROGRAM counts.
43export const isWindowsTerminal = ({ WT_SESSION, TERM_PROGRAM }: TerminalEnv) =>
44 Boolean(WT_SESSION) && (!TERM_PROGRAM || /windows\s*terminal/i.test(TERM_PROGRAM))
45
46// Terminals whose font sits in a text config: the first file that names a font wins, and within a file the first pattern.
47const CONFIG_TERMINALS: { is: (env: TerminalEnv) => boolean; files: (host: Host) => string[]; faces: RegExp[] }[] = [
48 {
49 is: env => env.TERM_PROGRAM === 'vscode',
50 files: ({ home, localAppData }) => [
51 `${home}/Library/Application Support/Code/User/settings.json`,
52 `${home}/.config/Code/User/settings.json`,
53 `${localAppData.replace(/\/Local$/, '/Roaming')}/Code/User/settings.json`,
54 ],
55 faces: [/"terminal\.integrated\.fontFamily"\s*:\s*"([^"]*)"/, /"editor\.fontFamily"\s*:\s*"([^"]*)"/],
56 },
57 {
58 is: env => env.TERM_PROGRAM === 'ghostty',
59 files: ({ home }) => [`${home}/.config/ghostty/config`, `${home}/Library/Application Support/com.mitchellh.ghostty/config`],
60 faces: [/^\s*font-family\s*=\s*"?([^"\n#]+)/m],
61 },
62 {
63 is: env => Boolean(env.KITTY_WINDOW_ID),
64 files: ({ home }) => [`${home}/.config/kitty/kitty.conf`],
65 faces: [/^\s*font_family\s+(.+)$/m],
66 },
67 {
68 is: env => env.TERM_PROGRAM === 'WezTerm',
69 files: ({ home }) => [`${home}/.wezterm.lua`, `${home}/.config/wezterm/wezterm.lua`],
70 faces: [/wezterm\.font(?:_with_fallback)?\(\s*\{?\s*["']([^"']+)["']/],
71 },
72 {
73 is: env => Boolean(env.ALACRITTY_WINDOW_ID),
74 files: ({ home }) => [`${home}/.config/alacritty/alacritty.toml`],
75 faces: [/\[font\.normal\][^[]*?family\s*=\s*"([^"]+)"/],
76 },
77]
78
79// iTerm2 keeps its profiles in a plist; plutil prints the part asked for. The profile is the one this window runs, else the default.
80async function itermFace(env: TerminalEnv, host: Host) {
81 const plist = `${host.home}/Library/Preferences/com.googlecode.iterm2.plist`
82 const profiles: { Name?: string; Guid?: string; 'Normal Font'?: string }[] = JSON.parse(await host.run(['plutil', '-extract', 'New Bookmarks', 'json', '-o', '-', plist]))
83 const named = profiles.find(profile => profile.Name === env.ITERM_PROFILE)
84 if (named) return named['Normal Font']
85 const guid = (await host.run(['plutil', '-extract', 'Default Bookmark Guid', 'raw', '-o', '-', plist])).trim()
86 return profiles.find(profile => profile.Guid === guid)?.['Normal Font']
87}
88
89// GNOME Terminal keeps the default profile's font in dconf, and ignores it while the profile uses the system font.
90async function gnomeFace(host: Host) {
91 const unquote = (text: string) => text.trim().replace(/^'|'$/g, '')
92 const schema = `org.gnome.Terminal.Legacy.Profile:/org/gnome/terminal/legacy/profiles:/:${unquote(await host.run(['gsettings', 'get', 'org.gnome.Terminal.ProfilesList', 'default']))}/`
93 if (unquote(await host.run(['gsettings', 'get', schema, 'use-system-font'])) === 'true') return undefined
94 return unquote(await host.run(['gsettings', 'get', schema, 'font']))
95}
96
97// Konsole names its profile in konsolerc, and the profile file holds `Font=Name,size,...`.
98async function konsoleFace(host: Host) {
99 const profile = /^DefaultProfile=(.+)$/m.exec(await host.read(`${host.home}/.config/konsolerc`))?.[1]?.trim()
100 return profile ? /^Font=([^,\n]+)/m.exec(await host.read(`${host.home}/.local/share/konsole/${profile}`))?.[1] : undefined
101}
102
103async function configFace(terminal: (typeof CONFIG_TERMINALS)[number], host: Host) {
104 for (const path of terminal.files(host)) {
105 const text = await host.read(path).catch(() => '')
106 for (const pattern of terminal.faces) {
107 const face = pattern.exec(text)?.[1]
108 if (face) return face
109 }
110 }
111 return undefined
112}
113
114function terminalFace(env: TerminalEnv, host: Host) {
115 if (env.TERM_PROGRAM === 'iTerm.app') return itermFace(env, host)
116 if (env.GNOME_TERMINAL_SCREEN) return gnomeFace(host)
117 if (env.KONSOLE_VERSION) return konsoleFace(host)
118 const terminal = CONFIG_TERMINALS.find(candidate => candidate.is(env))
119 return terminal ? configFace(terminal, host) : Promise.resolve(undefined)
120}
121
122// Every mod looks up the font on start at once, so a lookup can fail by chance; a third failure throws and the caller keeps its saved answer.
123async function retried<T>(attempt: () => Promise<T>, tries = 3): Promise<T> {
124 for (let left = tries - 1; ; left--) {
125 try {
126 return await attempt()
127 } catch (error) {
128 if (left === 0) throw error
129 }
130 }
131}
132
133// Nerd glyphs only when the terminal this window runs in names a Nerd font; an unknown terminal stays safe, and a lookup that keeps failing throws.
134export async function detectNerd(env: TerminalEnv, host: Host) {
135 if (isWindowsTerminal(env)) {
136 for (const path of terminalSettings(host.localAppData)) {
137 if (isNerdFace(await host.read(path).catch(() => ''))) return true
138 }
139 return false
140 }
141 const face = await retried(() => terminalFace(env, host))
142 return face !== undefined && isNerdName(face)
143}
144
145// `text` led by its icon in Nerd mode; the text alone in safe mode or when the name has no icon.
146export const withIcon = (look: Look, name: string, text: string) => (look.glyph.icon[name] ? `${look.glyph.icon[name]} ${text}` : text)
147
148// A status note with its leading mark (MARK.warn or MARK.ok from theme.ts) drawn as this look's glyph.
149export function withMark(look: Look, note: string) {
150 if (note.startsWith('! ')) return `${look.glyph.warn} ${note.slice(2)}`
151 if (note.startsWith('✓ ')) return `${look.glyph.ok} ${note.slice(2)}`
152 return note
153}
154
155type Elements = { Box: (props: any) => RenderElement; Text: (props: any) => RenderElement }
156
157// A pane's title line: the name's icon in Nerd mode, the safe mark otherwise.
158export const paneTitle = ({ Text }: Elements, look: Look, icon: string, text: string) => (
159 <Text key="pane-title" color={THEME.accent} bold>{`${look.glyph.icon[icon] ?? look.glyph.title} ${text}`}</Text>
160)
161
162// One line of text in `color` with every separator dot in the separator colour, since a dot in the text's colour reads as part of the words.
163export function sepText({ Text }: Pick<Elements, 'Text'>, text: string, color: string, key: string, props: Record<string, unknown> = {}) {
164 return (
165 <Text key={key} {...props}>
166 {text.split(MARK.sep).flatMap((part, at) => [
167 ...(at > 0 ? [<Text key={`${key}-sep-${at}`} color={style.colors.pills.separator}>{MARK.sep}</Text>] : []),
168 <Text key={`${key}-part-${at}`} color={color}>{part}</Text>,
169 ])}
170 </Text>
171 )
172}
173
174// The footer rows every pane reserves at its bottom: a rule, then the hovered control's glyph, label and what it does.
175export const FOOTER_ROWS = 2
176
177// A Button cannot post a tip, so its tip is drawn hidden and shown by the hover group the Button's pill shares.
178export type Reveal = { scope: string; tip: Tip }
179
180const tipRow = ({ Box, Text }: Elements, key: string, tip: Tip | null, columns: number, extra: Record<string, unknown> = {}) => (
181 <Box key={key} flexDirection="row" paddingX={1} width={columns} height={1} backgroundColor={THEME.bg} {...extra}>
182 {tip ? <Text color={THEME.accent} backgroundColor={THEME.bg}>{tip.glyph}</Text> : null}
183 {tip ? <Box flexShrink={0}><Text color={THEME.onSurface} backgroundColor={THEME.bg}>{` ${tip.label}`}</Text></Box> : null}
184 {tip ? sepText({ Text }, ` ${MARK.sep} ${tip.text}`, THEME.muted, `${key}-text`, { backgroundColor: THEME.bg, wrap: 'truncate-end' }) : <Text backgroundColor={THEME.bg}> </Text>}
185 </Box>
186)
187
188// Pinned at the pane bottom (scroll offset plus body rows) on its own background, so no row shows through; the row under the rule stays blank with no tip, so nothing jumps.
189export function paneFooter(elements: Elements, tip: Tip | null, columns: number, scroll: { offset: number; bodyRows: number }, reveals: Reveal[] = []) {
190 const { Box, Text } = elements
191 return (
192 <Box key="pane-footer" position="absolute" top={Math.max(0, scroll.offset + scroll.bodyRows - FOOTER_ROWS)} left={0} width={columns} height={FOOTER_ROWS} flexDirection="column" backgroundColor={THEME.bg}>
193 <Text key="footer-rule" color={THEME.border} backgroundColor={THEME.bg}>{MARK.rule.repeat(Math.max(1, columns))}</Text>
194 {tipRow(elements, 'footer-tip', tip, columns)}
195 {reveals.map((reveal, at) =>
196 tipRow(elements, `footer-reveal-${at}`, reveal.tip, columns, { position: 'absolute', top: 1, left: 0, display: 'none', hover: { scope: reveal.scope, display: 'flex' } }),
197 )}
198 </Box>
199 )
200}
201
202// A pill's leading icon, mark or digit, or its count: a rounded subpill washed from `hue` into the `fill` it sits on.
203export function subpill({ Box, Text }: Elements, nerd: boolean, fill: string, text: string, key: string, hue: string = THEME.accent) {
204 const bg = mix(hue, fill, WASH)
205 return (
206 <Box key={key} flexDirection="row" flexShrink={0}>
207 {nerd ? <Text key={`${key}-left`} color={bg} backgroundColor={fill}>{CAPS.left}</Text> : null}
208 <Text key={`${key}-text`} color={hue} backgroundColor={bg} bold>{nerd ? text : ` ${text} `}</Text>
209 {nerd ? <Text key={`${key}-right`} color={bg} backgroundColor={fill}>{CAPS.right}</Text> : null}
210 </Box>
211 )
212}
213
214// A section's heading: a surface3 pill led by its icon as a subpill in Nerd mode, bold accent text in safe mode.
215export function heading(elements: Elements, look: Look, key: string, icon: string, title: string) {
216 const { Box, Text } = elements
217 if (!look.nerd) {
218 return (
219 <Box key={`${key}-head`}>
220 <Text color={THEME.accent} bold>{title}</Text>
221 </Box>
222 )
223 }
224 const glyph = look.glyph.icon[icon]
225 return (
226 <Box key={`${key}-head`} flexDirection="row">
227 <Text color={THEME.surface3}>{CAPS.left}</Text>
228 <Box key={`${key}-head-body`} flexDirection="row" backgroundColor={THEME.surface3}>
229 {glyph ? subpill(elements, true, THEME.surface3, glyph, `${key}-head-icon`) : null}
230 <Text color={THEME.onSurface} bold>{glyph ? ` ${title}` : title}</Text>
231 </Box>
232 <Text color={THEME.surface3}>{CAPS.right}</Text>
233 </Box>
234 )
235}
236
237// A card's fill: surface1 washed lightly toward its hue, under a neutral border. Pills on the card sit on it.
238export const cardFill = (hue: string = THEME.accent) => mix(THEME.surface1, hue, 0.06)
239
240// The row above a card, painted on bg so a scrolled card above never shows through the gap.
241export function cardGap({ Box }: Elements, key: string, bg: string = THEME.bg) {
242 return <Box key={`${key}-gap`} height={1} flexShrink={0} backgroundColor={bg} />
243}
244
245// A card's title laid over its top border after the corner (`top` is the border's row); Pane refuses borderTop, so the border stays whole underneath.
246export function titleEdge(elements: Elements, look: Look, key: string, icon: string, title: string, hint = '', top = 1, edge: string = THEME.border) {
247 const { Box, Text } = elements
248 const glyph = look.nerd ? look.glyph.icon[icon] : undefined
249 return (
250 <Box key={`${key}-edge`} position="absolute" top={top} left={1} flexDirection="row" height={1} backgroundColor={THEME.bg}>
251 <Text color={edge}>{`${MARK.rule} `}</Text>
252 {glyph ? <Text color={THEME.accent}>{`${glyph} `}</Text> : null}
253 {sepText(elements, title, THEME.onSurface, `${key}-title`, { bold: true })}
254 {hint ? sepText(elements, ` ${hint}`, THEME.muted, `${key}-hint`) : null}
255 <Text> </Text>
256 </Box>
257 )
258}
259
260// A round card tinted toward `hue`, its title (and muted `hint`) set into the top edge, `right` (such as Fold all) at that edge's end.
261export function section(elements: Elements, look: Look, key: string, icon: string, title: string, body: unknown, hue: string = THEME.accent, { hint = '', right }: { hint?: string; right?: unknown } = {}) {
262 const { Box } = elements
263 return (
264 <Box key={`${key}-block`} flexDirection="column">
265 {cardGap(elements, key)}
266 <Box key={key} flexDirection="column" borderStyle="round" borderColor={THEME.border} backgroundColor={cardFill(hue)} paddingX={1}>
267 {body}
268 </Box>
269 {title ? titleEdge(elements, look, key, icon, title, hint) : null}
270 {right ? (
271 <Box key={`${key}-edge-right`} position="absolute" top={1} right={1} flexDirection="row" height={1} backgroundColor={THEME.bg}>
272 {right}
273 </Box>
274 ) : null}
275 </Box>
276 )
277}
278
279hooks/loops.tsx 260 lines1// mod-progress / hooks/loops.tsx, by v-amorim
2// The pane's list: a card per loop, two rows each, its gates under it when clicked.
3import type { ClientModule, RenderChildren } from 'claude-code'
4
5import { postTip } from './entry'
6import { cardFill, lookFor } from './look'
7import marks from './marks'
8import { countGates, cutText, formatElapsed, gaugeColor, formatTokens, groupGates, modelInfo, stripCells, type Gate, type GateState, type Row } from './progress'
9import { breathColor, keepSpinning, spinFrame, type SpinState } from './spin'
10import style from './style'
11import text from './text'
12import { CAPS, decodeAll, fill, MARK, THEME } from './theme'
13
14export type LoopsProps = { rows: Row[]; width: number; expanded: string; nerd: boolean; viewing: string; folded?: string[]; now?: number; still?: boolean }
15
16const GROUPS: Record<GateState, { label: string; color: string }> = {
17 open: { label: text.groupOpen, color: THEME.accent },
18 pending: { label: text.groupWaiting, color: THEME.warn },
19 met: { label: text.groupMet, color: THEME.ok },
20 abandoned: { label: text.groupDropped, color: THEME.alert },
21}
22export type LoopsMessage = { type: 'toggle'; id: string } | { type: 'task'; id: string } | { type: 'fold'; id: string }
23
24// The count a folded card shows, and the toggle's label on an open one: shells when every task is one.
25export const tasksLabel = (tasks: Row['tasks']) => {
26 const isShells = tasks.every(task => task.kind === 'shell')
27 const n = tasks.length
28 return n === 1 ? (isShells ? text.shellOne : text.taskOne) : fill(isShells ? text.shellMany : text.taskMany, { n })
29}
30
31// The tip of a card's task toggle.
32export const foldTip = (row: Row, isFolded: boolean, glyph: string) => ({ glyph, label: tasksLabel(row.tasks), text: isFolded ? text.tipFoldShut : text.tipFoldOpen })
33
34type Line = { kind: 'head' | 'pills' | 'task' | 'gate' | 'ghead' | 'gfold' | 'empty' | 'gap' | 'run'; loop: number; group?: number; gate?: number; task?: number; run?: string }
35
36// The rows top to bottom: a line naming the workflow run where its agents start, then head, pills, a line per background task, the open loop's gate groups (a header each, a row per open or waiting gate, one line for met or dropped), and a gap before the next card.
37export function layoutLoops(rows: Row[], expanded: string, folded: string[] = []) {
38 const lines: Line[] = []
39 rows.forEach((row, loop) => {
40 if (row.run && row.run !== rows[loop - 1]?.run) lines.push({ kind: 'run', loop: -1, run: row.run })
41 lines.push({ kind: 'head', loop }, { kind: 'pills', loop })
42 if (!folded.includes(row.id)) row.tasks.forEach((_, task) => lines.push({ kind: 'task', loop, task }))
43 if (row.id === expanded) {
44 if (row.gates.length === 0) lines.push({ kind: 'empty', loop })
45 groupGates(row.gates).forEach((group, at) => {
46 lines.push({ kind: 'ghead', loop, group: at })
47 if (group.state === 'met' || group.state === 'abandoned') lines.push({ kind: 'gfold', loop, group: at })
48 else group.gates.forEach((_, gate) => lines.push({ kind: 'gate', loop, group: at, gate }))
49 })
50 }
51 if (loop < rows.length - 1) lines.push({ kind: 'gap', loop: -1 })
52 })
53 return lines
54}
55
56const LEVELS = style.levels as Record<string, { glyph: string; color: string }>
57
58// @ts-expect-error Row holds undefined fields, which the host's JsonValue props type refuses.
59const Loops: ClientModule<LoopsProps, SpinState> = (props, surface) => {
60 const { Box, Text } = surface.elements
61 const { rows, width, expanded, nerd, viewing } = props
62 const folded = props.folded ?? []
63 const look = lookFor(nerd)
64 const own = decodeAll(marks.glyphs)[nerd ? 'nerd' : 'safe']
65 const lines = layoutLoops(rows, expanded, folded)
66 const spin = props.still ? 0 : (surface.state?.spin ?? 0)
67 keepSpinning(surface, rows.some(row => row.status === 'running' || row.tasks.some(task => task.status === 'running')), props.still)
68 // The state's `at` is the line under the pointer, times two, plus one over a card's task toggle: a task line is its own target, any other line stands for its card.
69 const pointed = surface.state?.at ?? -1
70 const hoveredAt = pointed >= 0 ? Math.floor(pointed / 2) : -1
71 const isFoldHover = pointed >= 0 && pointed % 2 === 1
72 const setPointed = (at: number) => surface.setState({ at, spin: surface.state?.spin ?? 0 })
73 const taskOf = (at: number) => {
74 const line = lines[at]
75 return line?.kind === 'task' ? rows[line.loop]?.tasks[line.task!] : undefined
76 }
77 const foldText = (row: Row) => `${folded.includes(row.id) ? look.glyph.collapsed : look.glyph.expanded} ${tasksLabel(row.tasks)} `
78 // The toggle sits at the right end of the card's second row, when the card has background tasks.
79 const isFoldAt = (at: number, x: number) => {
80 const line = lines[at]
81 const row = line?.kind === 'pills' ? rows[line.loop] : undefined
82 return row !== undefined && row.tasks.length > 0 && x >= width - [...foldText(row)].length
83 }
84 const targetOf = (at: number, isFold: boolean) => (taskOf(at) ? `task:${taskOf(at)!.id}` : `${isFold ? 'fold' : 'loop'}:${lines[at]?.loop ?? -1}`)
85
86 surface.onPointer(event => {
87 const at = event.type === 'leave' || (lines[event.y]?.loop ?? -1) < 0 ? -1 : event.y
88 const isFold = at >= 0 && isFoldAt(at, event.x)
89 const next = at < 0 ? -1 : at * 2 + (isFold ? 1 : 0)
90 if (next !== (surface.state?.at ?? -1)) {
91 setPointed(next)
92 if (targetOf(at, isFold) !== targetOf(hoveredAt, isFoldHover)) {
93 const task = taskOf(at)
94 const row = rows[lines[at]?.loop ?? -1]
95 postTip(surface, 'loops', task ? { glyph: task.kind === 'shell' ? look.glyph.icon.Bash ?? look.glyph.expanded : look.glyph.expanded, label: task.kind === 'shell' ? text.taskShell : text.taskMonitor, text: text.tipTask } : row && isFold ? foldTip(row, folded.includes(row.id), folded.includes(row.id) ? look.glyph.collapsed : look.glyph.expanded) : row ? { glyph: row.id === expanded ? look.glyph.expanded : look.glyph.collapsed, label: row.name, text: text.tipLoop } : null)
96 }
97 }
98 if (event.type !== 'down' || event.button !== 'left' || at < 0) return
99 const task = taskOf(at)
100 if (task) surface.post({ type: 'task', id: task.id } satisfies LoopsMessage)
101 else if (isFold) surface.post({ type: 'fold', id: rows[lines[at]!.loop]!.id } satisfies LoopsMessage)
102 else surface.post({ type: 'toggle', id: rows[lines[at]!.loop]!.id } satisfies LoopsMessage)
103 })
104
105 // A pill on the card: rounded caps on Nerd, a space each side on safe.
106 const pill = (key: string, on: string, body: RenderChildren) => (
107 <Box key={key} flexDirection="row" flexShrink={0}>
108 <Text key={`${key}-left`} color={THEME.surface3} backgroundColor={on}>{nerd ? CAPS.left : ' '}</Text>
109 <Box key={`${key}-body`} flexDirection="row" backgroundColor={THEME.surface3}>{body}</Box>
110 <Text key={`${key}-right`} color={THEME.surface3} backgroundColor={on}>{nerd ? CAPS.right : ' '}</Text>
111 </Box>
112 )
113
114 const drawLine = (line: Line, at: number) => {
115 const key = `line-${at}`
116 if (line.kind === 'gap') return <Box key={key} height={1} />
117 if (line.kind === 'run') {
118 const members = rows.filter(each => each.run === line.run)
119 const label = fill(text.workflowRun, { id: line.run!.replace(/^wf_/, '').slice(0, 6), sep: MARK.sep, done: members.filter(each => each.status !== 'running').length, total: members.length })
120 return <Box key={key} height={1} width={width} paddingLeft={1}><Text wrap="truncate-end" color={THEME.secondary} bold>{label}</Text></Box>
121 }
122 const row = rows[line.loop]!
123 const isHover = line.kind === 'task' ? hoveredAt === at : lines[hoveredAt]?.kind !== 'task' && (lines[hoveredAt]?.loop ?? -1) === line.loop
124 const on = isHover ? THEME.lifted : cardFill()
125 const isViewed = line.kind === 'head' && row.id === viewing
126 const frame = (inner: RenderChildren) => (
127 <Box key={key} height={1} width={width} flexDirection="row" backgroundColor={on}>
128 <Text key={`${key}-lead`}>{' '}</Text>
129 <Text key={`${key}-pad`} color={THEME.accent} bold>{isViewed ? MARK.cursor : ' '}</Text>
130 {inner}
131 </Box>
132 )
133
134 if (line.kind === 'head') {
135 const mark = row.status === 'running' ? { glyph: spinFrame(spin), color: THEME.accent } : row.status === 'done' ? { glyph: look.glyph.ok, color: THEME.good } : { glyph: look.glyph.warn, color: THEME.alert }
136 const right = fill(text.tokensTime, { tokens: formatTokens(row.tokens), sep: MARK.sep, time: row.elapsed || '0s' })
137 return frame(
138 <Box key={`${key}-body`} flexDirection="row" flexGrow={1} columnGap={1}>
139 <Text key={`${key}-mark`} color={mark.color}>{mark.glyph}</Text>
140 <Box key={`${key}-name`} flexGrow={1} flexShrink={1}>
141 <Text wrap="truncate-end">
142 <Text color={row.hue}>{`${own.agentDot} `}</Text>
143 <Text color={isHover || isViewed ? THEME.accent : THEME.onSurface} bold>{row.name}</Text>
144 <Text color={THEME.muted}>{row.description ? ` ${row.description}` : ''}</Text>
145 </Text>
146 </Box>
147 <Box key={`${key}-right`} flexShrink={0}>
148 <Text color={THEME.muted}>{`${right} `}</Text>
149 </Box>
150 </Box>,
151 )
152 }
153
154 if (line.kind === 'pills') {
155 const info = modelInfo(row.model)
156 const level = row.effort ? LEVELS[row.effort] : undefined
157 const gates = countGates(row.gates)
158 const gauge = row.percent === undefined ? 0 : Math.round((row.percent / 100) * marks.gaugeCells)
159 const countText = ` ${fill(text.gatesCount, { done: gates.done, total: gates.total })}${row.isPhaseDone ? ` ${MARK.sep} ${text.phaseDone}` : ''}`
160 const before = [row.model ? 2 + [...info.name].length + (level ? 2 : 0) : 0, row.percent === undefined ? 0 : 2 + 3 + marks.gaugeCells + fill(text.gaugePercent, { percent: row.percent }).length].filter(cols => cols > 0)
161 // The card's lead and padding, the pills before this one with their gaps, this pill's caps and count, the fold toggle, and a column of slack.
162 const room = width - 4 - before.reduce((sum, cols) => sum + cols + 1, 0) - 2 - [...countText].length - (row.tasks.length > 0 ? [...foldText(row)].length : 0) - 1
163 const cells = stripCells(row.gates, room)
164 // On a running card the first open gate is the one being worked: it breathes, the rest wait on surface-4.
165 const breathing = row.status === 'running' ? cells.indexOf('open') : -1
166 const cellOf = (state: GateState, at: number) => (state === 'abandoned' ? { glyph: own.gateDropped, color: THEME.alert } : state === 'open' ? { glyph: own.gateOpen, color: at === breathing ? breathColor(spin) : THEME.lifted } : { glyph: own.gateMet, color: state === 'met' ? THEME.accent : THEME.warn })
167 const isFoldOver = isHover && isFoldHover
168 return frame(
169 <Box key={`${key}-row`} flexDirection="row" flexGrow={1}>
170 <Box key={`${key}-body`} flexDirection="row" columnGap={1} paddingLeft={2}>
171 {row.model ? pill(`${key}-model`, on, (
172 <Text color={info.hue} bold>
173 {info.name}
174 {level ? <Text color={level.color}>{` ${level.glyph}`}</Text> : null}
175 </Text>
176 )) : null}
177 {row.percent === undefined ? null : pill(`${key}-gauge`, on, (
178 <Text>
179 <Text color={gaugeColor(row.percent)}>{`${own.gaugeIcon} ${own.gaugeFull.repeat(gauge)}`}</Text>
180 <Text color={THEME.lifted}>{own.gaugeEmpty.repeat(marks.gaugeCells - gauge)}</Text>
181 <Text color={gaugeColor(row.percent)}>{` ${fill(text.gaugePercent, { percent: row.percent })}`}</Text>
182 </Text>
183 ))}
184 {row.hasGates ? pill(`${key}-gates`, on, (
185 <Text>
186 {cells.map((state, at) => <Text key={`${key}-cell-${at}`} color={cellOf(state, at).color}>{cellOf(state, at).glyph}</Text>)}
187 <Text color={THEME.muted}>{countText}</Text>
188 </Text>
189 )) : null}
190 </Box>
191 <Box key={`${key}-gap`} flexGrow={1} />
192 {row.tasks.length === 0 ? null : (
193 <Box key={`${key}-fold`} flexShrink={0}>
194 <Text>
195 <Text color={THEME.accent}>{folded.includes(row.id) ? look.glyph.collapsed : look.glyph.expanded}</Text>
196 <Text color={isFoldOver ? THEME.accent : THEME.muted}>{` ${tasksLabel(row.tasks)} `}</Text>
197 </Text>
198 </Box>
199 )}
200 </Box>,
201 )
202 }
203
204 if (line.kind === 'task') {
205 const task = row.tasks[line.task!]!
206 const mark = task.status === 'running' ? { glyph: spinFrame(spin), color: THEME.accent } : task.status === 'done' ? { glyph: look.glyph.ok, color: THEME.good } : { glyph: look.glyph.warn, color: THEME.alert }
207 return frame(
208 <Box key={`${key}-body`} flexDirection="row" flexGrow={1} columnGap={1} paddingLeft={2}>
209 <Text key={`${key}-mark`} color={mark.color}>{mark.glyph}</Text>
210 <Box key={`${key}-text`} flexGrow={1} flexShrink={1}>
211 <Text wrap="truncate-end">
212 <Text color={THEME.muted}>{`${task.kind === 'shell' ? text.taskShell : text.taskMonitor} `}</Text>
213 <Text color={isHover ? THEME.accent : task.status === 'running' ? THEME.onSurface : THEME.muted}>{task.command}</Text>
214 </Text>
215 </Box>
216 <Box key={`${key}-right`} flexShrink={0}>
217 <Text color={THEME.muted}>{`${task.elapsed} `}</Text>
218 </Box>
219 </Box>,
220 )
221 }
222
223 if (line.kind === 'empty') {
224 return frame(<Text key={`${key}-empty`} color={THEME.muted} wrap="truncate-end">{` ${text.noGates}`}</Text>)
225 }
226
227 const group = groupGates(row.gates)[line.group!]!
228 const name = (gate: Gate) => `${row.isMain && gate.file ? `${gate.file}/` : ''}${gate.id}`
229 if (line.kind === 'ghead') {
230 return frame(
231 <Text key={`${key}-ghead`} wrap="truncate-end">
232 <Text color={GROUPS[group.state].color} bold>{` ${GROUPS[group.state].label}`}</Text>
233 <Text color={THEME.muted}>{` ${MARK.sep} ${group.gates.length}`}</Text>
234 </Text>,
235 )
236 }
237 if (line.kind === 'gfold') {
238 const names = group.state === 'met' ? group.gates.map(name).join(' ') : group.gates.map(gate => `${name(gate)} ${gate.text}`).join(` ${MARK.sep} `)
239 return frame(<Text key={`${key}-gfold`} wrap="truncate-end" color={THEME.muted}>{` ${cutText(names, Math.max(1, width - 7))}`}</Text>)
240 }
241 const gate = group.gates[line.gate!]!
242 const since = group.state === 'pending' ? gate.changedAt : gate.firstSeenAt
243 const age = since !== undefined && props.now !== undefined ? formatElapsed(props.now - since) : ''
244 return frame(
245 <Box key={`${key}-body`} flexDirection="row" paddingLeft={4} flexGrow={1} flexShrink={1}>
246 <Box key={`${key}-text`} flexGrow={1} flexShrink={1}>
247 <Text wrap="truncate-end" color={THEME.onSurface}>{`${name(gate)} ${gate.text}`}</Text>
248 </Box>
249 <Box key={`${key}-age`} flexShrink={0} paddingLeft={1}>
250 <Text color={THEME.muted}>{`${age} `}</Text>
251 </Box>
252 </Box>,
253 )
254 }
255
256 return <Box flexDirection="column">{lines.map(drawLine)}</Box>
257}
258
259export default Loops
260hooks/pills.tsx 167 lines1// hooks/pills.tsx, by v-amorim
2// Shared pill row; every mod that draws in the row carries the same copy.
3import type { RenderElement } from 'claude-code'
4
5import { entryCells, mix, type EntryProps, type Tip } from './entry'
6import style from './style'
7import { CAPS, decodeAll, THEME, WASH } from './theme'
8
9// The menu group on Moonlight surface-3, readouts on surface-2.
10export const PILL = { primary: style.colors.pills.primary, secondary: style.colors.pills.secondary }
11
12// Menu entries left to right, by the id in their `menu-pad-<id>` Box key; an unknown id goes last.
13const ENTRY_ORDER = ['manager', 'skills', 'prompts', 'filetree', 'progress', 'headsup']
14
15type Node = { props?: Record<string, unknown>; children?: unknown[] } | null | undefined
16type Elements = { Box: (props: any) => RenderElement; Text: (props: any) => RenderElement }
17
18const keyOf = (node: unknown) => (node as Node)?.props?.key as string | undefined
19const childrenOf = (node: unknown) => (node as Node)?.children ?? []
20
21// Splits next(e) into the menu entries other mods drew, the rest of the row, what else the band holds, and the hover tip under the row.
22export function splitRow(others: unknown) {
23 if (keyOf(others) !== 'menu-rows') return { group: [] as unknown[], row: [] as unknown[], rest: [others], tip: null as unknown }
24 const children = childrenOf(others)
25 const rest = children.filter(child => keyOf(child) !== 'menu-row' && keyOf(child) !== 'menu-tip')
26 const pills = childrenOf(children.find(child => keyOf(child) === 'menu-row'))
27 const groupPill = pills.find(child => keyOf(child) === 'menu-group')
28 const body = childrenOf(groupPill).find(child => keyOf(child) === 'menu-group-body')
29 return {
30 // A mod still running an older copy may draw `menu-group-sep-*` separators between entries; the group drops them.
31 group: childrenOf(body).filter(child => !keyOf(child)?.startsWith('menu-group-sep')),
32 row: pills.filter(child => keyOf(child) !== 'menu-group'),
33 rest,
34 tip: children.find(child => keyOf(child) === 'menu-tip') ?? null,
35 }
36}
37
38// The one-line tip above the pill row, filled while the pointer is on an entry: its "Name:" in bold accent, the rest muted.
39export const tipLine = ({ Box, Text }: Elements, text: string) => {
40 const at = text.indexOf(': ')
41 return (
42 <Box key="menu-tip" paddingLeft={2} flexDirection="row">
43 {at < 0 ? null : <Text color={THEME.accent} bold>{text.slice(0, at + 1)}</Text>}
44 <Text color={THEME.muted}>
45 {(at < 0 ? text : text.slice(at + 1)).split(style.marks.sep).flatMap((part, index) => (index === 0 ? [part] : [<Text key={`sep-${index}`} color={style.colors.pills.separator}>{style.marks.sep}</Text>, part]))}
46 </Text>
47 </Box>
48 )
49}
50
51// One pill with its own caps where the font has them; `on` is what it sits on, `hover` lights the caps with a Button scoped `key`.
52export function pill({ Box, Text }: Elements, nerd: boolean, bg: string, body: unknown, key = 'menu-pill', on?: string, hover?: string) {
53 const under = on ? { backgroundColor: on } : {}
54 const lit = hover ? { hover: { scope: key, color: hover } } : {}
55 return (
56 <Box key={key} flexDirection="row">
57 {nerd ? <Text key={`${key}-cap-left`} color={bg} {...under} {...lit}>{CAPS.left}</Text> : null}
58 <Box key={`${key}-body`} flexDirection="row" backgroundColor={bg}>
59 {body}
60 </Box>
61 {nerd ? <Text key={`${key}-cap-right`} color={bg} {...under} {...lit}>{CAPS.right}</Text> : null}
62 </Box>
63 )
64}
65
66// mod-menu's `labels`: `always` icon and label, `never` the icon alone, `auto` the icon alone while the row overflows (Nerd only; safe keeps labels).
67export type LabelsMode = 'auto' | 'always' | 'never'
68
69// The value of `{ plugin: 'mod-menu', key: 'labels' }` as a mode; a missing or unknown value is `auto`, so a mod works without mod-menu.
70export const labelsOf = (value: unknown): LabelsMode => (value === 'always' || value === 'never' ? value : 'auto')
71
72type ClientNode = { type: 'Client'; props: { props: Record<string, unknown> } }
73const isClient = (node: unknown): node is ClientNode => (node as { type?: string } | null)?.type === 'Client'
74
75// The cells a drawn row takes with every label shown: a Client that may go compact says its own `cells`, or is an entry.tsx one.
76const cellsOf = (node: unknown): number => {
77 if (Array.isArray(node)) return node.reduce((sum: number, each) => sum + cellsOf(each), 0)
78 if (typeof node === 'string') return [...node].length
79 if (!node || typeof node !== 'object') return 0
80 if (isClient(node)) {
81 const { props } = node.props
82 return typeof props.cells === 'number' ? props.cells : entryCells({ ...(props as EntryProps), compact: false })
83 }
84 // A `menu-flash` word comes and goes with the pointer; counting it would flip the row under the pointer.
85 const kids = childrenOf(node).filter(each => each && !keyOf(each)?.startsWith('menu-flash'))
86 return kids.reduce((sum: number, each) => sum + cellsOf(each), Number((node as Node)?.props?.columnGap ?? 0) * Math.max(0, kids.length - 1))
87}
88
89const compacted = (node: unknown): unknown => {
90 if (Array.isArray(node)) return node.map(compacted)
91 if (!node || typeof node !== 'object') return node
92 if (isClient(node)) return node.props.props.compact === false ? { ...node, props: { ...node.props, props: { ...node.props.props, compact: true } } } : node
93 const kids = (node as Node)?.children
94 return kids ? { ...node, children: kids.map(compacted) } : node
95}
96
97// A panel's entry in the menu group, keyed `menu-pad-<id>` for the row's order; the open one is a pill of its own.
98export function menuEntry({ Box, Client, Text }: Elements & { Client: (props: any) => RenderElement }, nerd: boolean, id: string, { labels, ...props }: Omit<EntryProps, 'id' | 'bg' | 'tight'> & { labels?: LabelsMode }) {
99 // The open panel reads as selected, like an On switch: ok text on ok washed into the group fill.
100 const bg = props.isOpen ? mix(THEME.ok, PILL.primary, WASH) : PILL.primary
101 const glyph = (style.glyphs.nerd.icon as Record<string, string>)[`menu-${id}`]
102 const icon = nerd && !props.lead && glyph ? decodeAll(glyph) : undefined
103 // The icon sits tight against the cap, so the label that follows brings its own gap.
104 const shown = icon ? { lead: icon, label: labels === 'never' ? '' : ` ${props.label}`, ...(labels === 'auto' ? { compact: false } : {}) } : {}
105 // On Nerd the caps take the label's padding cells, so an entry is as wide as before the caps.
106 const entry = <Client key={`menu-entry-${id}`} module="./entry.tsx" height={1} props={{ id, bg, ...props, ...(props.isOpen ? { color: THEME.ok } : {}), ...shown, nerd, tight: nerd } satisfies EntryProps} />
107 return (
108 <Box key={`menu-pad-${id}`} flexDirection="row">
109 {/* A closed entry gets caps in the group's own color, so opening a panel never shifts its neighbours. */}
110 {pill({ Box, Text }, nerd, bg, entry, props.isOpen ? `menu-open-${id}` : `menu-closed-${id}`, PILL.primary)}
111 </Box>
112 )
113}
114
115// The band above the prompt: what else it holds (Claude Code's own effort line), then the pill row, so the pills sit on the input.
116export function pillRow(elements: Elements & { Client?: (props: any) => RenderElement }, nerd: boolean, group: unknown[], row: unknown[], rest: unknown[], tip: unknown = null, columns?: number) {
117 const { Box, Client, Text } = elements
118 // Load order changes with an install or a toggle, so entries sort by a fixed rank.
119 const rank = (entry: unknown) => {
120 const at = ENTRY_ORDER.indexOf(String(keyOf(entry) ?? '').replace(/^menu-pad-/, ''))
121 return at < 0 ? ENTRY_ORDER.length : at
122 }
123 // Like a choice bar: the entries side by side on one pill, the open panel's lifted into its own, a divider Client between neighbours.
124 const sorted = [...group].sort((a, b) => rank(a) - rank(b))
125 const entries = Client
126 ? sorted.flatMap((entry, at) => (at === 0 ? [entry] : [<Client key={`menu-group-sep-${at}`} module="./entry.tsx" height={1} props={{ id: `sep-${at}`, label: '', isOpen: false, bg: PILL.primary, rail: true } satisfies EntryProps} />, entry]))
127 : sorted
128 const pills = (
129 <Box key="menu-row" flexDirection="row" columnGap={1}>
130 {group.length > 0 ? pill(elements, nerd, PILL.primary, entries, 'menu-group') : null}
131 {row}
132 </Box>
133 )
134 // Measured with every label shown, so a row cut to icons never grows back into a row that does not fit.
135 const fits = columns === undefined || cellsOf(pills) <= columns
136 return (
137 <Box key="menu-rows" flexDirection="column">
138 {rest}
139 {/* Above the row, kept even when empty so nothing above jumps when a tip appears. */}
140 {tip ?? tipLine(elements, ' ')}
141 {fits ? pills : compacted(pills)}
142 </Box>
143 )
144}
145
146// One chip per view of a panel (its list, its settings), standing in for the title: id, label, icon glyph, an optional count and an optional hover tip.
147export type Tab = { id: string; label: string; icon: string; isOpen: boolean; count?: number; tip?: Tip }
148
149// A panel's title line: the open view on surface3 and bold, the others on surface2 and muted; a click swaps the view.
150export function tabRow({ Box, Client, Text }: Elements & { Client: (props: any) => RenderElement }, nerd: boolean, tabs: Tab[], still: boolean) {
151 return (
152 <Box key="tab-row" flexDirection="row" columnGap={1}>
153 {tabs.map(tab => {
154 const bg = tab.isOpen ? THEME.surface3 : THEME.surface2
155 // Client props refuse an undefined value, so a tab without a count or tip leaves the key out.
156 const countColor = tab.isOpen ? THEME.accent : style.colors.pills.readout
157 const badge = tab.count === undefined ? {} : { badge: { text: String(tab.count), color: countColor, bg: mix(countColor, bg, WASH) } }
158 const tip = tab.tip ? { tip: tab.tip } : {}
159 const entry = (
160 <Client key={tab.id} module="./entry.tsx" height={1} props={{ id: tab.id, label: tab.label, isOpen: tab.isOpen, bg, idle: THEME.muted, still, subLead: tab.icon, subLeadColor: THEME.accent, nerd, ...(tab.isOpen ? { color: THEME.secondary } : {}), ...badge, ...tip } satisfies EntryProps} />
161 )
162 return pill({ Box, Text }, nerd, bg, entry, `${tab.id}-pill`)
163 })}
164 </Box>
165 )
166}
167hooks/progress.ts 536 lines1// mod-progress / hooks/progress.ts, by v-amorim
2// The data side of the mod: gates files, loop figures, rows. No `$` in here, so tests call it directly.
3import marks from './marks'
4import style from './style'
5import text from './text'
6import { fill } from './theme'
7
8// 'pending' is a checked box whose EVIDENCE is missing or still `pending`.
9export type GateState = 'met' | 'open' | 'pending' | 'abandoned'
10// `file` names the gates/<file>.md a gate came from, none for GATES.md; the stamps are first read and last state change.
11export type Gate = { id: string; text: string; state: GateState; file?: string; firstSeenAt?: number; changedAt?: number }
12export type Task = { id: string; subject: string; status: string }
13// A workflow script's agent: no list entry names it, so its run, place, name and task are kept from its spawn.
14export type WorkflowAgent = { runId: string; index: number; name: string; description: string }
15// A background shell or monitor an agent started: it ends by TaskStop, its notification, or a Stop that no longer lists it.
16export type BgKind = 'shell' | 'monitor'
17// `output` is the file the host writes the task's output to, when its result or notification names one.
18export type BgTask = { id: string; kind: BgKind; command: string; startedAt: number; endedAt?: number; end?: 'done' | 'failed'; output?: string }
19export type Loop = { bg?: BgTask[]; model?: string; effort?: string; tokens: number; context: number; spawnedAt?: number; endedAt?: number; isFailed?: true; gatesStem?: string; tasks?: Task[]; step?: string; workflow?: WorkflowAgent }
20export type Usage = { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }
21
22// The main turn's last stop on an API error (StopFailure): its error kind, when it happened, and the reset of a full usage window.
23export type Stop = { error: string; at: number; resetsAt?: string }
24
25// Kept in $.store under the session id, so a reload keeps it and another session starts clean.
26export type Snap = {
27 sid: string
28 busy: boolean
29 startedAt?: number
30 main: Loop
31 agents: Record<string, Loop>
32 tasks: Task[]
33 gates: Record<string, Gate[]>
34 expanded: string
35 // The pane's view of the loops; a missing value is the cards.
36 view?: 'cards' | 'timeline'
37 // The cards whose background tasks are folded to their count; the rest show them.
38 folded?: string[]
39 stop?: Stop
40}
41
42export const MAIN_GATES = 'GATES'
43
44export const emptySnap = (sid: string): Snap => ({ sid, busy: false, main: { tokens: 0, context: 0 }, agents: {}, tasks: [], gates: {}, expanded: '' })
45
46const GATE_RE = /^- \[( |x|X)\] (.*)$/
47const ATTR_RE = /^\s+(CHECK|EXPECT|EVIDENCE):\s?(.*)$/
48const ABANDON_RE = /^ABANDON:\s*(\S+)\s*(.*)$/
49
50// A box is met only when checked and its EVIDENCE is no longer pending; ABANDON lines give a gate up.
51export function parseGates(source: string): Gate[] {
52 const found: { id: string; text: string; checked: boolean; evidence: string | null }[] = []
53 const abandoned = new Set<string>()
54 let current: (typeof found)[number] | null = null
55 source.split(/\r?\n/).forEach((line, at) => {
56 const gate = GATE_RE.exec(line)
57 if (gate) {
58 const body = gate[2]!.trim()
59 const id = /^(\S+?):/.exec(body)?.[1] ?? `line${at + 1}`
60 current = { id, text: body.replace(/^\S+?:\s*/, ''), checked: gate[1]!.toLowerCase() === 'x', evidence: null }
61 found.push(current)
62 return
63 }
64 const attr = current ? ATTR_RE.exec(line) : null
65 if (attr) {
66 if (attr[1] === 'EVIDENCE') current!.evidence = attr[2]!.trim()
67 return
68 }
69 const gone = ABANDON_RE.exec(line)
70 if (gone) abandoned.add(gone[1]!.replace(/:$/, ''))
71 if (/^#|^- /.test(line)) current = null
72 })
73 return found.map(each => ({
74 id: each.id,
75 text: each.text,
76 state: abandoned.has(each.id) ? 'abandoned' : !each.checked ? 'open' : each.evidence && !/^pending$/i.test(each.evidence) ? 'met' : 'pending',
77 }))
78}
79
80// Carries each gate's stamps over a re-read, matching on file and id; a gate whose state changed gets a new changedAt.
81export function mergeStamps(before: Gate[] | undefined, after: Gate[], now: number): Gate[] {
82 return after.map(gate => {
83 const was = before?.find(each => each.id === gate.id && each.file === gate.file)
84 return { ...gate, firstSeenAt: was?.firstSeenAt ?? now, changedAt: was && was.state === gate.state ? (was.changedAt ?? now) : now }
85 })
86}
87
88export type GateGroup = { state: GateState; gates: Gate[] }
89
90// Open, waiting, met, then dropped; a group with no gates is left out.
91export const groupGates = (gates: Gate[]): GateGroup[] =>
92 (['open', 'pending', 'met', 'abandoned'] as const).map(state => ({ state, gates: gates.filter(gate => gate.state === state) })).filter(group => group.gates.length > 0)
93
94// The strip fills from the left like a bar: met, waiting, open, then dropped, each in file order.
95export const stripOrder = (gates: Gate[]) => (['met', 'pending', 'open', 'abandoned'] as const).flatMap(state => gates.filter(gate => gate.state === state))
96
97export const waitingCount =(rows: Row[]) => [...new Set(rows.flatMap(row => row.gates))].filter(gate => gate.state === 'pending').length
98
99// Past `room` each state gets its share of cells, at least one, so an unfinished strip never draws all full.
100export function stripCells(gates: Gate[], room: number): GateState[] {
101 const ordered = stripOrder(gates).map(gate => gate.state)
102 if (ordered.length <= room) return ordered
103 if (room <= 0) return []
104 const states = (['met', 'pending', 'open', 'abandoned'] as const).map(state => ({ state, count: ordered.filter(each => each === state).length })).filter(each => each.count > 0)
105 const shares = states.map(each => ({ ...each, cells: Math.floor((each.count * room) / ordered.length), rest: ((each.count * room) / ordered.length) % 1 }))
106 for (const each of [...shares].sort((a, b) => b.rest - a.rest).slice(0, room - shares.reduce((sum, each) => sum + each.cells, 0))) each.cells++
107 for (const each of shares.filter(share => share.cells === 0)) {
108 const donor = shares.reduce((most, share) => (share.cells > most.cells ? share : most))
109 if (donor.cells > 1) donor.cells--, each.cells++
110 }
111 return shares.flatMap(each => Array.from({ length: each.cells }, () => each.state))
112}
113
114// `value` cut to `room` columns, ending in … when it was longer.
115export const cutText = (value: string, room: number) => (value.length <= room ? value : room <= 1 ? '…'.slice(0, Math.max(0, room)) : `${value.slice(0, room - 1)}…`)
116
117export const isGatesPath = (path: string) => /(^|\/)(GATES\.md|gates\/[^/]+\.md)$/i.test(path.replace(/\\/g, '/'))
118
119export const stemOf = (file: string) => file.replace(/\.md$/i, '')
120
121// The first gates file a brief names, as gates/<stem>.md.
122export const gatesStemIn = (prompt: string) => prompt.replace(/\\/g, '/').match(/gates\/([^/\s`'")]+)\.md/i)?.[1]
123
124// An agent's gates file: the one its brief names, else gates/<name>.md, else the description's first word ("leaf-1.1.1: ...").
125export const gatesOf = (snap: { gates: Record<string, Gate[]> }, info: { name?: string; description: string }, stem?: string) =>
126 (stem ? snap.gates[stem] : undefined) ?? (info.name ? snap.gates[info.name] : undefined) ?? snap.gates[info.description.trim().split(/\s+/)[0]?.replace(/:$/, '') ?? '']
127
128export function applyStep(snap: Snap, agentId: string | undefined, model: string, effort: unknown, usage: Usage | null | undefined) {
129 const loop = agentId ? (snap.agents[agentId] ??= { tokens: 0, context: 0 }) : snap.main
130 loop.model = model
131 if (typeof effort === 'string') loop.effort = effort
132 else delete loop.effort
133 if (usage) {
134 loop.tokens += usage.output_tokens
135 loop.context = usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
136 }
137}
138
139// The main session's tasks sit on the snap, an agent's on its loop.
140export function applyTask(owner: { tasks?: Task[] }, tool: string, input: Record<string, unknown>, createdId?: string) {
141 const tasks = (owner.tasks ??= [])
142 if (tool === 'TaskCreate' && createdId) tasks.push({ id: createdId, subject: String(input.subject ?? ''), status: 'pending' })
143 if (tool !== 'TaskUpdate') return
144 const task = tasks.find(each => each.id === String(input.taskId))
145 if (!task) return
146 if (input.status === 'deleted') owner.tasks = tasks.filter(each => each !== task)
147 else {
148 if (typeof input.status === 'string') task.status = input.status
149 if (typeof input.subject === 'string') task.subject = input.subject
150 }
151}
152
153const baseName = (path: string) => path.replace(/\\/g, '/').split('/').pop() ?? ''
154const hostOf = (url: string) => /^https?:\/\/([^/?#]+)/i.exec(url)?.[1] ?? url
155
156// A call with no file, pattern or command to name shows the tool's own name.
157export function stepOf(tool: string, input: Record<string, unknown>) {
158 const slot = (key: string) => (typeof input[key] === 'string' ? (input[key] as string).replace(/\s+/g, ' ').trim() : '')
159 const named = (template: string, value: string) => (value ? fill(template, { what: value }) : tool)
160 switch (tool) {
161 case 'Read':
162 return named(text.stepRead, baseName(slot('file_path')))
163 case 'Edit':
164 case 'MultiEdit':
165 return named(text.stepEdit, baseName(slot('file_path')))
166 case 'Write':
167 return named(text.stepWrite, baseName(slot('file_path')))
168 case 'Grep':
169 case 'Glob':
170 return named(text.stepSearch, slot('pattern'))
171 case 'Bash':
172 return slot('description') || named(text.stepRun, slot('command').split(' ')[0] ?? '')
173 case 'WebFetch':
174 return named(text.stepFetch, hostOf(slot('url')))
175 case 'Agent':
176 return text.stepAgent
177 default:
178 return tool
179 }
180}
181
182const BOOKKEEPING = new Set(['TaskCreate', 'TaskUpdate', 'TaskList', 'TaskGet', 'TodoWrite'])
183
184export function applyCall(snap: Snap, agentId: string | undefined, tool: string, input: Record<string, unknown>) {
185 const loop = agentId ? snap.agents[agentId] : snap.main
186 // Task bookkeeping says nothing about the work, so the step before it stays.
187 if (loop && !BOOKKEEPING.has(tool)) loop.step = stepOf(tool, input)
188}
189
190const FAMILIES = ['opus', 'sonnet', 'haiku']
191const HUES = style.colors.models as Record<string, string>
192
193// The family and version read from a model id, the family alone from an alias, the raw id from anything else.
194export function modelInfo(model: string | undefined) {
195 if (!model) return { name: '', family: 'inherit', hue: HUES.inherit! }
196 const family = FAMILIES.find(each => model.toLowerCase().includes(each))
197 if (!family) return { name: model.replace(/^claude-/, ''), family: 'inherit', hue: HUES.inherit! }
198 const label = family[0]!.toUpperCase() + family.slice(1)
199 const version = new RegExp(`${family}-(\\d+)(?:[-.](\\d{1,2}))?(?!\\d)`, 'i').exec(model)
200 return { name: version ? `${label} ${version[1]}${version[2] ? `.${version[2]}` : ''}` : label, family, hue: HUES[family]! }
201}
202
203// A request never exceeds its window, so context past the default proves the 1M window; a 1M main session gives its agents 1M too.
204export function windowFor(model: string | undefined, mainModel: string | undefined, mainWindow: number | undefined, context = 0) {
205 if (model?.includes('[1m]') || context > marks.windows.default) return marks.windows.oneMillion
206 if (mainWindow && model === mainModel) return mainWindow
207 return (mainWindow ?? 0) >= marks.windows.oneMillion ? marks.windows.oneMillion : marks.windows.default
208}
209
210export const gaugeColor = (percent: number) => {
211 const at = marks.gaugeFrom.reduce((found, from, index) => (percent >= from ? index : found), 0)
212 return style.colors.usage[at]!
213}
214
215export function formatTokens(count: number) {
216 if (count < 1000) return String(count)
217 if (count < 100000) return `${(count / 1000).toFixed(1)}k`
218 if (count < 1000000) return `${Math.round(count / 1000)}k`
219 return `${(count / 1000000).toFixed(1)}M`
220}
221
222export function formatElapsed(ms: number) {
223 const seconds = Math.max(0, Math.floor(ms / 1000))
224 if (seconds < 60) return `${seconds}s`
225 const minutes = Math.floor(seconds / 60)
226 return minutes < 60 ? `${minutes}m` : `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`
227}
228
229// Finished tasks stay on their card, but only this many per owner.
230const KEEP_ENDED = 12
231
232const bgOwner = (snap: Snap, agentId: string | undefined) => (agentId ? snap.agents[agentId] : snap.main)
233
234const ID_KEYS = ['backgroundTaskId', 'background_task_id', 'shellId', 'shell_id', 'taskId', 'task_id', 'id']
235
236// The id a background start answers with, from its structured result or its text ("... running in background with ID: b3k9x").
237export function bgIdOf(result: unknown): string | undefined {
238 const found = (value: unknown): string | undefined => {
239 if (!value || typeof value !== 'object') return undefined
240 for (const key of ID_KEYS) {
241 const id = (value as Record<string, unknown>)[key]
242 if (typeof id === 'string' && id) return id
243 }
244 return undefined
245 }
246 const record = result as { result?: unknown; text?: unknown } | undefined
247 const direct = found(record?.result) ?? found(record)
248 if (direct) return direct
249 const body = [typeof record?.text === 'string' ? record.text : '', typeof record?.result === 'string' ? record.result : '', JSON.stringify(record?.result ?? '')].join('\n')
250 return /\bID:?\s+([A-Za-z0-9_-]+)/.exec(body)?.[1] ?? /task[_ -]?id\W+([A-Za-z0-9_-]+)/i.exec(body)?.[1]
251}
252
253// A background Bash call, or a Monitor, starts a task; its tool name and input say which and what it runs.
254// A foreground Bash the host moved to the background (ctrl+b, or its timeout) answers with a task id too: `isMoved`.
255export function bgStartOf(tool: string, input: Record<string, unknown>, isMoved = false): { kind: BgKind; command: string } | undefined {
256 if (tool === 'Bash' && (input.run_in_background === true || isMoved)) return { kind: 'shell', command: String(input.command ?? '') }
257 if (tool === 'Monitor') return { kind: 'monitor', command: String(input.command ?? input.description ?? '') }
258 return undefined
259}
260
261export function addBg(snap: Snap, agentId: string | undefined, id: string, start: { kind: BgKind; command: string; output?: string }, now: number) {
262 const owner = agentId ? (snap.agents[agentId] ??= { tokens: 0, context: 0 }) : snap.main
263 const list = (owner.bg ??= [])
264 if (list.some(task => task.id === id)) return false
265 list.push({ id, kind: start.kind, command: start.command, startedAt: now, ...(start.output ? { output: start.output } : {}) })
266 const ended = list.filter(task => task.endedAt !== undefined)
267 if (ended.length > KEEP_ENDED) owner.bg = list.filter(task => !ended.slice(0, ended.length - KEEP_ENDED).includes(task))
268 return true
269}
270
271// Marks a task ended wherever it lives; true when it was running.
272export function endBg(snap: Snap, id: string, end: 'done' | 'failed', now: number) {
273 for (const owner of [snap.main, ...Object.values(snap.agents)]) {
274 const task = owner.bg?.find(each => each.id === id)
275 if (!task || task.endedAt !== undefined) continue
276 task.endedAt = now
277 task.end = end
278 return true
279 }
280 return false
281}
282
283// The file a start's text names ("Output is being written to: <path>"), or a notification's <output-file>.
284export const bgOutputOf = (source: string) => /<output-file>\s*(.+?)\s*<\/output-file>/.exec(source)?.[1] ?? /written to:\s*(.+?\.output)(?=\.?\s|$)/.exec(source)?.[1]
285
286export const findBg = (snap: Snap, id: string) => [snap.main, ...Object.values(snap.agents)].flatMap(loop => loop.bg ?? []).find(task => task.id === id)
287
288// The lines a task's output keeps, and what it holds of a line still being written.
289export const TAIL_LINES = 20
290// What a page holds while it is open: every line up to this many, the last ones when the output is longer.
291export const HELD_LINES = 400
292export type Tail = { from: number; lines: string[]; partial: string }
293
294// Output is the program's own text: escapes and control characters never reach the screen.
295export const stripControl = (value: string) =>
296 value
297 .replace(/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g, '')
298 .replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '')
299 .replace(/\u001b[@-Z\\-_]/g, '')
300 .replace(/\t/g, ' ')
301 .replace(/[\u0000-\u001f\u007f-\u009f]/g, '')
302
303// Adds what was written since the last read, keeping the last TAIL_LINES lines; `size` is the file's length in bytes.
304export function appendTail(tail: Tail, chunk: string, size: number, keep = TAIL_LINES): Tail {
305 const parts = (tail.partial + chunk.replace(/\r\n?/g, '\n')).split('\n')
306 const partial = parts.pop() ?? ''
307 const lines = [...tail.lines, ...parts.map(stripControl)].slice(-keep)
308 return { from: size, lines, partial: stripControl(partial) }
309}
310
311// The columns a card's lines have: the pane's padding, then the card's border and padding.
312export const cardWidth = (bodyColumns: number) => Math.max(1, bodyColumns - 6)
313
314// The rows these lines take wrapped at `width`, one at least each.
315export const wrappedRows = (lines: string[], width: number) => lines.reduce((sum, line) => sum + Math.max(1, Math.ceil(line.length / width)), 0)
316
317// How many output lines the page has room for: the body less the back pill row, the command card (border, a gap above, its wrapped lines), the output card's border and gap, and the rows the footer keeps free; never under one.
318export function outputRoom(bodyRows: number, bodyColumns: number, command: string, lead: string, footerRows: number) {
319 const width = cardWidth(bodyColumns)
320 const commandRows = wrappedRows(command.split(/\r?\n/).map((line, at) => `${at === 0 ? lead : ' '} ${line}`), width)
321 return Math.max(1, bodyRows - (1 + 3 + commandRows + 3 + footerRows))
322}
323
324// What a task's page draws: the whole command, its status line figures, and where its output lives.
325export function pageOf(snap: Snap, id: string, now: number) {
326 const task = findBg(snap, id)
327 if (!task) return undefined
328 const status: Status = task.endedAt === undefined ? 'running' : task.end === 'failed' ? 'failed' : 'done'
329 return { kind: task.kind, command: task.command, status, elapsed: formatClock((task.endedAt ?? now) - task.startedAt), output: task.output }
330}
331
332export const endOfStatus = (status: string | undefined): 'done' | 'failed' => (status === 'failed' || status === 'killed' || status === 'stopped' ? 'failed' : 'done')
333
334// A Stop lists what still runs: every running task of that owner missing from it has finished.
335export function resyncBg(snap: Snap, agentId: string | undefined, listed: { id: string; status: string }[], now: number) {
336 let changed = false
337 for (const task of bgOwner(snap, agentId)?.bg ?? []) {
338 if (task.endedAt !== undefined) continue
339 const still = listed.find(each => each.id === task.id)
340 if (still && statusOf(still.status) === 'running') continue
341 task.endedAt = now
342 task.end = still ? endOfStatus(still.status) : 'done'
343 changed = true
344 }
345 return changed
346}
347
348// A Claude Code exit or restart kills its shells and monitors, so what survives in the store is stale.
349export function wipeBg(snap: Snap) {
350 const owners = [snap.main, ...Object.values(snap.agents)].filter(loop => loop.bg && loop.bg.length > 0)
351 owners.forEach(loop => delete loop.bg)
352 return owners.length > 0
353}
354
355export const hasRunningBg = (snap: Snap) => [snap.main, ...Object.values(snap.agents)].some(loop => loop.bg?.some(task => task.endedAt === undefined))
356
357// 4m 10s: a task's clock keeps its seconds.
358export function formatClock(ms: number) {
359 const seconds = Math.max(0, Math.floor(ms / 1000))
360 if (seconds < 60) return `${seconds}s`
361 if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, '0')}s`
362 return `${Math.floor(seconds / 3600)}h ${String(Math.floor((seconds % 3600) / 60)).padStart(2, '0')}m`
363}
364
365export type AgentLite = { id: string; name?: string; description: string; type: string; status: string }
366export type UsageLite = { tokens?: number; window?: number; percent?: number; startedAt?: number }
367export type Status = 'running' | 'done' | 'failed'
368
369// What a card draws for one background task; a finished one keeps its final elapsed time.
370export type RowTask = { id: string; kind: BgKind; command: string; status: Status; elapsed: string }
371
372export type Row = {
373 id: string
374 isMain: boolean
375 name: string
376 description: string
377 status: Status
378 model: string
379 hue: string
380 // The workflow run this agent belongs to; the rows of one run sit together.
381 run?: string
382 effort: string | undefined
383 percent: number | undefined
384 tokens: number
385 elapsed: string
386 gates: Gate[]
387 hasGates: boolean
388 tasks: RowTask[]
389 // When the loop began and, once over, ended: the timeline's lane runs from one to the other.
390 startedAt?: number
391 endedAt?: number
392 // The main ledger reads from gates files and none is open: every written phase is finished, which says nothing about the ones not written yet.
393 isPhaseDone?: boolean
394}
395
396// Agents that share a name (two of one type) are told apart by what they do; the hover tip keeps the name.
397export const laneLabels = (rows: Pick<Row, 'isMain' | 'name' | 'description'>[]) =>
398 rows.map(row => (row.isMain || !row.description.trim() || rows.filter(other => other.name === row.name).length < 2 ? row.name : row.description))
399
400export const statusOf = (status: string): Status => (status === 'completed' ? 'done' : status === 'failed' || status === 'killed' ? 'failed' : 'running')
401
402// Marks an agent that stopped as ended at the first look that saw it stopped; true when something changed.
403export function settle(snap: Snap, list: AgentLite[], now: number) {
404 let changed = false
405 for (const info of list) {
406 const loop = (snap.agents[info.id] ??= { tokens: 0, context: 0 })
407 if (statusOf(info.status) !== 'running' && loop.endedAt === undefined) {
408 loop.endedAt = now
409 delete loop.step
410 changed = true
411 }
412 }
413 return changed
414}
415
416export const firstLine = (text: string) => (text.split('\n')[0] ?? '').trim()
417
418const tasksOf = (loop: Loop, now: number): RowTask[] =>
419 (loop.bg ?? []).map(task => ({ id: task.id, kind: task.kind, command: firstLine(task.command), status: task.endedAt === undefined ? 'running' : task.end === 'failed' ? 'failed' : 'done', elapsed: formatClock((task.endedAt ?? now) - task.startedAt) }))
420
421// A workflow agent ends with its own turn; true when this marked it.
422export function finishAgent(snap: Snap, id: string, now: number, isFailed: boolean) {
423 const loop = snap.agents[id]
424 if (!loop?.workflow || loop.endedAt !== undefined) return false
425 loop.endedAt = now
426 if (isFailed) loop.isFailed = true
427 delete loop.step
428 return true
429}
430
431const taskGate = (task: Task): Gate => ({ id: `#${task.id}`, text: task.subject, state: task.status === 'completed' ? 'met' : 'open' })
432
433// One rule for main and every agent: a gates file with entries wins over the task list.
434const gatesFor = (file: Gate[] | undefined, tasks: Task[] = []) => (file && file.length > 0 ? file : tasks.map(taskGate))
435
436// The call's phrase shows only while the loop runs, so a finished card keeps its description.
437const shownDescription = (loop: Loop, status: Status, description: string) => (status === 'running' && loop.step ? loop.step : description)
438
439// The main session first, then each agent in spawn order.
440export function buildRows(snap: Snap, list: AgentLite[], usage: UsageLite | undefined, now: number, mainName: string, mainTask: string, mainModel: string | undefined): Row[] {
441 const { main } = snap
442 const model = main.model ?? mainModel
443 // The main ledger is GATES.md plus every gates/<name>.md.
444 const ledger = Object.entries(snap.gates).sort(([a], [b]) => Number(b === MAIN_GATES) - Number(a === MAIN_GATES)).flatMap(([, gates]) => gates)
445 const mainGates = gatesFor(ledger, snap.tasks)
446 const window = windowFor(model, model, usage?.window)
447 const started = usage?.startedAt ?? snap.startedAt
448 const percent = (loop: Loop, size: number) => (loop.context > 0 ? Math.min(100, Math.round((loop.context / size) * 100)) : undefined)
449 const doing = snap.tasks.find(task => task.status === 'in_progress') ?? snap.tasks.find(task => task.status !== 'completed')
450 const mainStatus: Status = snap.busy ? 'running' : 'done'
451 const rows: Row[] = [{
452 id: 'main',
453 isMain: true,
454 name: mainName,
455 description: shownDescription(main, mainStatus, doing?.subject ?? mainTask),
456 status: mainStatus,
457 model: model ?? '',
458 hue: modelInfo(model).hue,
459 effort: main.effort,
460 percent: usage?.percent ?? percent(main, window),
461 tokens: main.tokens,
462 elapsed: started === undefined ? '' : formatElapsed(now - started),
463 gates: mainGates,
464 hasGates: mainGates.length > 0,
465 tasks: tasksOf(main, now),
466 ...(started === undefined ? {} : { startedAt: started }),
467 ...(mainStatus === 'running' ? {} : { endedAt: main.endedAt ?? now }),
468 isPhaseDone: ledger.length > 0 && !ledger.some(gate => gate.state === 'open' || gate.state === 'pending'),
469 }]
470 const listed = new Set(list.map(info => info.id))
471 const ofWorkflow = Object.entries(snap.agents)
472 .filter(([id, loop]) => loop.workflow && !listed.has(id))
473 .sort(([, a], [, b]) => (a.spawnedAt ?? 0) - (b.spawnedAt ?? 0) || a.workflow!.index - b.workflow!.index)
474 const entries = [
475 ...list.map(info => ({ info, loop: snap.agents[info.id] ?? { tokens: 0, context: 0 }, status: statusOf(info.status) })),
476 ...ofWorkflow.map(([id, loop]) => ({ info: { id, name: loop.workflow!.name, description: loop.workflow!.description, type: '', status: loop.endedAt === undefined ? 'running' : loop.isFailed ? 'failed' : 'completed' }, loop, status: (loop.endedAt === undefined ? 'running' : loop.isFailed ? 'failed' : 'done') as Status })),
477 ]
478 for (const { info, loop, status } of entries) {
479 const gates = gatesFor(gatesOf(snap, info, loop.gatesStem), loop.tasks)
480 rows.push({
481 id: info.id,
482 isMain: false,
483 name: info.name ?? info.type,
484 description: shownDescription(loop, status, info.description),
485 status,
486 model: loop.model ?? '',
487 hue: modelInfo(loop.model).hue,
488 ...(loop.workflow ? { run: loop.workflow.runId } : {}),
489 effort: loop.effort,
490 percent: percent(loop, windowFor(loop.model, model, usage?.window, loop.context)),
491 tokens: loop.tokens,
492 elapsed: loop.spawnedAt === undefined ? '' : formatElapsed((loop.endedAt ?? now) - loop.spawnedAt),
493 gates,
494 hasGates: gates.length > 0,
495 tasks: tasksOf(loop, now),
496 ...(loop.spawnedAt === undefined ? {} : { startedAt: loop.spawnedAt }),
497 ...(loop.endedAt === undefined ? {} : { endedAt: loop.endedAt }),
498 })
499 }
500 return rows
501}
502
503export const countGates = (gates: Gate[]) => ({ done: gates.filter(gate => gate.state === 'met').length, total: gates.length })
504
505// The band pill's badge: the agents besides the main session and the background tasks of any card that are running.
506export function bandSummary(rows: Row[]) {
507 // The main ledger holds the gates of every file, and an agent paired with a file lists the same objects again.
508 const gates = countGates([...new Set(rows.flatMap(row => row.gates))])
509 const running = rows.filter(row => !row.isMain && row.status === 'running').length + rows.reduce((sum, row) => sum + row.tasks.filter(task => task.status === 'running').length, 0)
510 return { count: running, ...gates }
511}
512
513export type LaneLoop = { start?: number; end?: number; isRunning: boolean }
514
515// Each loop's cells on one clock from the earliest start to now (or the last end): a running loop reaches the right edge, a loop with no start begins at the clock's, a zero-length one still takes a cell.
516export function laneCells(loops: LaneLoop[], now: number, width: number): { from: number; to: number }[] {
517 const cells = Math.max(1, width)
518 const starts = loops.flatMap(loop => (loop.start === undefined ? [] : [loop.start]))
519 const first = starts.length > 0 ? Math.min(...starts) : now
520 const endOf = (loop: LaneLoop) => (loop.isRunning ? now : (loop.end ?? loop.start ?? first))
521 const last = Math.max(first + 1, ...loops.map(endOf))
522 const at = (time: number) => ((time - first) / (last - first)) * cells
523 return loops.map(loop => {
524 const from = Math.min(cells - 1, Math.max(0, Math.floor(at(loop.start ?? first))))
525 return { from, to: loop.isRunning ? cells : Math.min(cells, Math.max(from + 1, Math.round(at(endOf(loop))))) }
526 })
527}
528
529export const NAME_CELLS = 16
530
531// The timeline row's columns, which add up to the width: lead and cursor, mark, name, lane, duration, one trailing space, with a space between each.
532export function timelineCols(width: number, durCells: number) {
533 const name = width >= 34 ? NAME_CELLS : Math.max(6, width - 18)
534 return { name, dur: durCells, lane: Math.max(1, width - (2 + 1 + 1 + name + 1 + 1 + durCells + 1)) }
535}
536hooks/stop.ts 72 lines1// mod-progress / hooks/stop.ts, by v-amorim
2// Why the main turn stopped: StopFailure's error kind in words, its tone, and when a full usage window resets. No `$` in here, so tests call it directly.
3import { formatElapsed, type Stop } from './progress'
4import text from './text'
5import { fill } from './theme'
6
7export type StopKind = keyof typeof text.stopKind
8export type StopTone = 'warn' | 'alert'
9export type LimitWindow = { percentUsed: number; resetsAt?: string }
10export type StopView = { line: string; tone: StopTone }
11
12// Each error kind of StopFailure (SDKAssistantMessageError), grouped by what the person does about it.
13const KIND: Record<string, StopKind> = {
14 authentication_failed: 'signIn',
15 oauth_org_not_allowed: 'signIn',
16 cloud_credential_error: 'signIn',
17 account_on_hold: 'account',
18 verification_required: 'account',
19 billing_error: 'billing',
20 invalid_request: 'invalid',
21 model_not_found: 'invalid',
22 rate_limit: 'usageLimit',
23 overloaded: 'overloaded',
24}
25
26// Alert: the failure will not clear on its own, so the person must act. Warn: it may clear by itself, so waiting helps.
27const TONE: Record<StopKind, StopTone> = {
28 signIn: 'alert',
29 account: 'alert',
30 billing: 'alert',
31 invalid: 'alert',
32 usageLimit: 'warn',
33 overloaded: 'warn',
34 other: 'warn',
35}
36
37export const kindOf = (error: string): StopKind => KIND[error] ?? 'other'
38
39// The latest reset among the windows that are full.
40export function fullUntil(windows: LimitWindow[]) {
41 let until: string | undefined
42 for (const window of windows) {
43 const at = Date.parse(window.resetsAt ?? '')
44 if (window.percentUsed < 100 || Number.isNaN(at)) continue
45 if (until === undefined || at > Date.parse(until)) until = window.resetsAt
46 }
47 return until
48}
49
50// A reset time is kept only for a usage limit, the one kind a window explains.
51export function stopOf(error: string, at: number, windows: LimitWindow[]): Stop {
52 const resetsAt = kindOf(error) === 'usageLimit' ? fullUntil(windows) : undefined
53 return resetsAt === undefined ? { error, at } : { error, at, resetsAt }
54}
55
56// Local clock time such as 7:33pm; a reset on another day gets its date in front.
57export function resumeAt(resetsAt: string, now: number) {
58 const reset = new Date(resetsAt)
59 const hours = reset.getHours()
60 const time = `${hours % 12 || 12}:${String(reset.getMinutes()).padStart(2, '0')}${hours < 12 ? text.am : text.pm}`
61 if (reset.toDateString() === new Date(now).toDateString()) return time
62 return `${reset.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}, ${time}`
63}
64
65// The line under the pane's hint and in the pill's tip, or null when the turn did not stop.
66export function stopLine(stop: Stop | undefined, now: number): StopView | null {
67 if (!stop) return null
68 const kind = kindOf(stop.error)
69 const resume = stop.resetsAt === undefined ? '' : ` ${fill(text.resumeAfter, { time: resumeAt(stop.resetsAt, now) })}`
70 return { line: fill(text.stopped, { ago: formatElapsed(now - stop.at), reason: text.stopKind[kind] }) + resume, tone: TONE[kind] }
71}
72hooks/settings.tsx 297 lines1// hooks/settings.tsx, by v-amorim
2// Shared settings body; every mod carries the same copy.
3import type { ConfigRow, ConfigValue, RenderElement } from 'claude-code'
4
5import type { EntryProps, Tip } from './entry'
6import { cardFill, cardGap, section, sepText, type Look, type Reveal } from './look'
7import style from './style'
8import { CAPS, fill, MARK, THEME } from './theme'
9
10// A button a mod offers beside its settings: runs `/<command> <args>`, declared in its showcase.json.
11// isLocal marks the drawing mod's own command, which it calls directly, so Claude working does not hold it.
12export type Action = { label: string; command: string; args?: string; isLocal?: boolean }
13
14type Elements = { Box: (props: any) => RenderElement; Button: (props: any) => RenderElement; Client: (props: any) => RenderElement; Text: (props: any) => RenderElement }
15
16export type SettingsProps = {
17 // The mod's own /config rows (`<plugin>.<field>`), as $.config.list() returns them.
18 rows: ConfigRow[]
19 actions: Action[]
20 // Another mod's command run while Claude works only queues as a prompt, so those actions go inert.
21 working: boolean
22 run: (action: Action) => unknown
23 // The animations setting is off: a clicked control does not shimmer.
24 still?: boolean
25 // Hover tips by setting field (the key after the dot); a setting without one shows its description's first sentence.
26 tips?: Record<string, string>
27 // The mod's example cards, from its showcase.json, so its own panel and mod-manager draw the same ones.
28 previews?: Previews
29 // The mod's plugin.json userConfig, read by its own panel and by mod-manager alike, for the min and max a number setting declares.
30 fields?: Record<string, Range>
31 // Text settings (by field) another mod edits in its own panel: the card shows a pill running that panel's command in place of "edit it in /config".
32 editors?: Record<string, Action>
33}
34
35export type Range = { min?: number; max?: number }
36
37// A title's trailing "(unit)" is what its number reads in; the head drops it and the value carries it.
38export function splitTitle(label: string) {
39 const match = /^(.*?)\s*\(([^()]+)\)$/.exec(label)
40 return match ? { name: match[1]!, unit: match[2]! } : { name: label, unit: '' }
41}
42
43// Large round counts read short, in thousands or millions.
44const short = (n: number) => (n >= 1_000_000 && n % 100_000 === 0 ? `${n / 1_000_000}M` : n >= 10_000 && n % 1000 === 0 ? `${n / 1000}K` : String(n))
45
46export function showNumber(value: number, unit: string) {
47 if (!unit) return String(value)
48 if (unit === '%') return `${value}%`
49 return `${short(value)} ${value === 1 ? unit.replace(/s$/, '') : unit}`
50}
51
52// About twenty clicks from one end of the range to the other, on a 1, 2 or 5 step; no range steps by one.
53export function stepOf({ min, max }: Range = {}) {
54 if (min === undefined || max === undefined || max <= min) return 1
55 const raw = (max - min) / 20
56 const power = 10 ** Math.floor(Math.log10(raw))
57 const nearest = [1, 2, 5, 10].map(factor => factor * power).reduce((best, step) => (Math.abs(step - raw) < Math.abs(best - raw) ? step : best))
58 return Math.max(1, nearest)
59}
60
61export function stepNumber(value: number, by: 1 | -1, range: Range = {}) {
62 return Math.min(range.max ?? Infinity, Math.max(range.min ?? -Infinity, value + by * stepOf(range)))
63}
64
65type ThemeColor = keyof typeof THEME
66
67// A literal run of an example line, as the mod's previews test drew it from its real tree; a color is a theme name or a #hex.
68export type PreviewSpan = { text: string; color?: string; bg?: string; bold?: boolean }
69export type PreviewLine = { spans: PreviewSpan[] }
70export type Preview = { frame?: { border: string; bg: string }; nerd: PreviewLine[]; safe: PreviewLine[] }
71
72// showcase.json's "previews", written by `just previews`: per setting field, per value as text ("true", "false", an option), the card in each icon mode.
73export type Previews = Record<string, Record<string, Preview>>
74
75const shade = (color: string | undefined) => (color === undefined ? undefined : color in THEME ? THEME[color as ThemeColor] : color)
76
77// What the row's current value looks like, as a card under it; null when the mod drew none for that value.
78function exampleOf({ Box, Text }: Elements, look: Look, previews: Previews | undefined, row: ConfigRow, index: number) {
79 const entry = previews?.[row.key.slice(row.key.indexOf('.') + 1)]?.[String(row.value)]
80 const lines = look.nerd ? entry?.nerd : entry?.safe
81 if (!entry || !lines?.length) return null
82 // The lines carry their own padding and strip, so the frame adds none. The example sinks to bg, so it never matches the card it sits in.
83 const sunk = (color: string | undefined) => (color !== undefined && color === shade(entry.frame?.bg) ? THEME.bg : color)
84 return (
85 <Box key={`setting-preview-${index}`} flexDirection="column" borderStyle="round" borderColor={shade(entry.frame?.border) ?? THEME.border} backgroundColor={THEME.bg} marginTop={1}>
86 {lines.map((line, index) => (
87 <Text key={`line-${index}`} wrap="truncate-end">
88 {line.spans.length === 0 ? ' ' : line.spans.map((span, at) => <Text key={`span-${at}`} color={shade(span.color) ?? THEME.onSurface} {...(span.bg ? { backgroundColor: sunk(shade(span.bg)) } : {})} {...(span.bold ? { bold: true } : {})}>{span.text}</Text>)}
89 </Text>
90 ))}
91 </Box>
92 )
93}
94
95// The value as the panel shows it: on/off as words, a list as its items.
96const shown = (row: ConfigRow) =>
97 row.kind === 'boolean' ? (row.value === true ? style.text.on : style.text.off) : Array.isArray(row.value) ? row.value.join(', ') : String(row.value)
98
99// Words that stay capitalized when a description follows a tip's label and colon.
100const PROPER = new Set(['Claude', 'Haiku', 'Opus', 'Sonnet', 'Windows', 'Nerd'])
101
102// A setting's description as the tip after "Label: ": its first sentence, led by a lowercase letter unless that word is a proper noun.
103export function descriptionTip(description: string) {
104 const sentence = description.split('. ')[0]!.replace(/\.$/, '')
105 const word = /^[A-Z][a-z]+\b/.exec(sentence)?.[0]
106 return word && !PROPER.has(word) ? sentence[0]!.toLowerCase() + sentence.slice(1) : sentence
107}
108
109// A control is an Entry pill posting `setting:<key>=<value as JSON>`; the mod's ui.message hook saves it with settingChoice.
110const SETTING = 'setting:'
111const settingId = (row: ConfigRow, value: ConfigValue) => `${SETTING}${row.key}=${JSON.stringify(value)}`
112
113// The change a clicked settings control asks for, or null when the message is something else.
114export function settingChoice(data: unknown): { key: string; value: ConfigValue } | null {
115 const message = data as { type?: string; id?: string } | undefined
116 if (message?.type !== 'menu-entry' || !message.id?.startsWith(SETTING)) return null
117 const rest = message.id.slice(SETTING.length)
118 const at = rest.indexOf('=')
119 if (at === -1) return null
120 try {
121 return { key: rest.slice(0, at), value: JSON.parse(rest.slice(at + 1)) as ConfigValue }
122 } catch {
123 return null
124 }
125}
126
127// A pill already frames the glyph, so drop the brackets and padding of the standalone button (safe `[↻]`).
128const bare = (glyph: string) => glyph.replace(/[[\]\s]/g, '')
129
130// A pill on the card's tint, capped where the font has the caps; `hover` lights the caps with a Button scoped to the pill's key.
131const pillOn = ({ Box, Text }: Elements, look: Look, key: string, bg: string, body: unknown, on = THEME.surface1, hover?: string) => {
132 const lit = hover ? { hover: { scope: key, color: hover } } : {}
133 return (
134 <Box key={key} flexDirection="row" flexShrink={0}>
135 {look.nerd ? <Text key={`${key}-left`} color={bg} backgroundColor={on} {...lit}>{CAPS.left}</Text> : null}
136 {body}
137 {look.nerd ? <Text key={`${key}-right`} color={bg} backgroundColor={on} {...lit}>{CAPS.right}</Text> : null}
138 </Box>
139 )
140}
141
142export type Choice = { key: string; isOn: boolean; props: Pick<EntryProps, 'id' | 'label' | 'still' | 'tip' | 'subLead' | 'subLeadColor'> }
143
144// A control track: a surface3 pill on `on`, padded by a space each side in safe mode, where it has no caps.
145function track(elements: Elements, look: Look, key: string, on: string, parts: unknown[]) {
146 const { Box, Text } = elements
147 const space = (side: string) => (look.nerd ? null : <Text key={`${key}-space-${side}`} backgroundColor={THEME.surface3}> </Text>)
148 return pillOn(elements, look, `${key}-bar`, THEME.surface3, <Box key={`${key}-options`} flexDirection="row" backgroundColor={THEME.surface3}>{[space('left'), ...parts, space('right')]}</Box>, on)
149}
150
151// A choice in a track: the pick a lifted (surface-4) subpill in primary-70 (`ok`), the rest muted; `on` is what the track sits on.
152export function choiceBar(elements: Elements, look: Look, key: string, on: string, choices: Choice[]) {
153 const { Client } = elements
154 const entry = (choice: Choice, shape: Partial<EntryProps>) => (
155 <Client key={choice.key} module="./entry.tsx" height={1} props={{ ...choice.props, isOpen: choice.isOn, bg: THEME.surface3, ...shape } satisfies EntryProps} />
156 )
157 const options = choices.map(choice =>
158 choice.isOn
159 ? // Tight, so the subpill is as wide as an idle option: the caps (Nerd) or a space (safe) stand where its padding was.
160 pillOn(elements, look, `${choice.key}-chosen`, THEME.lifted, entry(choice, { bg: THEME.lifted, color: THEME.ok, idle: THEME.muted, nerd: look.nerd, tight: true }), THEME.surface3)
161 : entry(choice, { idle: THEME.muted }),
162 )
163 return track(elements, look, key, on, options)
164}
165
166const actionScope = (index: number) => `action-row-${index}`
167
168// The tip each live action shows in the pane footer on hover; a Button cannot post one, so paneFooter reveals it by the pill's hover group.
169export const actionReveals = (actions: Action[], look: Look, working: boolean): Reveal[] =>
170 actions.flatMap((action, index) =>
171 working && !action.isLocal ? [] : [{ scope: actionScope(index), tip: { glyph: bare(look.glyph.go), label: action.label, text: fill(style.text.runsCommand, { command: [action.command, action.args].filter(Boolean).join(' ') }) } }],
172 )
173
174const editorScope = (index: number) => `editor-row-${index}`
175
176const fieldOf = (row: ConfigRow) => row.key.slice(row.key.indexOf('.') + 1)
177
178// The footer tip of each live editor pill, like actionReveals.
179export const editorReveals = (rows: ConfigRow[], editors: Record<string, Action>, look: Look, working: boolean): Reveal[] =>
180 rows.flatMap((row, index) => {
181 const action = row.kind === 'text' && !row.isLocked ? editors[fieldOf(row)] : undefined
182 return action && !working ? [{ scope: editorScope(index), tip: { glyph: MARK.open, label: action.label, text: fill(style.text.runsCommand, { command: [action.command, action.args].filter(Boolean).join(' ') }) } }] : []
183 })
184
185// A pill with a plain Button so the press is a real one; the glyph sits in the label so the whole pill lights.
186const buttonPill = (elements: Elements, look: Look, scope: string, key: string, glyph: string, label: string, onPress: () => unknown) => {
187 const { Button } = elements
188 return pillOn(
189 elements,
190 look,
191 scope,
192 THEME.surface3,
193 <Button key={key} label={look.nerd ? `${glyph} ${label}` : ` ${glyph} ${label} `} plain hover={{ scope, color: THEME.accent, inverse: true }} onPress={onPress} />,
194 THEME.surface1,
195 THEME.accent,
196 )
197}
198
199// Edits the row in place: on/off flips, a choice is a bar, a number steps; text is edited in /config.
200function control(elements: Elements, look: Look, row: ConfigRow, index: number, still: boolean, tips?: Record<string, string>, fields?: Record<string, Range>) {
201 const { Client, Text } = elements
202 const key = `setting-${index}`
203 const text = tips?.[row.key.slice(row.key.indexOf('.') + 1)] ?? (row.description ? descriptionTip(row.description) : '')
204 // Client props refuse an undefined value, so no tip text means no key.
205 const tipFor = (glyph: string) => (text ? { tip: { glyph: bare(glyph), label: splitTitle(row.label).name, text } satisfies Tip } : {})
206 const entry = (suffix: string, label: string, value: ConfigValue, bg: string, color: string, isOpen: boolean, glyph = look.glyph.cycle) => (
207 <Client key={`${key}-${suffix}`} module="./entry.tsx" height={1} props={{ id: settingId(row, value), label, isOpen, bg, color, idle: color, still, ...tipFor(glyph) } satisfies EntryProps} />
208 )
209 if (row.isLocked) return <Text color={THEME.muted}>{`${shown(row)} ${bare(look.glyph.locked)} ${style.text.locked}`}</Text>
210 if (row.kind === 'boolean') {
211 // The switch: `━━●` on, `●━━` off, the knob a bare glyph joined to the line.
212 const isOn = row.value === true
213 const shape = isOn
214 ? { lead: MARK.track, leadColor: THEME.accent, label: MARK.knob, idle: THEME.ok }
215 : { lead: MARK.knob, leadColor: style.colors.pills.readout, label: MARK.track, idle: THEME.trackOff }
216 const flip = <Client key={`${key}-flip`} module="./entry.tsx" height={1} props={{ id: settingId(row, !isOn), isOpen: false, bg: THEME.surface3, tight: true, still, ...shape, ...tipFor(look.glyph.cycle) } satisfies EntryProps} />
217 return track(elements, look, key, cardFill(), [flip])
218 }
219 if (row.kind === 'choice' && row.options?.length) {
220 const choices = row.options.map((option, at) => ({ key: `${key}-option-${at}`, isOn: option === String(row.value), props: { id: settingId(row, option), label: option, still, ...tipFor(look.glyph.cycle) } }))
221 return choiceBar(elements, look, key, cardFill(), choices)
222 }
223 if (row.kind === 'number' && typeof row.value === 'number') {
224 const range = fields?.[row.key.slice(row.key.indexOf('.') + 1)]
225 const value = <Text key={`${key}-value`} color={THEME.ok} backgroundColor={THEME.lifted} bold>{look.nerd ? showNumber(row.value, splitTitle(row.label).unit) : ` ${showNumber(row.value, splitTitle(row.label).unit)} `}</Text>
226 return track(elements, look, key, cardFill(), [
227 entry('down', bare(look.glyph.down), stepNumber(row.value, -1, range), THEME.surface3, THEME.muted, false, look.glyph.down),
228 pillOn(elements, look, `${key}-value-pill`, THEME.lifted, value, THEME.surface3),
229 entry('up', bare(look.glyph.up), stepNumber(row.value, 1, range), THEME.surface3, THEME.muted, false, look.glyph.up),
230 ])
231 }
232 return <Text color={THEME.secondary}>{shown(row)}</Text>
233}
234
235// A setting's first row: the label bold on the left, its control anchored right.
236export const settingHead = ({ Box, Text }: Pick<Elements, 'Box' | 'Text'>, key: string, label: string, control: unknown) => (
237 <Box key={key} flexDirection="row" columnGap={2} justifyContent="space-between">
238 <Text color={THEME.onSurface} bold>{label}</Text>
239 {control}
240 </Box>
241)
242
243// A hairline across its box, under a setting's head row; a Client, since only a Client learns the width it gets.
244export const hairline = ({ Client }: Pick<Elements, 'Client'>, key: string, bg: string) => (
245 <Client key={key} module="./entry.tsx" width="100%" height={1} props={{ id: key, label: '', isOpen: false, bg, rule: true } satisfies EntryProps} />
246)
247
248// The Settings and Actions sections of a mod's panel or page: one round-bordered card per setting.
249export function settingsBody(elements: Elements, look: Look, { rows, actions, working, run, previews, tips, fields, editors = {}, still = false }: SettingsProps) {
250 const { Box, Text } = elements
251 const settings = [
252 rows.length === 0 ? <Text key="settings-none" color={THEME.muted}>{style.text.noSettings}</Text> : null,
253 ...rows.map((row, index) => {
254 const example = exampleOf(elements, look, previews, row, index)
255 const editor = row.kind === 'text' && !row.isLocked ? editors[fieldOf(row)] : undefined
256 const head = editor && !working ? (
257 <Box key={`setting-control-${index}`} flexDirection="row" columnGap={1}>
258 {control(elements, look, row, index, still, tips, fields)}
259 {buttonPill(elements, look, editorScope(index), `setting-editor-${index}`, MARK.open, editor.label, () => run(editor))}
260 </Box>
261 ) : (
262 control(elements, look, row, index, still, tips, fields)
263 )
264 const hint = row.kind !== 'text' ? '' : editor ? (working ? style.text.idleOnly : '') : style.text.editInConfig
265 const note = [row.description, hint].filter(Boolean).join(` ${MARK.sep} `)
266 return (
267 <Box key={`setting-row-${index}`} flexDirection="column" borderStyle="round" borderColor={THEME.border} backgroundColor={cardFill()} paddingX={1} marginTop={index === 0 ? 0 : 1}>
268 {settingHead(elements, `setting-head-${index}`, splitTitle(row.label).name, head)}
269 {row.description || example ? hairline(elements, `setting-rule-${index}`, cardFill()) : null}
270 {row.description ? (
271 sepText(elements, note, THEME.muted, `setting-text-${index}`)
272 ) : null}
273 {example}
274 </Box>
275 )
276 }),
277 ]
278 const buttons = actions.map((action, index) =>
279 working && !action.isLocal ? (
280 sepText(elements, `${look.glyph.go} ${action.label} ${MARK.sep} ${style.text.idleOnly}`, THEME.muted, `action-${index}`)
281 ) : (
282 buttonPill(elements, look, actionScope(index), `action-${index}`, bare(look.glyph.go), action.label, () => run(action))
283 ),
284 )
285 return (
286 <Box key="settings" flexDirection="column">
287 {/* The cards carry their own border, and the panel's open Settings tab already names them. */}
288 <Box key="settings-cards" flexDirection="column">
289 {cardGap(elements, 'settings')}
290 {settings}
291 </Box>
292 {actions.length > 0 ? section(elements, look, 'actions', 'actions', style.text.actions, buttons) : null}
293 </Box>
294 )
295}
296
297hooks/text.ts 87 lines1// mod-progress / hooks/text.ts, by v-amorim
2// Every string this mod shows. Edit the values to reword or translate; keep the keys and the {slots}.
3export default {
4 "pillLabel": "Progress",
5 "tipPill": "every agent with its context, tokens and gates",
6 "tipPillGates": "{count} running, gates {done}/{total}",
7 "tipPillWaiting": "{sep} {n} waiting",
8 "groupOpen": "Open",
9 "groupWaiting": "Waiting on evidence",
10 "groupMet": "Met",
11 "groupDropped": "Dropped",
12 "viewCards": "Cards",
13 "viewTimeline": "Timeline",
14 "tipViewCards": "shows each agent as a card with its gates",
15 "tipViewTimeline": "shows every agent as a lane on one clock",
16 "tipLane": "selects this agent, as clicking its card does",
17 "tipLoop": "shows or hides this agent's gates",
18 "openTranscript": "Open its transcript",
19 "backToMain": "Back to main",
20 "paneTitle": "Progress",
21 "settingsPaneTitle": "mod-progress",
22 "settingsTab": "Settings",
23 "hint": "Click an agent to see its gates",
24 "settingsHint": "Click a setting to change it {sep} The mod reloads",
25 "settingSaved": "{label} saved",
26 "progressWaiting": "Progress panel waiting: {reason}",
27 "settingsWaiting": "Settings panel waiting: {reason}",
28 "commandDescription": "Open the progress sidebar, or its settings with `settings`",
29 "mainName": "main",
30 "workflowAgent": "Workflow agent {n}",
31 "workflowRun": "Workflow {id} {sep} {done} of {total} done",
32 "mainTask": "this session",
33 "taskShell": "shell",
34 "taskMonitor": "monitor",
35 "tipTask": "opens this task's command and output",
36 "tipOpenTranscript": "shows this agent's own conversation in the transcript",
37 "tipBackToMain": "returns the transcript to the main session",
38 "tipBack": "returns to the list of agents",
39 "taskStatus": "{kind} {sep} {status} {sep} {elapsed}",
40 "taskRunning": "running",
41 "taskDone": "done",
42 "taskFailed": "failed",
43 "noOutputFile": "No output file for this task",
44 "noOutputYet": "No output yet",
45 "outputUnreadable": "Output file not readable (over 4 MiB or gone)",
46 "commandLead": "$",
47 "commandTitle": "Command",
48 "outputTitle": "Output",
49 "moreLines": "{n} more lines",
50 "showAll": "Show all",
51 "showLess": "Show less",
52 "tipShowAll": "shows every line",
53 "tipShowLess": "shows the last lines only",
54 "shellOne": "1 shell",
55 "shellMany": "{n} shells",
56 "taskOne": "1 task",
57 "taskMany": "{n} tasks",
58 "tipFoldOpen": "hides this agent's background tasks",
59 "tipFoldShut": "shows this agent's background tasks",
60 "foldAll": "Fold all",
61 "unfoldAll": "Unfold all",
62 "tipFoldAll": "folds every agent's background tasks to a count",
63 "tipUnfoldAll": "shows every agent's background tasks",
64 "noGates": "No gates yet. Add a GATES.md",
65 "stepRead": "Reading {what}",
66 "stepEdit": "Editing {what}",
67 "stepWrite": "Writing {what}",
68 "stepSearch": "Searching for {what}",
69 "stepRun": "Running {what}",
70 "stepFetch": "Fetching {what}",
71 "stepAgent": "Asking a subagent",
72 "noAgents": "No agents yet, only the main session.",
73 "gaugePercent": "{percent}%",
74 "gatesCount": "{done}/{total}",
75 "phaseDone": "phase done",
76 "tokensTime": "{tokens} {sep} {time}",
77 "unknownModel": "unknown model",
78 "tipTabList": "lists every agent of this session",
79 "tipTabSettings": "shows this mod's settings",
80 "stopped": "Stopped {ago} ago: {reason}.",
81 "resumeAfter": "Resume after {time}.",
82 "stopKind": {"signIn":"sign-in needed","account":"account on hold or needs verification","billing":"billing problem","invalid":"request rejected","usageLimit":"usage limit reached","overloaded":"server overloaded","other":"other error"},
83 "am": "am",
84 "pm": "pm",
85 "tipToast": {"label":"Dismiss","text":"hides this message"}
86}
87hooks/timeline.tsx 71 lines1// mod-progress / hooks/timeline.tsx, by v-amorim: a lane per loop on one shared clock.
2import type { ClientModule } from 'claude-code'
3
4import { postTip } from './entry'
5import { cardFill, lookFor } from './look'
6import type { LoopsMessage } from './loops'
7import marks from './marks'
8import { cutText, laneCells, laneLabels, timelineCols, type Row } from './progress'
9import { keepSpinning, spinFrame, type SpinState } from './spin'
10import text from './text'
11import { decodeAll, MARK, THEME } from './theme'
12
13export type TimelineProps = { rows: Row[]; width: number; expanded: string; nerd: boolean; viewing: string; now: number; still?: boolean }
14
15// @ts-expect-error Row holds undefined fields, which the host's JsonValue props type refuses.
16const Timeline: ClientModule<TimelineProps, SpinState> = (props, surface) => {
17 const { Box, Text } = surface.elements
18 const { rows, width, expanded, nerd, viewing, now } = props
19 const look = lookFor(nerd)
20 const own = decodeAll(marks.glyphs)[nerd ? 'nerd' : 'safe']
21 const hovered = surface.state?.at ?? -1
22 const spin = props.still ? 0 : (surface.state?.spin ?? 0)
23 keepSpinning(surface, rows.some(row => row.status === 'running'), props.still)
24 const durs = rows.map(row => row.elapsed || '0s')
25 const cols = timelineCols(width, Math.max(3, ...durs.map(dur => dur.length)))
26 const labels = laneLabels(rows)
27 const lanes = laneCells(rows.map(row => ({ start: row.startedAt, end: row.endedAt, isRunning: row.status === 'running' })), now, cols.lane)
28
29 surface.onPointer(event => {
30 const at = event.type === 'leave' || event.y < 0 || event.y >= rows.length ? -1 : event.y
31 if (at !== hovered) {
32 surface.setState({ at, spin: surface.state?.spin ?? 0 })
33 const row = rows[at]
34 postTip(surface, 'loops', row ? { glyph: row.id === expanded ? look.glyph.expanded : look.glyph.collapsed, label: row.name, text: text.tipLane } : null)
35 }
36 if (event.type === 'down' && event.button === 'left' && at >= 0) surface.post({ type: 'toggle', id: rows[at]!.id } satisfies LoopsMessage)
37 })
38
39 return (
40 <Box flexDirection="column">
41 {rows.map((row, at) => {
42 const key = `lane-${at}`
43 const isHover = hovered === at
44 const mark = row.status === 'running' ? { glyph: spinFrame(spin), color: THEME.accent } : row.status === 'done' ? { glyph: look.glyph.ok, color: THEME.good } : { glyph: look.glyph.warn, color: THEME.alert }
45 const { from, to } = lanes[at]!
46 const spanColor = row.status === 'running' ? THEME.accent : row.status === 'done' ? THEME.ok : THEME.alert
47 return (
48 <Box key={key} height={1} width={width} flexDirection="row" backgroundColor={isHover ? THEME.lifted : cardFill()}>
49 <Text wrap="truncate-end">
50 <Text>{' '}</Text>
51 <Text color={THEME.accent} bold>{row.id === viewing ? MARK.cursor : ' '}</Text>
52 <Text color={mark.color}>{mark.glyph}</Text>
53 <Text>{' '}</Text>
54 <Text color={isHover ? THEME.accent : THEME.onSurface} bold={row.id === expanded}>{cutText(labels[at]!, cols.name).padEnd(cols.name)}</Text>
55 <Text>{' '}</Text>
56 <Text color={THEME.border}>{own.laneIdle.repeat(from)}</Text>
57 <Text color={spanColor}>{own.laneSpan.repeat(to - from)}</Text>
58 <Text color={THEME.border}>{own.laneIdle.repeat(cols.lane - to)}</Text>
59 <Text>{' '}</Text>
60 <Text color={THEME.muted}>{durs[at]!.padStart(cols.dur)}</Text>
61 <Text>{' '}</Text>
62 </Text>
63 </Box>
64 )
65 })}
66 </Box>
67 )
68}
69
70export default Timeline
71hooks/theme.ts 46 lines1// hooks/theme.ts, by v-amorim
2// Shared theme; every mod carries the same copy. Colors, marks and glyphs live in style.ts.
3import style from './style'
4
5export const THEME = style.colors.theme
6
7// Shell command roles: what runs, its options, quoted text, variables, numbers and the glue between steps.
8export const SYNTAX = style.colors.syntax
9
10// One cell each and never an emoji: ⚠ switches to its emoji form in some fonts and draws two cells wide.
11export const MARK = style.marks
12
13// style.ts writes Nerd glyphs as {U+XXXX}, since private-use characters do not survive many editors and tools.
14const decode = (text: string) => text.replace(/\{U\+([0-9A-F]+)\}/gi, (_, hex: string) => String.fromCodePoint(parseInt(hex, 16)))
15
16export function decodeAll<T>(value: T): T {
17 if (typeof value === 'string') return decode(value) as T
18 if (Array.isArray(value)) return value.map(decodeAll) as T
19 return Object.fromEntries(Object.entries(value as object).map(([key, inner]) => [key, decodeAll(inner)])) as T
20}
21
22// A pill's rounded ends, drawn only where the font has them.
23export const CAPS = decodeAll(style.caps)
24
25export const statusColor = (note: string) =>
26 note.startsWith(MARK.ok) ? THEME.good : note.startsWith(MARK.warn) ? THEME.alert : THEME.muted
27
28const channels = (hex: string) => [1, 3, 5].map(at => parseInt(hex.slice(at, at + 2), 16))
29const isHex = (color: string) => /^#[0-9a-f]{6}$/i.test(color)
30
31// The color `t` of the way from `from` to `to`; a color that is not #RRGGBB switches halfway.
32export function mix(from: string, to: string, t: number) {
33 if (t <= 0) return from
34 if (t >= 1) return to
35 if (!isHex(from) || !isHex(to)) return t < 0.5 ? from : to
36 const end = channels(to)
37 return `#${channels(from).map((value, at) => Math.round(value + (end[at]! - value) * t).toString(16).padStart(2, '0')).join('')}`
38}
39
40// The share of a hue left in a subpill or count badge, washed into the fill it sits on.
41export const WASH = 0.72
42
43// A text.ts string with its {name} slots filled; a slot with no value stays as written, so a missing one shows.
44export const fill = (template: string, values: Record<string, string | number> = {}) =>
45 template.replace(/\{(\w+)\}/g, (slot, name: string) => (name in values ? String(values[name]) : slot))
46