SLOPSHOPPER

mod-progress

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…

newpanebandrowsguardcommand
★ 2v1.2.4MITupdated 2026-10-26v-amorim/moonlight-claude-mods/mod-progress
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mod-progress
│ ┃ Progress ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ client module ./entry.tsx │ ┃ Click an agent to see its gates ⏺ Read(src/auth.ts) │ ┃ ▣ client module ./entry.tsx▣ client module ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ╭────────────────────────────────────────╮ ⏺ Bash(bun test) │ ┃ │ ▣ client module ./loops.tsx │ ⎿ 3 pass, 1 fail │ ┃ ╰────────────────────────────────────────╯ │ ┃ No agents yet, only the main session. ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ ─────────────────────────────────────────── │ ┃ › /mod-progress │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ⟨Claude Code's own drawing⟩ ▣ client module ./entry.tsx ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ▣ client module ./entry.tsx
Pane · Progress
▣ client module ./entry.tsx Click an agent to see its gates ▣ client module ./entry.tsx▣ client module ./entry.tsx ╭────────────────────────────────────────────────────────╮ │ ▣ client module ./loops.tsx │ ╰────────────────────────────────────────────────────────╯ No agents yet, only the main session. ────────────────────────────────────────────────────────
README

<!-- mod-progress / README.md, by v-amorim --> <!-- Generated from the root README, plugin.json and showcase.json; edit those. -->

mod-progress

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.

Install

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

How to open

Progress pill.

See it

The pillThe panelA 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

Features

  • Shell output: A background shell shows its command and live output. A cut line wraps in full, so nothing is lost at the edge. Show all opens the whole output, and Show less folds it back.
  • Timeline: Switch the pane from Cards to Timeline to see every agent as a lane on one clock: when it started, how long it ran and whether it is still running. A click on a lane opens it like a click on its card.
  • The gates view: A card per loop with one cell per gate: met, waiting on evidence (checked, but its evidence still reads pending), open or dropped. A click on the card groups its gates by state, with how long each open or waiting gate has been so. The band tip counts every gate in the session and the ones waiting. Gates come from GATES.md files written by your own script or tool, not from the model. The rest of the mod works without them.
  • GATES.md format: This is the mod's own plan format; nothing else has to produce it. The ledger is 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.
  • Command hooks: On SessionStart, Stop, SubagentStop and StopFailure, a small shell hook appends the event's JSON as one line to ~/.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.

Settings

Change them with /mod-progress settings or the gear pill.

SettingDefaultWhat it does
Iconsautoauto 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
AnimationsonEntries shimmer while they wait. Hover colors also fade. Off, everything draws still

License

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

Source 17 files
hooks/register.tsx 993 lines
1// 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}
993
hooks/bridge.ts 107 lines
1// 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}
107
hooks/entry.tsx 241 lines
1// 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
241
hooks/look.tsx 279 lines
1// 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
279
hooks/loops.tsx 260 lines
1// 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
260
hooks/pills.tsx 167 lines
1// 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}
167
hooks/progress.ts 536 lines
1// 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}
536
hooks/stop.ts 72 lines
1// 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}
72
hooks/settings.tsx 297 lines
1// 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
297
hooks/text.ts 87 lines
1// 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}
87
hooks/timeline.tsx 71 lines
1// 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
71
hooks/theme.ts 46 lines
1// 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