Live plan progress bars above the Claude Code prompt: stages, steps, a pixel fill and soft sounds for decision, error and done

在 Claude Code 輸入框上方顯示即時進度條:Claude 接到多步驟任務時,會把工作拆成幾個階段和步驟,邊做邊更新,你看得到它走到哪。
這個版本是 zycck/claude-mods 裡 plan-progress 外掛的 fork(衍生版本),在原作上加了 6 種可切換的外觀和一個樣式面板。
Kirill Serditov(@zycck),以 MIT 授權發布。
本專案從原作 commit ea2c96b 複製而來。以下功能都是原作者做的,本版本沿用:
/progress、/progress-demo、/progress-sounds、/progress-clear 指令original 樣式)由 @0x5421 修改。
| 類別 | 內容 |
|---|---|
| 新增 5 種樣式 | 分段 segments、細線 hairline、串珠 beads、刻度字 ledger、路線圖 transit,畫法在 hooks/styles.ts |
| 樣式面板 | 點輸入框下方的齒輪 ⚙(或輸入 /progress-style)打開,6 種樣式各有即時預覽,點「使用」立刻切換 |
| 底部只留齒輪 | 原版底部的 Progress 按鈕拿掉,只留一個 ⚙;顯示/隱藏進度條改在面板最上面切換 |
| 隱藏會一直記住 | 原版隱藏後,有新進度條或開新 session 就會自動跳回顯示。現在隱藏後一直保持隱藏,直到你按「顯示」或用 /progress-demo |
| 提示音開關 | 面板裡可以開關提示音,預設開;關掉後 /progress-sounds 試聽仍會響 |
| Subagent 顯示 | 面板最上面切換「展開/摘要/隱藏」:每個 subagent 一條、合成一行只顯示各狀態數量、或完全不顯示 |
| 完成音等回覆寫完才響 | 原版 Claude 一把最後一步標完成就變 Done、響完成音,那時回覆還沒寫。現在進度條先停在最後一步,等這一輪回覆整則寫完才變 Done、響一次完成音;subagent 全部跑完的完成音也一樣等到回覆寫完 |
| 其他 session 的進度條 | 桌面版每個 session 會把自己的狀態和進度條寫進 ~/.claude/plan-progress/sessions/。其他還在跑、跑完或在等你決定的 session,會在你的進度條下方各顯示一條(它最新的那條進度條,用你選的樣式畫),最多 5 條;點左邊的標題就切換過去。按 ✕ 會先變淡再消失,直到那個 session 換到下一個狀態才再出現。⚙ 面板的「其他 session」可以開關,預設開 |
| 自動收掉完成的進度條 | 原版完成的進度條會一直留著(最多 3 條)。現在你送出下一則訊息時,完成的那幾條會漸淡後消失,進行中的不受影響 |
| 記住選擇 | 選好的樣式、subagent 顯示方式、進度條顯示/隱藏、提示音開關都會存起來,下次開 session 沿用 |
| 切換指令 | /progress-style 打開面板並列出樣式;/progress-style <名稱> 直接切換;/progress-style next 換下一個 |
| 深淺色 | 新樣式的顏色跟著深淺色主題切換 |
| 規則調整 | 階段名一律用英文,任務標題和步驟仍用使用者的語言 |
| 修正 | 原版 subagent 條在淺色主題下文字幾乎看不見 |
| 修正 | 每一列的標題起點和進度條左邊緣都對齊:右邊的百分比或時間放進同寬的欄位,所有標題都用同一種無邊框按鈕;標題最多佔整列 40%,再長才截斷 |
| 修正 | 判斷「Claude 在問你問題」時也認得中文全形問號「?」,不會再把中文提問的回覆擋回去 |
| 測試 | tests/panel.test.ts:畫出 ⚙ 按鈕、樣式面板和 6 種樣式的進度條,並按按鈕確認會切換 |
| 名稱 | 說明 |
|---|---|
segments(預設) | 一個階段一格,所有階段名都看得到,陶土橘 |
hairline | 2px 細線+會呼吸的線頭,平常黑白,右邊顯示經過時間 |
beads | 一步一顆珠子,同階段用線串起來,藍色 |
ledger | 等寬字+小方格,正在做的那格像游標閃,只有要注意時才有顏色 |
transit | 像捷運路線圖,站名在下方,紫色,比其他樣式高一點 |
original | 原作者的像素顆粒進度條 |
在 Claude Code 外的終端機執行:
claude plugin marketplace add 0x5421/plan-progress
claude plugin install plan-progress@fork-zycck-mods
已經下載到本機的話,第一行改成 claude plugin marketplace add /path/to/plan-progress。
開一個新的 session 就會載入。
先把 plugins/plan-progress/.claude-plugin/plugin.json 的 version 加一,再執行:
claude plugin update plan-progress@fork-zycck-mods
版本號沒變的話,更新指令會說已經是最新版而不更新。
/progress-style,在面板裡點「使用」/progress-demo/progress/progress-clear進度條在桌面版 app 顯示圖形;在終端機只顯示文字進度條,樣式切換不影響終端機。
claude plugin test plugins/plan-progress
MIT。原作版權屬於 Kirill Serditov,見 LICENSE;依 MIT 授權,轉發或修改時必須保留這份版權聲明與授權文字。本版本的修改同樣以 MIT 授權提供。
hooks/register.tsx 1446 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AgentRun, OtherSession, OthersView, Plan, PlanStage, PlanState, PlanStep, StepStatus } from '../types'
5import { DEFAULT_STYLE, STYLE_IDS, STYLE_INFO, STYLES, isStyleId } from './styles'
6import type { StyleId } from './styles'
7
8const TOOL = 'mcp__plan-progress__plan_progress'
9const plans = atom({ plugin: 'plan-progress', key: 'plans' } as const, [])
10const MAX_BARS = 3
11// a space as wide as a digit, so ' 0%' and '100%' take the same room
12const FIGURE_SPACE = String.fromCharCode(0x2007)
13// whether the bars show; kept in $.store too, so hidden stays hidden across sessions and new bars
14// until the person shows them again
15const isOpen = atom({ plugin: 'plan-progress', key: 'isOpen' } as const, true)
16const SHOWN_STORE_KEY = 'isOpen'
17const setShown = async ($: EngineInterface, shown: boolean) => {
18 await update($, isOpen, () => shown)
19 await $.store.set(SHOWN_STORE_KEY, shown)
20}
21// whether the decision, error and done sounds play; kept in $.store the same way
22const soundsOn = atom({ plugin: 'plan-progress', key: 'sounds' } as const, true)
23const SOUNDS_STORE_KEY = 'sounds'
24const setSounds = async ($: EngineInterface, isOn: boolean) => {
25 await update($, soundsOn, () => isOn)
26 await $.store.set(SOUNDS_STORE_KEY, isOn)
27}
28const tick = atom({ plugin: 'plan-progress', key: 'tick' } as const, 0)
29// the chosen look; kept in $.store too so a new session starts with it
30const barStyle = atom({ plugin: 'plan-progress', key: 'style' } as const, DEFAULT_STYLE as string)
31const STYLE_STORE_KEY = 'style'
32const styleOf = async ($: EngineInterface): Promise<StyleId> => {
33 const v = await read($, barStyle)
34 return isStyleId(v) ? v : DEFAULT_STYLE
35}
36const setStyle = async ($: EngineInterface, id: StyleId) => {
37 await update($, barStyle, () => id)
38 await $.store.set(STYLE_STORE_KEY, id)
39}
40// the pane that lists every style with a live preview; opened from the ⚙ beside the Progress button
41const STYLE_PANE = 'plan-progress-styles'
42const STYLE_PANE_TITLE = '進度條樣式'
43
44// what the footer's gear does: it opens the style pane, where the bars are also shown or hidden
45async function openStyles($: EngineInterface) {
46 return $.ui.open({ id: STYLE_PANE, title: STYLE_PANE_TITLE })
47}
48// the gear that opens the pane; U+FE0E asks for the plain text glyph, not the colour emoji
49const SETTINGS_GLYPH = '⚙︎'
50
51// how subagent strips show under a bar: one strip each, one summary line, or none
52const AGENT_VIEWS = ['expanded', 'summary', 'hidden'] as const
53type AgentView = (typeof AGENT_VIEWS)[number]
54const AGENT_VIEW_LABEL: Record<AgentView, string> = { expanded: '展開', summary: '摘要', hidden: '隱藏' }
55const isAgentView = (v: unknown): v is AgentView => typeof v === 'string' && (AGENT_VIEWS as readonly string[]).includes(v)
56const agentView = atom({ plugin: 'plan-progress', key: 'agentView' } as const, 'expanded' as string)
57const AGENT_VIEW_STORE_KEY = 'agentView'
58const agentViewOf = async ($: EngineInterface): Promise<AgentView> => {
59 const v = await read($, agentView)
60 return isAgentView(v) ? v : 'expanded'
61}
62const setAgentView = async ($: EngineInterface, v: AgentView) => {
63 await update($, agentView, () => v)
64 await $.store.set(AGENT_VIEW_STORE_KEY, v)
65}
66const STRIP_H = 18
67const STRIP_GAP = 3
68const MAX_STRIPS = 4 // past this, the finished ones fold into one "+N more" strip
69const FOLD_MS = 5000 // finished strips stay this long, failed ones stay until the bar closes
70
71const STATE_COLOR: Record<PlanState, string> = { running: '#8B7CF6', needs_input: '#E09A1E', error: '#E5484D', done: '#30A46C' }
72const STATE_GLYPH: Record<PlanState, string> = { running: '●', needs_input: '?', error: '!', done: '✓' }
73const STATUSES: StepStatus[] = ['pending', 'active', 'done', 'error', 'skipped']
74const TRACK_H = 22
75const NARROW = 360
76
77const RULES = `# Progress bars
78Tasks needing more than ~3 edits or commands get a bar via ${TOOL}: create it once with the full breakdown (2-7 stages with short steps, or kind "todo" for one flat list; titles of at most 4 words; the bar title and step titles in the user's language, stage names always in English), then update it with short calls only: {id, next:true} when the active step is finished, or {id, done:[...], active:"..."}, {id, failed:"...", note}. Send state "needs_input" with a note before asking the user to decide. Never describe the bars to the user.`
79
80type Raw = Record<string, unknown>
81const str = (v: unknown, max = 120) => (typeof v === 'string' ? v.replace(/\s+/g, ' ').trim().slice(0, max) : '')
82const status = (v: unknown): StepStatus => (STATUSES.includes(v as StepStatus) ? (v as StepStatus) : 'pending')
83const list = (v: unknown): Raw[] => (Array.isArray(v) ? v.filter(x => x && typeof x === 'object') : []) as Raw[]
84const isFinished = (s: StepStatus) => s === 'done' || s === 'skipped'
85
86const same = (a: string, b: string) => a.trim().toLowerCase() === b.trim().toLowerCase()
87
88// short updates: {next:true}, {done:[titles]}, {active:title}, {failed:title} against the stored plan
89function applyOps(stages: PlanStage[], input: Raw): PlanStage[] {
90 const next = stages.map(s => ({ ...s, steps: s.steps.map(st => ({ ...st })) }))
91 const steps = next.flatMap(s => s.steps)
92 const find = (title: string) => steps.find(st => same(st.title, title))
93 if (input.next === true) {
94 const at = steps.findIndex(st => st.status === 'active') >= 0 ? steps.findIndex(st => st.status === 'active') : steps.findIndex(st => !isFinished(st.status))
95 const cur = steps[at]
96 if (cur) cur.status = 'done'
97 const following = steps.slice(at + 1).find(st => st.status === 'pending')
98 if (following) following.status = 'active'
99 }
100 for (const t of Array.isArray(input.done) ? input.done : []) {
101 const st = typeof t === 'string' ? find(t) : undefined
102 if (st) st.status = 'done'
103 }
104 const active = typeof input.active === 'string' ? find(input.active) : undefined
105 if (active) {
106 const at = steps.indexOf(active)
107 steps.forEach((st, i) => {
108 if (st.status === 'active' && i !== at) st.status = i < at ? 'done' : 'pending'
109 })
110 active.status = 'active'
111 }
112 const failed = typeof input.failed === 'string' ? find(input.failed) : undefined
113 if (failed) failed.status = 'error'
114
115 return next
116}
117
118function normalize(input: Raw, prev: Plan | null, now: number, id: string): Plan {
119 const isPartial = list(input.stages).length === 0 && prev !== null
120 const stages: PlanStage[] = isPartial ? applyOps(prev.stages, input) : list(input.stages)
121 .map(s => ({
122 name: str(s.name, 80) || 'Stage',
123 steps: list(s.steps).map(st => ({
124 title: str(st.title) || 'Step',
125 status: status(st.status),
126 substeps: list(st.substeps).map(sub => ({ title: str(sub.title) || '…', status: status(sub.status) })),
127 })),
128 }))
129 .filter(s => s.steps.length > 0) as PlanStage[]
130 const title = str(input.title, 80) || prev?.title || 'Plan'
131 const steps = stages.flatMap(s => s.steps)
132 const isAllDone = steps.length > 0 && steps.every(s => isFinished(s.status))
133 const asked = input.state as PlanState
134 const failedNow = typeof input.failed === 'string'
135 const state: PlanState = ['running', 'needs_input', 'error', 'done'].includes(asked) ? asked : isAllDone ? 'done' : failedNow ? 'error' : 'running'
136
137 return {
138 id,
139 title,
140 kind: input.kind === 'todo' || (isPartial && prev?.kind === 'todo') ? 'todo' : 'plan',
141 stages,
142 state,
143 note: str(input.note, 160) || null,
144 startedAt: prev && prev.title === title ? prev.startedAt : now,
145 }
146}
147
148const clean = (s: string) =>
149 s
150 .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
151 .replace(/[*_`]/g, '')
152 .replace(/^\s*(\d+[.)]|[-*+]|\[[ xX]\])\s+/, '')
153 .replace(/^(\d+[.)]|\[[ xX]\])\s+/, '')
154 .trim()
155
156function parsePlan(markdown: string, now: number): Plan | null {
157 let title = ''
158 const headed: PlanStage[] = []
159 const items: { depth: number; text: string }[] = []
160 for (const line of markdown.split(/\r?\n/)) {
161 const h = line.match(/^(#{1,4})\s+(.*)$/)
162 if (h) {
163 const text = clean(h[2] ?? '')
164 if (h[1] === '#' && !title) title = text
165 else headed.push({ name: text, steps: [] })
166 continue
167 }
168 const li = line.match(/^(\s*)(\d+[.)]|[-*+])\s+(.*)$/)
169 if (!li) continue
170 const depth = Math.floor((li[1] ?? '').replace(/\t/g, ' ').length / 2)
171 const text = clean(li[3] ?? '').slice(0, 120)
172 if (!text) continue
173 items.push({ depth, text })
174 const stage = headed[headed.length - 1]
175 if (!stage) continue
176 const step = stage.steps[stage.steps.length - 1]
177 if (depth === 0 || !step) stage.steps.push({ title: text, status: 'pending', substeps: [] })
178 else step.substeps.push({ title: text, status: 'pending' })
179 }
180 let stages = headed.filter(s => s.steps.length > 0)
181 if (stages.length === 0) {
182 if (items.some(i => i.depth > 0)) {
183 for (const item of items) {
184 const stage = stages[stages.length - 1]
185 if (item.depth === 0 || !stage) stages.push({ name: item.text, steps: [] })
186 else stage.steps.push({ title: item.text, status: 'pending', substeps: [] })
187 }
188 stages = stages.map(s => (s.steps.length ? s : { ...s, steps: [{ title: s.name, status: 'pending', substeps: [] }] }))
189 } else if (items.length > 0) {
190 stages = [{ name: 'Tasks', steps: items.map(i => ({ title: i.text, status: 'pending' as StepStatus, substeps: [] })) }]
191 }
192 }
193 if (stages.length === 0) return null
194 const first = stages[0]?.steps[0]
195 if (first) first.status = 'active'
196
197 return { id: 'plan', title: title || 'Plan', kind: stages.length === 1 ? 'todo' : 'plan', stages, state: 'running', note: null, startedAt: now }
198}
199
200function st(title: string, s: StepStatus): PlanStep {
201 return { title, status: s, substeps: [] }
202}
203
204const DEMO = (now: number): Plan => ({
205 id: 'demo',
206 title: 'Orders module',
207 kind: 'plan',
208 state: 'running',
209 note: null,
210 startedAt: now - 260_000,
211 stages: [
212 { name: 'Analysis', steps: [st('Read modules', 'done'), st('Find dependencies', 'done'), st('List changes', 'done')] },
213 { name: 'DB migration', steps: [st('Table schema', 'done'), st('Create migration', 'done'), st('Move data', 'active'), st('Indexes', 'pending')] },
214 { name: 'API', steps: [st('Endpoints', 'pending'), st('Validation', 'pending'), st('Access rules', 'pending')] },
215 { name: 'Interface', steps: [st('List page', 'pending'), st('Order card', 'pending'), st('Filters', 'pending'), st('Empty states', 'pending')] },
216 { name: 'Verify', steps: [st('Tests', 'pending'), st('Build', 'pending')] },
217 ],
218})
219
220// ---------- drawing ----------
221
222type Where = { pos: number; total: number; stage: number; step: number; stageSize: number }
223
224function where(p: Plan): Where {
225 const steps = p.stages.flatMap((s, i) => s.steps.map((step, j) => ({ i, j, step })))
226 const at = steps.findIndex(x => !isFinished(x.step.status))
227 const pos = p.state === 'done' || at < 0 ? steps.length : at
228 const cur = steps[Math.min(pos, steps.length - 1)]
229 const stage = cur?.i ?? 0
230
231 return { pos, total: steps.length, stage, step: pos >= steps.length ? (p.stages[stage]?.steps.length ?? 0) : (cur?.j ?? 0) + 1, stageSize: p.stages[stage]?.steps.length ?? 0 }
232}
233
234const hex = (h: string) => [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16))
235const mix = (a: number[], b: number[], m: number) => a.map((v, i) => Math.round(v + ((b[i] ?? 0) - v) * m))
236const rgb = (c: number[]) => `rgb(${c.join(',')})`
237const esc = (s: string) => s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c)
238const hash = (a: number, b: number, k: number) => {
239 const x = Math.sin(a * 127.1 + b * 311.7 + k * 74.7) * 43758.5453
240 return x - Math.floor(x)
241}
242const textWidth = (s: string, px = 6.7) => [...s].reduce((w, ch) => w + (/[ -鿿]/.test(ch) ? 12 : /[ilI.,:;'|!]/.test(ch) ? 3.4 : /[mwMWШЩЖМ]/.test(ch) ? 9.5 : px), 0)
243
244const ICON_PATH: Partial<Record<PlanState, string>> = {
245 needs_input: 'M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3M12 17h.01',
246 error: 'M18 6 6 18M6 6l12 12',
247 done: 'M20 6 9 17l-5-5',
248}
249
250// last drawn head position per plan, so a redraw glides from where the bar was
251const lastHead = new Map<string, number>()
252
253function trackSvg(p: Plan, W: number): string {
254 const H = TRACK_H
255 const w = where(p)
256 const done = p.state === 'done'
257 // the fill is exactly the finished share: a fresh plan starts empty
258 const frac = done ? 1 : Math.min(1, w.pos / Math.max(1, w.total))
259 const fx = frac * W
260 const key = p.id
261 const from = lastHead.get(key) ?? fx
262 lastHead.set(key, fx)
263
264 const acc = hex(STATE_COLOR[p.state])
265 const light = mix(acc, [255, 255, 255], 0.32)
266 const grey = [132, 130, 138]
267 const ease = 'calcMode="spline" keyTimes="0;1" keySplines=".2 .8 .2 1"'
268 const glide = Math.abs(from - fx) > 0.5
269
270 const bounds: number[] = []
271 let acc2 = 0
272 p.stages.forEach((s, i) => {
273 acc2 += s.steps.length
274 if (i < p.stages.length - 1) bounds.push((acc2 / w.total) * W)
275 })
276
277 // pixels: 3px grid, 7 rows, denser and closer to the state colour towards the head
278 const buckets = [0, 1, 2, 3, 4].map(b => {
279 const m = b / 4
280 const dense = done ? 0.8 : 0.22 + 0.78 * Math.pow(m, 1.5)
281 return { color: rgb(done ? light : mix(grey, light, m)), opacity: (0.35 + 0.65 * dense).toFixed(2) }
282 })
283 let px = ''
284 for (let col = 0; col * 3 < fx; col++) {
285 const x = col * 3
286 const u = Math.min(1, (x + 1.5) / fx)
287 const dense = done ? 0.8 : 0.22 + 0.78 * Math.pow(u, 1.5)
288 const bucket = done ? 4 : Math.min(4, Math.floor(Math.min(1, Math.pow(u, 0.9) * 1.1) * 4.99))
289 for (let r = 0; r < 7; r++) {
290 if (hash(col, r, 1) > dense + 0.1) continue
291 px += `<rect x="${x}" y="${1 + r * 3}" class="b${bucket} t${Math.floor(hash(col, r, 2) * 4)}"/>`
292 }
293 }
294
295 let marks = ''
296 let k = 0
297 p.stages.forEach((s, i) => {
298 s.steps.forEach((_, j) => {
299 if (k > 0) {
300 const x = (k / w.total) * W
301 const isStage = j === 0
302 // stage boundaries are full-height lines, steps are short ticks; bright once passed
303 const passed = x < fx - 1
304 const h = isStage ? H : 8
305 const fill = passed ? rgb(mix(light, [255, 255, 255], 0.45)) : '#8A8984'
306 const opacity = passed ? (isStage ? 0.95 : 0.6) : isStage ? 0.7 : 0.45
307 marks += `<rect x="${(x - (isStage ? 1 : 0.75)).toFixed(1)}" y="${(H - h) / 2}" width="${isStage ? 2 : 1.5}" height="${h}" rx=".75" fill="${fill}" opacity="${opacity}"/>`
308 }
309 k++
310 })
311 void i
312 })
313
314 // knob: a pill with stage and count, or a round dot with the stage number when narrow
315 const isNarrow = W < NARROW
316 const color = STATE_COLOR[p.state]
317 const icon = ICON_PATH[p.state]
318 const single = p.stages.length === 1
319 const number = single ? Math.min(w.total, w.pos + 1) : w.stage + 1
320 let knob = ''
321 let kw = H
322 if (isNarrow) {
323 const label = done ? '' : String(number)
324 knob = `<circle cx="0" cy="${H / 2}" r="${H / 2}" fill="${color}"/>${
325 done ? `<path d="${ICON_PATH.done}" transform="translate(-6 5) scale(.5)" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>` : `<text x="0" y="${H / 2 + 4.2}" text-anchor="middle" class="kt">${label}</text>`
326 }`
327 } else {
328 const name = done ? 'Done' : single ? (p.stages[0]?.name ?? 'Tasks') : (p.stages[w.stage]?.name ?? '')
329 const agents = p.agents ?? []
330 const base = p.id === AGENTS ? `${w.pos}/${w.total}` : done ? `${w.total}/${w.total}` : single ? `${number}/${w.total}` : `${w.step}/${w.stageSize}`
331 const agentCount = agents.length > 0 && p.id !== AGENTS ? ` · ${agents.filter(a => a.state === 'done').length}/${agents.length} agents` : ''
332 const count = base + agentCount
333 const iconW = icon ? 16 : 0
334 const countW = textWidth(count, 6.5)
335 const maxW = Math.max(80, W * 0.55)
336 let shown = name
337 while (shown.length > 3 && 20 + iconW + textWidth(shown) + 6 + countW > maxW) shown = shown.slice(0, -1)
338 if (shown !== name) shown = shown.trimEnd() + '…'
339 kw = Math.round(20 + iconW + textWidth(shown) + 6 + countW)
340 const left = -kw / 2 + 10
341 knob = `<rect x="${-kw / 2}" y="0" width="${kw}" height="${H}" rx="${H / 2}" fill="${color}"/>`
342 if (icon) knob += `<path d="${icon}" transform="translate(${left} 5) scale(.5)" fill="none" stroke="#fff" stroke-width="3.6" stroke-linecap="round" stroke-linejoin="round"/>`
343 knob += `<text x="${left + iconW}" y="${H / 2 + 4.2}" class="kt">${esc(shown)}<tspan class="kc" dx="6">${count}</tspan></text>`
344 }
345 const clampX = (x: number) => Math.max(kw / 2, Math.min(W - kw / 2, x))
346 const kx = clampX(fx)
347 const kFrom = clampX(from)
348
349 const style = `<style>
350.b0{fill:${buckets[0]?.color};fill-opacity:${buckets[0]?.opacity}}.b1{fill:${buckets[1]?.color};fill-opacity:${buckets[1]?.opacity}}
351.b2{fill:${buckets[2]?.color};fill-opacity:${buckets[2]?.opacity}}.b3{fill:${buckets[3]?.color};fill-opacity:${buckets[3]?.opacity}}
352.b4{fill:${buckets[4]?.color};fill-opacity:${buckets[4]?.opacity}}
353rect[class]{width:2px;height:2px}
354.t0,.t1,.t2,.t3{animation:tw ${done ? 3.2 : 2.2}s ease-in-out infinite}
355.t1{animation-duration:${done ? 3.8 : 2.8}s;animation-delay:-.7s}.t2{animation-duration:${done ? 4.4 : 1.9}s;animation-delay:-1.3s}.t3{animation-duration:${done ? 3.5 : 3.3}s;animation-delay:-.4s}
356@keyframes tw{0%,100%{opacity:1}50%{opacity:${done ? 0.8 : 0.45}}}
357.kt{font:500 12px 'Anthropic Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif;fill:#fff}
358.kc{font-weight:400;fill-opacity:.75}
359@media (prefers-reduced-motion:reduce){.t0,.t1,.t2,.t3{animation:none}}
360</style>`
361 const glideFill = glide ? `<animate attributeName="width" from="${from.toFixed(1)}" to="${fx.toFixed(1)}" dur=".45s" ${ease} fill="freeze"/>` : ''
362 const glideKnob = glide ? `<animateTransform attributeName="transform" type="translate" from="${kFrom.toFixed(1)} 0" to="${kx.toFixed(1)} 0" dur=".45s" ${ease} fill="freeze"/>` : ''
363
364 return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${style}
365<defs><clipPath id="pill"><rect width="${W}" height="${H}" rx="${H / 2}"/></clipPath><clipPath id="fill"><rect width="${fx.toFixed(1)}" height="${H}">${glideFill}</rect></clipPath>
366<linearGradient id="base" x1="0" x2="${fx.toFixed(1)}" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="${rgb(acc)}" stop-opacity="${done ? 0.3 : 0.05}"/><stop offset="1" stop-color="${rgb(acc)}" stop-opacity=".33"/></linearGradient></defs>
367<g clip-path="url(#pill)"><rect width="${W}" height="${H}" fill="#808080" fill-opacity=".16"/>
368<g clip-path="url(#fill)"><rect width="${fx.toFixed(1)}" height="${H}" fill="url(#base)"/>${px}</g>${marks}</g>
369<g transform="translate(${kx.toFixed(1)} 0)">${glideKnob}${knob}</g></svg>`
370}
371
372const AGENT_COLOR: Record<AgentRun['state'], string> = {
373 running: STATE_COLOR.running,
374 waiting: STATE_COLOR.needs_input,
375 done: STATE_COLOR.done,
376 error: STATE_COLOR.error,
377}
378
379const elapsed = (ms: number) => {
380 const sec = Math.max(0, Math.round(ms / 1000))
381 return sec < 60 ? `${sec}s` : `${Math.floor(sec / 60)}m ${sec % 60}s`
382}
383
384// which strips show: all of a small batch; in a big one the unfinished first, the rest folded into one line
385function visibleAgents(p: Plan, now: number): { shown: AgentRun[]; hidden: AgentRun[] } | null {
386 const list = p.agents ?? []
387 if (list.length === 0) return null
388 const hasError = list.some(a => a.state === 'error')
389 if (p.agentsDoneAt && now - p.agentsDoneAt > FOLD_MS && !hasError) return null
390 if (list.length <= MAX_STRIPS) return { shown: list, hidden: [] }
391 const keep = new Set(list.filter(a => a.state !== 'done').slice(0, MAX_STRIPS - 1).map(a => a.id))
392 for (const a of [...list].reverse()) {
393 if (keep.size >= MAX_STRIPS - 1) break
394 keep.add(a.id)
395 }
396 return { shown: list.filter(a => keep.has(a.id)), hidden: list.filter(a => !keep.has(a.id)) }
397}
398
399// the person's choice applied to the strips: summary folds them into one made-up run that every style draws as a strip
400function applyAgentView(v: { shown: AgentRun[]; hidden: AgentRun[] } | null, view: AgentView): { shown: AgentRun[]; hidden: AgentRun[] } | null {
401 if (!v || view === 'expanded') return v
402 if (view === 'hidden') return null
403 const all = [...v.shown, ...v.hidden]
404 const count = (s: AgentRun['state']) => all.filter(a => a.state === s).length
405 const tool = (
406 [
407 ['running', 'running'],
408 ['waiting', 'waiting'],
409 ['done', 'done'],
410 ['error', 'failed'],
411 ] as const
412 )
413 .map(([s, label]) => (count(s) ? `${count(s)} ${label}` : ''))
414 .filter(Boolean)
415 .join(' · ')
416 const state: AgentRun['state'] = count('waiting') ? 'waiting' : count('running') ? 'running' : count('error') ? 'error' : 'done'
417 const ends = all.map(a => a.endedAt)
418 const endedAt = ends.every((t): t is number => t !== null) ? Math.max(...ends) : null
419 const startedAt = Math.min(...all.map(a => a.startedAt))
420 return { shown: [{ id: 'agents-summary', title: plural(all.length, 'subagent'), state, tool, startedAt, endedAt, depth: 0 }], hidden: [] }
421}
422
423// what each strip showed last time it was drawn, so a change morphs from the old status instead of jumping
424const lastStrip = new Map<string, { tool: string; color: string }>()
425const MORPH = '.2s'
426
427const stripsHeight = (n: number) => n * STRIP_H + (n - 1) * STRIP_GAP
428
429// one tinted strip per agent: state colour, name, what it does now and for how long; not a progress bar
430function stripsSvg(v: { shown: AgentRun[]; hidden: AgentRun[] }, W: number, now: number): string {
431 const isNarrow = W < NARROW
432 const rows: string[] = []
433 v.shown.forEach((a, i) => {
434 const c = AGENT_COLOR[a.state]
435 const y = i * (STRIP_H + STRIP_GAP)
436 const indent = a.depth > 0 ? 12 : 0
437 let px = ''
438 if (a.state === 'running') {
439 for (let col = 0; col * 3 < W; col++) {
440 for (let r = 0; r < 4; r++) {
441 if (hash(col + i * 41, r, 5) > 0.2) continue
442 px += `<rect x="${col * 3}" y="${y + 3 + r * 3.6}" class="t${Math.floor(hash(col, r, 6) * 4)}" fill="${c}" fill-opacity=".32"/>`
443 }
444 }
445 }
446 const nameRoom = isNarrow ? W - 30 - indent : W * 0.5
447 let name = (a.depth > 0 ? '↳ ' : '') + a.title
448 while (name.length > 4 && textWidth(name, 6.2) > nameRoom) name = name.slice(0, -1)
449 if (name !== (a.depth > 0 ? '↳ ' : '') + a.title) name = name.trimEnd() + '…'
450 const nameX = 19 + indent
451 const toolX = nameX + textWidth(name, 6.2) + 8
452 const time = elapsed((a.endedAt ?? now) - a.startedAt)
453 // a status change: the old word blurs out while the new one blurs in, and the tint flows to the new colour
454 const was = lastStrip.get(a.id)
455 lastStrip.set(a.id, { tool: a.tool, color: c })
456 const isToolChanged = was !== undefined && was.tool !== a.tool
457 const flow = (attr: string) => (was && was.color !== c ? `<animate attributeName="${attr}" from="${was.color}" to="${c}" dur="${MORPH}" fill="freeze"/>` : '')
458 const tool = isNarrow
459 ? ''
460 : (isToolChanged ? `<text x="${toolX}" y="${y + 12.5}" class="sn mo" style="fill:${was.color}">${esc(was.tool)}</text>` : '') +
461 `<text x="${toolX}" y="${y + 12.5}" class="sn${isToolChanged ? ' mi' : ''}" style="fill:${c}">${esc(a.tool)}</text>` +
462 `<text x="${W - 9}" y="${y + 12.5}" text-anchor="end" class="sn st">${time}</text>`
463 rows.push(
464 `<rect x="0" y="${y}" width="${W}" height="${STRIP_H}" rx="${STRIP_H / 2}" fill="${c}" fill-opacity=".15">${flow('fill')}</rect>${px}` +
465 `<circle cx="${10 + indent}" cy="${y + STRIP_H / 2}" r="3" fill="${c}"${a.state === 'running' ? ' class="sd"' : ''}>${flow('fill')}</circle>` +
466 `<text x="${nameX}" y="${y + 12.5}" class="sn">${esc(name)}</text>` +
467 tool,
468 )
469 })
470 if (v.hidden.length > 0) {
471 const y = v.shown.length * (STRIP_H + STRIP_GAP)
472 const doneCount = v.hidden.filter(a => a.state === 'done').length
473 rows.push(
474 `<rect x="0" y="${y}" width="${W}" height="${STRIP_H}" rx="${STRIP_H / 2}" fill="#808080" fill-opacity=".14"/>` +
475 `<text x="10" y="${y + 12.5}" class="sn st">+${plural(v.hidden.length, 'more agent')} · ${doneCount} done</text>`,
476 )
477 }
478 return `<style>.sn{font:400 11.5px 'Anthropic Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif;fill:#1F1E1C}.st{fill-opacity:.65}
479@media (prefers-color-scheme:dark){.sn{fill:#F0EEFC}}
480.sd{animation:sp 1.1s ease-in-out infinite}@keyframes sp{50%{opacity:.3}}
481.mi{animation:mi ${MORPH} ease-out both}@keyframes mi{from{opacity:0;filter:blur(3px)}}
482.mo{animation:mo ${MORPH} ease-in both}@keyframes mo{to{opacity:0;filter:blur(3px)}}
483@media (prefers-reduced-motion:reduce){.sd,.mi,.mo{animation:none}.mo{opacity:0}}</style>${rows.join('')}`
484}
485
486function plural(n: number, word: string) {
487 return `${n} ${word}${n === 1 ? '' : 's'}`
488}
489
490// a drawing placed in a fixed-height slot (centred or at the top), so previews of different heights take the same room
491function inSlot(drawn: { svg: string; height: number }, W: number, slotH: number, align: 'center' | 'top'): { svg: string; height: number } {
492 const dy = align === 'center' ? Math.max(0, (slotH - drawn.height) / 2) : 0
493 return {
494 svg: `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${slotH}" viewBox="0 0 ${W} ${slotH}"><g transform="translate(0 ${dy})">${drawn.svg}</g></svg>`,
495 height: slotH,
496 }
497}
498
499// one bar with its strips in the chosen style; the bar above the prompt and the pane's previews both draw through here
500function drawBar(style: StyleId, p: Plan, W: number, now: number, v: { shown: AgentRun[]; hidden: AgentRun[] } | null): { svg: string; height: number } {
501 if (style !== 'original') return STYLES[style].draw(p, W, now, v)
502 if (!v) return { svg: trackSvg(p, W), height: TRACK_H }
503 const stripsH = 5 + stripsHeight(v.shown.length + (v.hidden.length > 0 ? 1 : 0))
504 return {
505 svg: `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${TRACK_H + stripsH}">${trackSvg(p, W)}<g transform="translate(0 ${TRACK_H + 5})">${stripsSvg(v, W, now)}</g></svg>`,
506 height: TRACK_H + stripsH,
507 }
508}
509
510// ---------- engine glue ----------
511
512// a sound the mod raises on its own: silent while the person has turned sounds off
513function play($: EngineInterface, name: 'decision' | 'error' | 'done') {
514 void read($, soundsOn).then(isOn => (isOn ? playNow($, name) : undefined))
515}
516// the engine's player first (afplay on macOS); PowerShell where it cannot play
517function playNow($: EngineInterface, name: 'decision' | 'error' | 'done') {
518 const file = `${$.plugin.root}/sounds/${name}.wav`.replace(/\//g, '\\')
519 void $.audio.play({ asset: `sounds/${name}.wav` }).catch(() =>
520 $.process
521 .run(['powershell', '-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', `(New-Object Media.SoundPlayer '${file}').PlaySync()`], { timeoutMs: 5000 })
522 .catch(() => undefined),
523 )
524}
525
526// the agents bar is the mod's own; the model never owes it an update
527const AGENTS = 'agents:auto' // slug() never yields ':', so no model id can take it
528const isOpenPlan = (p: Plan) => p.id !== AGENTS && p.state === 'running' && !p.isFinishing && !p.stages.flatMap(s => s.steps).every(s => isFinished(s.status))
529
530const slug = (s: string) =>
531 s
532 .toLowerCase()
533 .replace(/[^\p{L}\p{N}]+/gu, '-')
534 .replace(/^-|-$/g, '')
535 .slice(0, 40) || 'plan'
536
537// adds or replaces one bar by id; keeps at most MAX_BARS, dropping finished ones first
538// computed inside update() from the latest list, so concurrent writers (parallel agents) do not drop each other
539function placeBar(list: readonly Plan[], next: Plan): Plan[] {
540 const prev = list.find(p => p.id === next.id)
541 // an update keeps its row; a new bar goes to the bottom
542 const rest = prev ? list.map(p => (p.id === next.id ? next : p)) : [...list, next]
543 while (rest.length > MAX_BARS) {
544 const doneAt = rest.findIndex(p => p.state === 'done')
545 rest.splice(doneAt >= 0 ? doneAt : 0, 1)
546 }
547 return rest
548}
549
550// a main-loop turn is running: a done reached inside it sounds once the turn ends, after the reply is written
551let isMainTurn = false
552let owesDone = false
553
554function chime($: EngineInterface, prev: PlanState | undefined, next: PlanState) {
555 if (next === prev) return
556 if (next === 'needs_input') play($, 'decision')
557 if (next === 'error') play($, 'error')
558 if (next === 'done') {
559 if (isMainTurn) owesDone = true
560 else play($, 'done')
561 }
562}
563
564async function putPlan($: EngineInterface, next: Plan) {
565 let prev: Plan | undefined
566 await update($, plans, list => {
567 prev = list.find(p => p.id === next.id)
568 return placeBar(list, next)
569 })
570 chime($, prev?.state, next.state)
571}
572
573// ---------- agents: drawn from engine events alone, no model calls ----------
574// each subagent lives on a bar as one state strip: the open task bar it was started under,
575// the bar of its parent agent, or the mod's own "Agents" bar when no task is open.
576// Module maps: a reload forgets running agents, whose strips then stay until the bar is closed.
577const agentHome = new Map<string, string>() // agentId -> bar id
578const toolUses = new Map<string, string>() // tool_use_id -> agentId, to find who waits on a permission
579const waiting = new Set<string>()
580let foldUntil = 0 // keep ticking until finished strips have folded
581
582// the mod's own bar mirrors its agents as steps, finished first, so percent and count read done/total
583function syncAuto(p: Plan, now: number): Plan {
584 const agents = p.agents ?? []
585 const isOver = agents.length > 0 && agents.every(a => a.state === 'done' || a.state === 'error')
586 const agentsDoneAt = isOver ? (p.agentsDoneAt ?? now) : null
587 if (p.id !== AGENTS) return { ...p, agentsDoneAt }
588 const rank = (a: AgentRun) => (a.state === 'done' ? 0 : a.state === 'error' ? 1 : 2)
589 const steps: PlanStep[] = [...agents]
590 .sort((a, b) => rank(a) - rank(b))
591 .map(a => ({ title: a.title, status: a.state === 'done' ? 'done' : a.state === 'error' ? 'error' : 'active', substeps: [] }))
592 const state: PlanState = isOver
593 ? agents.some(a => a.state === 'error') ? 'error' : 'done'
594 : agents.some(a => a.state === 'waiting') ? 'needs_input' : 'running'
595 return { ...p, agentsDoneAt, stages: [{ name: 'Agents', steps }], state }
596}
597
598function addRun(p: Plan, run: AgentRun, parentId: string | undefined, now: number): Plan {
599 // a batch that has finished makes room for the next one
600 const list = p.agentsDoneAt ? [] : [...(p.agents ?? [])]
601 let at = list.length
602 const parentAt = parentId ? list.findIndex(a => a.id === parentId) : -1
603 if (parentAt >= 0) {
604 at = parentAt + 1
605 while (at < list.length && (list[at]?.depth ?? 0) > 0) at++
606 }
607 list.splice(at, 0, run)
608 return syncAuto({ ...p, agents: list, agentsDoneAt: null }, now)
609}
610
611// changes one agent's strip inside the latest list; sounds follow the bar's state
612async function editAgent($: EngineInterface, agentId: string, change: (a: AgentRun) => AgentRun) {
613 const home = agentHome.get(agentId)
614 if (!home) return
615 const now = await $.clock.now()
616 let before: PlanState | undefined
617 let after: PlanState | undefined
618 let isFolding = false
619 await update($, plans, list =>
620 list.map(p => {
621 if (p.id !== home || !p.agents?.some(a => a.id === agentId)) return p
622 before = p.state
623 const next = syncAuto({ ...p, agents: p.agents.map(a => (a.id === agentId ? change(a) : a)) }, now)
624 after = next.state
625 isFolding = !p.agentsDoneAt && next.agentsDoneAt !== null
626 return next
627 }),
628 )
629 if (isFolding) foldUntil = now + FOLD_MS + 1500
630 if (before !== undefined && after !== undefined) chime($, before, after)
631}
632
633async function dropPlan($: EngineInterface, id: string) {
634 lastHead.delete(id)
635 for (const p of await read($, plans)) if (p.id === id) for (const a of p.agents ?? []) lastStrip.delete(a.id)
636 await update($, plans, list => list.filter(p => p.id !== id))
637}
638
639// finished bars stay until the person sends their next message (they have seen the result by then):
640// they fade out for LEAVE_MS, then leave the list
641const LEAVE_MS = 400
642async function fadeOutDone($: EngineInterface) {
643 const now = await $.clock.now()
644 let isLeaving = false
645 await update($, plans, list =>
646 list.map(p => {
647 if (p.state !== 'done' || p.leavingAt) return p
648 isLeaving = true
649 return { ...p, leavingAt: now }
650 }),
651 )
652 if (isLeaving) $.clock.after(LEAVE_MS, () => dropLeft($))
653}
654async function dropLeft($: EngineInterface) {
655 const now = await $.clock.now()
656 for (const p of await read($, plans)) {
657 if (!p.leavingAt || now - p.leavingAt < LEAVE_MS) continue
658 lastHead.delete(p.id)
659 for (const a of p.agents ?? []) lastStrip.delete(a.id)
660 }
661 await update($, plans, list => list.filter(p => !p.leavingAt || now - p.leavingAt < LEAVE_MS))
662}
663// a leaving bar's drawing fades to nothing; its title and count dim, since Text has no opacity
664const fadeOut = (svg: string, W: number, H: number) =>
665 `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}"><style>@keyframes pp-leave{to{opacity:0}}.pp-leave{animation:pp-leave ${LEAVE_MS}ms ease-out forwards}</style><g class="pp-leave">${svg}</g></svg>`
666
667// ---------- other sessions: each desktop session writes its status to a shared folder, every session reads the others ----------
668type SessionStatus = 'working' | 'done' | 'needs_input' | 'idle' | 'ended'
669type SessionFile = { v: 1; hostId: string; cwd: string; label: string; state: SessionStatus; since: number; updatedAt: number; bars?: unknown[] }
670const others = atom({ plugin: 'plan-progress', key: 'others' } as const, { rows: [] } as OthersView)
671const crossOn = atom({ plugin: 'plan-progress', key: 'crossSessions' } as const, true)
672const CROSS_STORE_KEY = 'crossSessions'
673const setCross = async ($: EngineInterface, isOn: boolean) => {
674 await update($, crossOn, () => isOn)
675 await $.store.set(CROSS_STORE_KEY, isOn)
676}
677// the desktop's own session id; the link is built from it, never read from a file, since `open` acts on whatever it is given
678const HOST_ID = /^local_[0-9a-f-]{36}$/
679const linkOf = (hostId: string) => `claude://claude.ai/epitaxy/${hostId}`
680const SEEN = 'seen.json'
681const HEARTBEAT_MS = 30_000 // a working session rewrites its file this often
682const WORKING_FRESH_MS = 90_000 // a working file older than this belongs to a session that is gone
683const KEEP_MS = 24 * 3600_000 // a finished session older than this no longer shows
684const MAX_OTHERS = 5
685const POLL_EVERY = 2 // seconds between reads of the shared folder
686let sessionsDir: string | null = null
687let mine: { hostId: string; cwd: string; state: SessionStatus; since: number; lastPrompt: string } | null = null
688let lastBeat = 0
689let lastBars = ''
690let lastOthers = ''
691let pollCount = 0
692
693// writes this session's status; a new state restarts `since`, so a second finish shows again after the first was seen
694async function publish($: EngineInterface, state?: SessionStatus) {
695 if (!mine || !sessionsDir) return
696 const now = await $.clock.now()
697 if (state) {
698 mine.state = state
699 mine.since = now
700 }
701 lastBeat = now
702 const list = await read($, plans)
703 const bar = [...list].reverse().find(p => p.id !== AGENTS)
704 const bars = barsOf(list)
705 lastBars = JSON.stringify(bars)
706 const file: SessionFile = { v: 1, hostId: mine.hostId, cwd: mine.cwd, label: bar?.title || mine.lastPrompt, state: mine.state, since: mine.since, updatedAt: now, bars }
707 await $.fs.write(`${sessionsDir}/${mine.hostId}.json`, JSON.stringify(file)).catch(() => undefined)
708}
709
710// the bars the other sessions draw: shape and state only, no subagent strips, none that is leaving
711const barsOf = (list: readonly Plan[]) =>
712 list.filter(p => !p.leavingAt).map(p => ({ id: p.id, title: p.title, kind: p.kind, stages: p.stages, state: p.state, note: p.note, startedAt: p.startedAt }))
713
714// a bar read from another session's file, rebuilt through the same checks a model's call goes through
715function awayBar(raw: unknown, hostId: string, now: number): Plan | undefined {
716 if (!raw || typeof raw !== 'object') return undefined
717 const r = raw as Raw
718 const p = normalize(r, null, now, `away:${hostId}`)
719 if (p.stages.length === 0) return undefined
720 return { ...p, startedAt: typeof r.startedAt === 'number' && Number.isFinite(r.startedAt) ? r.startedAt : now }
721}
722
723async function readSeen($: EngineInterface): Promise<Record<string, number>> {
724 if (!sessionsDir) return {}
725 try {
726 const v = JSON.parse((await $.fs.read(`${sessionsDir}/${SEEN}`)) as string)
727 return v && typeof v === 'object' ? v : {}
728 } catch {
729 return {}
730 }
731}
732
733async function pollOthers($: EngineInterface) {
734 if (!sessionsDir) return
735 const now = await $.clock.now()
736 const entries = await $.fs.list(sessionsDir).catch(() => [])
737 const seen = await readSeen($)
738 const rows: OtherSession[] = []
739 for (const entry of entries) {
740 if (entry.kind !== 'file' || !entry.name.endsWith('.json') || entry.name === SEEN || now - entry.mtimeMs > KEEP_MS) continue
741 let f: SessionFile
742 try {
743 f = JSON.parse((await $.fs.read(`${sessionsDir}/${entry.name}`)) as string)
744 } catch {
745 continue
746 }
747 if (f?.v !== 1 || !HOST_ID.test(f.hostId) || f.hostId === mine?.hostId) continue
748 const isShown =
749 f.state === 'working' ? now - f.updatedAt < WORKING_FRESH_MS : (f.state === 'done' || f.state === 'needs_input') && now - f.since < KEEP_MS
750 // ✕ or a switch hides a session until it changes state again
751 if (!isShown || f.since <= (seen[f.hostId] ?? 0)) continue
752 const bars = (Array.isArray(f.bars) ? f.bars : [])
753 .map(raw => ({ isAgents: (raw as Raw | null)?.id === AGENTS, p: awayBar(raw, f.hostId, now) }))
754 .filter((b): b is { isAgents: boolean; p: Plan } => b.p !== undefined)
755 // its newest task bar, or its subagents' bar when it has no task bar
756 const bar = ([...bars].reverse().find(b => !b.isAgents) ?? bars[bars.length - 1])?.p
757 rows.push({
758 hostId: f.hostId,
759 folder: str(String(f.cwd).split('/').pop(), 40),
760 label: str(f.label, 60),
761 state: f.state as OtherSession['state'],
762 since: f.since,
763 ...(bar ? { bar } : {}),
764 ...(leavingAway.has(f.hostId) ? { isLeaving: true } : {}),
765 })
766 }
767 // waiting on the person first, then finished, then running; the newest first within each
768 const rank = { needs_input: 0, done: 1, working: 2 } as const
769 rows.sort((a, b) => rank[a.state] - rank[b.state] || b.since - a.since)
770 const view = { rows }
771 const key = JSON.stringify(view)
772 if (key === lastOthers) return
773 lastOthers = key
774 await update($, others, () => view)
775}
776
777// the person has seen this finish: every session stops showing it until that session finishes again
778async function markSeen($: EngineInterface, row: OtherSession) {
779 if (sessionsDir) {
780 const now = await $.clock.now()
781 const seen = await readSeen($)
782 seen[row.hostId] = row.since
783 for (const [id, at] of Object.entries(seen)) if (now - at > KEEP_MS) delete seen[id]
784 await $.fs.write(`${sessionsDir}/${SEEN}`, JSON.stringify(seen)).catch(() => undefined)
785 }
786 lastOthers = ''
787 await update($, others, v => ({ ...v, rows: v.rows.filter(r => r.hostId !== row.hostId) }))
788}
789
790// ✕: the row dims for LEAVE_MS, then leaves; the seen mark is written once it has gone, so a read in between keeps it dimmed
791const leavingAway = new Set<string>()
792async function dismissAway($: EngineInterface, row: OtherSession) {
793 if (leavingAway.has(row.hostId)) return
794 leavingAway.add(row.hostId)
795 await update($, others, v => ({ ...v, rows: v.rows.map(r => (r.hostId === row.hostId ? { ...r, isLeaving: true } : r)) }))
796 $.clock.after(LEAVE_MS, async () => {
797 await markSeen($, row)
798 leavingAway.delete(row.hostId)
799 })
800}
801
802async function switchTo($: EngineInterface, row: OtherSession) {
803 if (!HOST_ID.test(row.hostId)) return
804 await markSeen($, row)
805 await $.process.run(['open', linkOf(row.hostId)], { timeoutMs: 5000 }).catch(() => undefined)
806}
807
808const STEP_SCHEMA = {
809 type: 'object',
810 required: ['title', 'status'],
811 properties: {
812 title: { type: 'string' },
813 status: { enum: STATUSES },
814 substeps: {
815 type: 'array',
816 items: { type: 'object', required: ['title', 'status'], properties: { title: { type: 'string' }, status: { enum: STATUSES } } },
817 },
818 },
819}
820
821// only calls that change something count as work for the enforcement below; reading and searching are free
822const WORK_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash', 'PowerShell'])
823const WORK_BEFORE_PLAN = 3 // the 4th changing call without a plan is refused once
824const CALLS_BEFORE_NUDGE = 6 // working calls without a plan update before a reminder
825
826
827export const register: Register = on => {
828 // per-turn bookkeeping; module variables are fine here, a reload just starts a fresh count
829 let workCalls = 0
830 let sinceUpdate = 0
831 let isPlanTouched = false
832 let hasRefused = false
833 let isWaitingOnBackground = false
834
835 on('turn.start', async ($, e, next) => {
836 workCalls = 0
837 sinceUpdate = 0
838 isPlanTouched = false
839 hasRefused = false
840 isWaitingOnBackground = false
841 isMainTurn = true
842 owesDone = false
843 if (mine) mine.lastPrompt = str(e.text, 30)
844 await publish($, 'working')
845
846 return next(e)
847 })
848
849 // the rule lives in the cached system prompt; a message only carries one short line when bars are open,
850 // and the person answering clears any "needs input" without a model call
851 on('prompt.submit', async ($, e, next) => {
852 // the person's own message, typed here or sent from their phone, retires the finished bars;
853 // a prompt with no origin is the person's own too
854 const origin = e.origin as { kind: string; asUser?: boolean } | undefined
855 if (!origin || origin.kind === 'composer' || origin.kind === 'bridge' || (origin.kind === 'plugin' && origin.asUser === true)) await fadeOutDone($)
856 if (origin?.kind !== 'composer') return next(e)
857 const list = await read($, plans)
858 if (list.some(p => p.state === 'needs_input')) {
859 await update($, plans, all => all.map(p => (p.state === 'needs_input' ? { ...p, state: 'running' as const, note: null } : p)))
860 }
861 const open = list.filter(p => p.state !== 'done' && p.id !== AGENTS)
862 if (open.length === 0) return next(e)
863 const line = `plan-progress open bars: ${open
864 .map(p => {
865 const w = where(p)
866 return `${p.id} (${p.stages[w.stage]?.name ?? ''} ${w.step}/${w.stageSize})`
867 })
868 .join(', ')}`
869
870 return next({ ...e, context: [...(e.context ?? []), line] })
871 })
872
873 // watches the main loop's changing calls: refuses once when multi-step work starts without a bar,
874 // and reminds to update the bar when it goes stale mid-turn
875 on('tool.call', async ($, e, next) => {
876 // a subagent's call only names its current tool on its strip; no gate, no reminders
877 if (e.agentId) {
878 const agentId = e.agentId
879 if (!agentHome.has(agentId)) return next(e)
880 await editAgent($, agentId, a => ({ ...a, state: 'running', tool: e.tool }))
881 if (e.tool_use_id) toolUses.set(e.tool_use_id, agentId)
882 const ran = await next(e)
883 if (e.tool_use_id) toolUses.delete(e.tool_use_id)
884 if (waiting.delete(agentId)) await editAgent($, agentId, a => (a.state === 'waiting' ? { ...a, state: 'running' } : a))
885 return ran
886 }
887 if (!WORK_TOOLS.has(e.tool)) return next(e)
888 isWaitingOnBackground = (e as unknown as Raw).run_in_background === true
889 const hasLivePlan = isPlanTouched || (await read($, plans)).some(isOpenPlan)
890 if (!hasLivePlan && !hasRefused && workCalls >= WORK_BEFORE_PLAN) {
891 hasRefused = true
892
893 return { deny: `plan-progress: several changes ahead. Create a bar with ${TOOL} first, then retry.` }
894 }
895 const ran = await next(e)
896 // a shell call that only read (ls, git status, grep) is not work
897 if (ran.deny !== undefined || ran.isReadOnly) return ran
898 workCalls += 1
899 sinceUpdate += 1
900 if (hasLivePlan && sinceUpdate >= CALLS_BEFORE_NUDGE) {
901 sinceUpdate = 0
902
903 return { ...ran, context: [...(ran.context ?? []), `plan-progress: bar is stale, send {id, next:true} or {id, done, active}.`] }
904 }
905
906 return ran
907 })
908
909 // an open bar at the end of a turn: a question to the user marks it waiting on its own;
910 // only a turn that did work and left the bar unexplained is sent back once
911 on('classic.Stop', async ($, e, next) => {
912 const result = await next(e)
913 if (e.stop_hook_active || result.block || isWaitingOnBackground || (e.background_tasks?.length ?? 0) > 0) return result
914 const open = (await read($, plans)).filter(isOpenPlan)
915 if (open.length === 0) return result
916 const asks = /[??]\s*$/.test(e.last_assistant_message ?? '')
917 if (asks) {
918 const last = open[open.length - 1]
919 if (last) await putPlan($, { ...last, state: 'needs_input' })
920
921 return result
922 }
923 if (workCalls === 0 && !isPlanTouched) return result
924
925 return {
926 ...result,
927 block: `plan-progress: ${open.map(p => p.id).join(', ')} still open. Update each with ${TOOL}: {id, next:true}, or state "done", "needs_input" or "error" with a note.`,
928 }
929 })
930
931 on('session.start', async ($, e, next) => {
932 await $.tool.register({
933 name: 'plan_progress',
934 description: 'Live progress bar above the prompt, one per id. Create with title + stages; update with short ops (next, done, active, failed) or state.',
935 inputSchema: {
936 type: 'object',
937 required: ['id'],
938 properties: {
939 id: { type: 'string', description: 'Bar id; reuse it for updates' },
940 title: { type: 'string' },
941 kind: { enum: ['plan', 'todo'] },
942 stages: {
943 type: 'array',
944 description: 'Full breakdown, only when creating or restructuring',
945 items: { type: 'object', required: ['name', 'steps'], properties: { name: { type: 'string' }, steps: { type: 'array', items: STEP_SCHEMA } } },
946 },
947 next: { type: 'boolean', description: 'Active step finished, start the next one' },
948 done: { type: 'array', items: { type: 'string' }, description: 'Step titles now finished' },
949 active: { type: 'string', description: 'Step title now in progress' },
950 failed: { type: 'string', description: 'Step title that failed' },
951 state: { enum: ['running', 'needs_input', 'error', 'done'] },
952 note: { type: 'string', description: 'One line for needs_input or error' },
953 },
954 },
955 })
956 const saved = await $.store.get(STYLE_STORE_KEY)
957 if (isStyleId(saved)) await update($, barStyle, () => saved)
958 const savedView = await $.store.get(AGENT_VIEW_STORE_KEY)
959 if (isAgentView(savedView)) await update($, agentView, () => savedView)
960 const savedShown = await $.store.get(SHOWN_STORE_KEY)
961 if (typeof savedShown === 'boolean') await update($, isOpen, () => savedShown)
962 const savedSounds = await $.store.get(SOUNDS_STORE_KEY)
963 if (typeof savedSounds === 'boolean') await update($, soundsOn, () => savedSounds)
964 const savedCross = await $.store.get(CROSS_STORE_KEY)
965 if (typeof savedCross === 'boolean') await update($, crossOn, () => savedCross)
966 // a desktop session names itself in its environment; a terminal session has no id to switch to and only reads
967 const home = await $.env.get('HOME')
968 const hostId = await $.env.get('CLAUDE_CODE_HOST_SESSION_ID')
969 if (home) sessionsDir = `${home}/.claude/plan-progress/sessions`
970 if (hostId && HOST_ID.test(hostId)) mine = { hostId, cwd: await $.session.cwd(), state: 'idle', since: await $.clock.now(), lastPrompt: '' }
971 $.clock.every(1000, async () => {
972 // a bar that moved reaches the other sessions within a second; a working session also beats every 30 s
973 const isBarMoved = mine !== null && JSON.stringify(barsOf(await read($, plans))) !== lastBars
974 if (isBarMoved || (mine?.state === 'working' && (await $.clock.now()) - lastBeat >= HEARTBEAT_MS)) await publish($)
975 pollCount += 1
976 if (pollCount % POLL_EVERY === 0 && (await read($, crossOn))) await pollOthers($)
977 // the hairline style shows elapsed time, so it redraws every second while a bar runs
978 const isTiming = (await styleOf($)) === 'hairline' && (await read($, plans)).some(p => p.state !== 'done')
979 if (isTiming || agentHome.size > 0 || (await $.clock.now()) < foldUntil) await update($, tick, n => n + 1)
980 })
981 await $.command.register({ name: 'progress', description: 'Show or hide the progress bars' })
982 await $.command.register({ name: 'progress-style', description: 'Switch the progress bar style (no argument lists them, "next" cycles)' })
983 await $.command.register({ name: 'progress-demo', description: 'Show a sample plan in the progress bars' })
984 await $.command.register({ name: 'progress-sounds', description: 'Play the decision, error and done sounds' })
985 await $.command.register({ name: 'progress-clear', description: 'Remove all progress bars' })
986
987 return next(e)
988 })
989
990 on('prompt.compose', async ($, e, next) => {
991 const result = await next(e)
992
993 return { sections: [...result.sections, { id: 'plan-progress:rules', text: RULES, scope: 'session' as const }] }
994 })
995
996 on('tool.call', { tool: TOOL }, async ($, e) => {
997 const raw = e as unknown as Raw
998 const now = await $.clock.now()
999 const list = await read($, plans)
1000 const id = slug(str(raw.id, 60) || str(raw.title, 80))
1001 const next = normalize(raw, list.find(p => p.id === id) ?? null, now, id)
1002 if (next.stages.length === 0) return { deny: `plan_progress: no bar "${id}" yet; create it with title and stages.` }
1003 isPlanTouched = true
1004 sinceUpdate = 0
1005 // finished mid-turn: the bar holds at its last step until the turn ends, so done comes after the reply
1006 const prevState = list.find(p => p.id === id)?.state
1007 const isHeld = isMainTurn && next.state === 'done' && prevState !== 'done'
1008 await putPlan($, isHeld ? { ...next, state: 'running', isFinishing: true } : next)
1009 const w = where(next)
1010
1011 const active = next.stages.flatMap(st => st.steps).find(st => st.status === 'active')
1012
1013 return { result: `${id}: ${Math.min(w.pos, w.total)}/${w.total}, ${next.state}${active ? `, active "${active.title}"` : ''}` }
1014 })
1015
1016 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
1017 const live = (await read($, plans)).filter(p => p.state === 'running' && !p.isFinishing).pop()
1018 if (live) await update($, plans, list => list.map(p => (p.id === live.id ? { ...p, state: 'needs_input' as const } : p)))
1019 play($, 'decision')
1020 await publish($, 'needs_input')
1021 const ran = await next(e)
1022 if (live) await update($, plans, list => list.map(p => (p.id === live.id && p.state === 'needs_input' ? { ...p, state: 'running' as const } : p)))
1023 await publish($, 'working')
1024
1025 return ran
1026 })
1027
1028 on('tool.call', { tool: 'ExitPlanMode' }, async ($, e, next) => {
1029 play($, 'decision')
1030 const ran = await next(e)
1031 const text = ran.deny === undefined && ran.isError !== true ? (ran.result as { plan?: unknown } | undefined)?.plan : undefined
1032 if (typeof text === 'string') {
1033 const parsed = parsePlan(text, await $.clock.now())
1034 if (parsed) await putPlan($, { ...parsed, id: slug(parsed.title) })
1035 }
1036
1037 return ran
1038 })
1039
1040 on('command.run', { command: 'progress' }, async $ => {
1041 const open = await read($, isOpen)
1042 await setShown($, !open)
1043 const hasBars = (await read($, plans)).length > 0
1044
1045 return { text: `${open ? 'Progress bars hidden.' : 'Progress bars shown.'}${hasBars ? '' : ' No plan yet. /progress-demo shows a sample.'}` }
1046 })
1047
1048 on('command.run', { command: 'progress-demo' }, async $ => {
1049 await putPlan($, DEMO(await $.clock.now()))
1050 // asked for by the person, so it shows the bars again
1051 await setShown($, true)
1052
1053 return { text: 'Sample plan shown above the prompt.' }
1054 })
1055
1056 on('command.run', { command: 'progress-style' }, async ($, e) => {
1057 const current = await styleOf($)
1058 const arg = e.args.trim().toLowerCase()
1059 const list = STYLE_IDS.map(id => `${id === current ? '▸' : ' '} ${id.padEnd(9)} ${STYLE_INFO[id]}`).join('\n')
1060 if (!arg) {
1061 await $.ui.open({ id: STYLE_PANE, title: STYLE_PANE_TITLE })
1062 return { text: `目前風格:${current}(已打開樣式面板)\n${list}\n\n用法:/progress-style <名稱>,或 /progress-style next 換下一個` }
1063 }
1064 const nextId = arg === 'next' ? STYLE_IDS[(STYLE_IDS.indexOf(current) + 1) % STYLE_IDS.length] : arg
1065 if (!isStyleId(nextId)) return { text: `沒有「${arg}」這個風格。\n${list}` }
1066 await setStyle($, nextId)
1067 const hasBars = (await read($, plans)).length > 0
1068 return { text: `已切換到 ${nextId}:${STYLE_INFO[nextId]}${hasBars ? '' : '\n現在沒有進度條,用 /progress-demo 看樣子。'}` }
1069 })
1070
1071 on('command.run', { command: 'progress-clear' }, async $ => {
1072 await update($, plans, () => [])
1073
1074 return { text: 'Progress bars removed.' }
1075 })
1076
1077 on('command.run', { command: 'progress-sounds' }, async $ => {
1078 // asked for by the person, so it plays even with sounds off
1079 playNow($, 'decision')
1080 $.clock.after(900, () => playNow($, 'error'))
1081 $.clock.after(1800, () => playNow($, 'done'))
1082
1083 return { text: 'Sounds: decision, error, done.' }
1084 })
1085
1086 // always drawn, so the person sees the mod is loaded: one gear that opens the style pane.
1087 // A Button: the desktop draws it with a native grey box, but its footer draws no Client
1088 // region and no pressable Markdown link (0.5.7-0.5.10), so a Button is the one control that works there;
1089 // one glyph keeps that box small and has no descender to clip
1090 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
1091 const { Box, Button } = $.ui.resolve(e)
1092 // other mods add their labels to modes beneath us; keep them
1093 const below = await next(e)
1094
1095 // a gap only when other labels follow: with none, it left the gear one cell further
1096 // from the model name than the footer's own spacing
1097 return (
1098 <Box flexDirection="row" alignItems="center" gap={e.props.modes.length > 0 ? 1 : 0}>
1099 <Button key="progress-style" plain dimColor label={SETTINGS_GLYPH} onPress={() => openStyles($)} />
1100 {below}
1101 </Box>
1102 )
1103 })
1104
1105 // four settings, each a press away: whether the bars show, whether sounds play, how subagents show,
1106 // then one bordered tile per style with its preview
1107 on('ui.render', { component: 'Pane', requestId: STYLE_PANE }, async ($, e) => {
1108 const t = $.ui.resolve(e)
1109 const { Box, Button, Text } = t
1110 const Svg = 'Svg' in t ? t.Svg : null
1111 const current = await styleOf($)
1112 const view = await agentViewOf($)
1113 const isShown = await read($, isOpen)
1114 const isSounding = await read($, soundsOn)
1115 const isCross = await read($, crossOn)
1116 const now = await $.clock.now()
1117 const W = Math.max(200, Math.min(460, (e.props.bodyColumns || 50) * 8 - 24))
1118 // a tile's border and padding take about three columns
1119 const tileW = W - 24
1120 const sampleAgents: AgentRun[] = [
1121 { id: 'pa1', title: '讀取模組', state: 'running', tool: 'Read', startedAt: now - 48_000, endedAt: null, depth: 0 },
1122 { id: 'pa2', title: '量 API 延遲', state: 'done', tool: 'Done', startedAt: now - 72_000, endedAt: now - 6_000, depth: 0 },
1123 { id: 'pa3', title: '比對打包大小', state: 'waiting', tool: 'Needs approval', startedAt: now - 9_000, endedAt: null, depth: 0 },
1124 ]
1125 const agentsSample = { ...DEMO(now), id: 'preview-agents', agents: sampleAgents }
1126 const allAgents = { shown: sampleAgents, hidden: [] }
1127 // the slot keeps the expanded height in every mode, so switching modes never moves the list below
1128 const agentsSlotH = drawBar(current, agentsSample, W, now, allAgents).height
1129 const agentsDrawn = inSlot(drawBar(current, agentsSample, W, now, applyAgentView(allAgents, view)), W, agentsSlotH, 'top')
1130 // every style's preview sits centred in one slot as tall as the tallest, so all tiles share one size
1131 const previews = new Map(STYLE_IDS.map(id => [id, drawBar(id, { ...DEMO(now), id: `preview-${id}` }, tileW, now, null)] as const))
1132 const previewSlotH = Math.max(...[...previews.values()].map(d => d.height))
1133
1134 return (
1135 <Box flexDirection="column" gap={2}>
1136 {/* the two on/off settings side by side; save and close stays at the top right */}
1137 <Box key="settings-row" flexDirection="row" alignItems="flex-end" gap={3}>
1138 <Box flexDirection="column" gap={1}>
1139 <Text bold>進度條</Text>
1140 <Box key="bars-row" flexDirection="row" alignItems="center" gap={1}>
1141 {([true, false] as const).map(shown => (
1142 <Button
1143 key={shown ? 'bars-shown' : 'bars-hidden'}
1144 variant={shown === isShown ? 'primary' : 'secondary'}
1145 label={shown ? '顯示' : '隱藏'}
1146 onPress={() => (shown === isShown ? undefined : setShown($, shown))}
1147 />
1148 ))}
1149 </Box>
1150 </Box>
1151 <Box flexDirection="column" gap={1}>
1152 <Text bold>提示音</Text>
1153 <Box key="sounds-row" flexDirection="row" alignItems="center" gap={1}>
1154 {([true, false] as const).map(isOn => (
1155 <Button
1156 key={isOn ? 'sounds-on' : 'sounds-off'}
1157 variant={isOn === isSounding ? 'primary' : 'secondary'}
1158 label={isOn ? '開' : '關'}
1159 onPress={() => (isOn === isSounding ? undefined : setSounds($, isOn))}
1160 />
1161 ))}
1162 </Box>
1163 </Box>
1164 <Box flexGrow={1} />
1165 {/* every press in the pane already saved; this one only closes it */}
1166 <Button key="save-close" variant="primary" label="儲存並關閉" onPress={() => $.ui.close({ id: STYLE_PANE })} />
1167 </Box>
1168
1169 <Box flexDirection="column" gap={1}>
1170 <Text bold>其他 session</Text>
1171 <Box key="cross-row" flexDirection="row" alignItems="center" gap={1}>
1172 {([true, false] as const).map(isOn => (
1173 <Button
1174 key={isOn ? 'cross-on' : 'cross-off'}
1175 variant={isOn === isCross ? 'primary' : 'secondary'}
1176 label={isOn ? '開' : '關'}
1177 onPress={() => (isOn === isCross ? undefined : setCross($, isOn))}
1178 />
1179 ))}
1180 </Box>
1181 </Box>
1182
1183 <Box flexDirection="column" gap={1}>
1184 <Text bold>Subagent 顯示</Text>
1185 <Box key="agents-row" flexDirection="row" alignItems="center" gap={1}>
1186 {AGENT_VIEWS.map(k => (
1187 <Button
1188 key={`agents-${k}`}
1189 variant={k === view ? 'primary' : 'secondary'}
1190 label={AGENT_VIEW_LABEL[k]}
1191 onPress={() => (k === view ? undefined : setAgentView($, k))}
1192 />
1193 ))}
1194 </Box>
1195 {Svg ? [<Svg key="agents-preview" source={agentsDrawn.svg} alt={`subagents ${view} preview`} width={W} height={agentsDrawn.height} />] : []}
1196 </Box>
1197
1198 {/* tiles name no width (a Box width counts cells, not pixels); the column stretches them all to the pane's width */}
1199 <Box key="style-list" flexDirection="column" alignItems="stretch" gap={1}>
1200 <Text bold>進度條樣式</Text>hooks/styles.ts 376 lines1import type { AgentRun, Plan, PlanState, StepStatus } from '../types'
2
3// the bar styles a person can switch between with /progress-style; 'original' is the author's pixel bar in register.tsx
4export const STYLE_IDS = ['segments', 'hairline', 'beads', 'ledger', 'transit', 'original'] as const
5export type StyleId = (typeof STYLE_IDS)[number]
6export const DEFAULT_STYLE: StyleId = 'segments'
7export const STYLE_INFO: Record<StyleId, string> = {
8 segments: '分段:一個階段一格,所有階段名都看得到(陶土橘)',
9 hairline: '細線:2px 細線+會呼吸的線頭,平常黑白,右邊顯示經過時間',
10 beads: '串珠:一步一顆珠子,同階段用線串起來(藍)',
11 ledger: '刻度字:等寬字+小方格,像終端機游標,只有要注意時才有顏色',
12 transit: '路線圖:像捷運圖,站名在下方,比其他版本高一點(紫)',
13 original: '原版:作者的像素顆粒進度條',
14}
15export const isStyleId = (v: unknown): v is StyleId => typeof v === 'string' && (STYLE_IDS as readonly string[]).includes(v)
16
17export type Visible = { shown: AgentRun[]; hidden: AgentRun[] }
18export type Drawn = { svg: string; height: number }
19export type Glyph = { char: string; color: string } | null
20export type BarStyle = {
21 glyph: (p: Plan) => Glyph
22 right: (p: Plan, now: number) => string
23 draw: (p: Plan, W: number, now: number, agents: Visible | null) => Drawn
24}
25
26// colours live in CSS variables so one drawing follows the app's light or dark scheme
27const PALETTE = `<style>svg{--ink:#1F1E1C;--muted:#73726C;--track:rgba(31,30,28,.08);--faint:rgba(31,30,28,.24);--panel:#F0EEE6;--on:#FFFBF7;--amber:#B26F12;--red:#C93C3C;--green:#2E8655;--clay:#C6613F;--blue:#3D6FD6;--violet:#6B5BD2}
28@media (prefers-color-scheme:dark){svg{--ink:#F2F0EA;--muted:#A3A19A;--track:rgba(242,240,234,.10);--faint:rgba(242,240,234,.30);--panel:#2A2927;--on:#1F1E1C;--amber:#E2A64B;--red:#F07272;--green:#5FC58D;--clay:#E08A6A;--blue:#86A8F2;--violet:#A99CF6}}</style>`
29// the glyph left of the title is a Text colour, one hex for both schemes
30const GLYPH_COLOR = { clay: '#D97757', blue: '#5B85E8', violet: '#8B7CF6', needs_input: '#E09A1E', error: '#E5484D', done: '#30A46C' }
31const SANS = "'Anthropic Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif"
32const MONO = "ui-monospace,'SF Mono',Menlo,monospace"
33
34const svg = (W: number, H: number, body: string) => `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${PALETTE}${body}</svg>`
35const esc = (s: string) => s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c)
36const fin = (s: StepStatus) => s === 'done' || s === 'skipped'
37const n1 = (v: number) => v.toFixed(1)
38const fill = (c: string, op?: number) => `style="fill:${c}${op === undefined ? '' : `;fill-opacity:${op}`}"`
39const ring = (c: string, w: number, bg = 'var(--panel)') => `style="fill:${bg};stroke:${c};stroke-width:${w}"`
40
41// an estimate of drawn text width; the module has no canvas to measure with
42function textWidth(s: string, px: number, mono = false): number {
43 return [...s].reduce((w, ch) => {
44 if (/[ -鿿-]/.test(ch)) return w + px
45 if (mono) return w + px * 0.6
46 if (/[ilI.,:;'|!]/.test(ch)) return w + px * 0.3
47 if (/[mwMW]/.test(ch)) return w + px * 0.82
48 if (/[A-Z]/.test(ch)) return w + px * 0.66
49 return w + px * 0.55
50 }, 0)
51}
52function fit(s: string, px: number, max: number, mono = false): string {
53 if (textWidth(s, px, mono) <= max) return s
54 let t = s
55 while (t.length > 1 && textWidth(t + '…', px, mono) > max) t = t.slice(0, -1)
56 return t.length > 1 ? t + '…' : ''
57}
58
59type Where = { pos: number; total: number; stage: number; step: number; stageSize: number; title: string }
60function where(p: Plan): Where {
61 const steps = p.stages.flatMap((s, i) => s.steps.map((step, j) => ({ i, j, step })))
62 const at = steps.findIndex(x => !fin(x.step.status))
63 const pos = p.state === 'done' || at < 0 ? steps.length : at
64 const cur = steps[Math.min(pos, steps.length - 1)]
65 const stage = cur?.i ?? 0
66 const stageSize = p.stages[stage]?.steps.length ?? 0
67 return { pos, total: steps.length, stage, step: pos >= steps.length ? stageSize : (cur?.j ?? 0) + 1, stageSize, title: cur?.step.title ?? '' }
68}
69const stateVar = (state: PlanState, accent: string) =>
70 state === 'running' ? accent : state === 'needs_input' ? 'var(--amber)' : state === 'error' ? 'var(--red)' : 'var(--green)'
71const glyphFor = (p: Plan, accent: string): Glyph => ({
72 char: p.state === 'needs_input' ? '?' : p.state === 'error' ? '!' : p.state === 'done' ? '✓' : '●',
73 color: p.state === 'running' ? accent : GLYPH_COLOR[p.state],
74})
75const percent = (p: Plan) => {
76 const w = where(p)
77 return `${p.state === 'done' ? 100 : Math.round((Math.min(w.pos, w.total) / Math.max(1, w.total)) * 100)}%`
78}
79const stepCount = (p: Plan) => {
80 const w = where(p)
81 return `${Math.min(w.pos, w.total)}/${w.total}`
82}
83const elapsed = (ms: number) => {
84 const sec = Math.max(0, Math.round(ms / 1000))
85 return sec < 60 ? `${sec}s` : `${Math.floor(sec / 60)}m ${sec % 60}s`
86}
87
88// ---------- agent strips, shared by the new styles ----------
89
90type StripLook = { accent: string; bg: boolean; mono?: boolean; tint?: number }
91function strips(v: Visible, W: number, now: number, o: StripLook): { body: string; height: number } {
92 const RH = o.bg ? 18 : 16
93 const GAP = o.bg ? 3 : 2
94 const px = o.mono ? 10.5 : 11.5
95 const family = o.mono ? MONO : SANS
96 const colour = (a: AgentRun) => (a.state === 'running' ? o.accent : a.state === 'waiting' ? 'var(--amber)' : a.state === 'done' ? 'var(--green)' : 'var(--red)')
97 const rows: string[] = []
98 v.shown.forEach((a, i) => {
99 const y = i * (RH + GAP)
100 const cy = y + RH / 2
101 const c = colour(a)
102 const indent = a.depth > 0 ? 12 : 0
103 let row = o.bg ? `<rect x="0" y="${y}" width="${W}" height="${RH}" rx="${RH / 2}" ${fill(c, o.tint ?? 0.12)}/>` : ''
104 const dx = (o.bg ? 10 : 4) + indent
105 row += `<circle cx="${dx}" cy="${cy}" r="3" ${fill(c)}>${a.state === 'running' ? '<animate attributeName="opacity" values="1;.35;1" dur="1.4s" repeatCount="indefinite"/>' : ''}</circle>`
106 const name = fit((a.depth > 0 ? '↳ ' : '') + a.title, px, W * 0.45, o.mono)
107 const nx = dx + 9
108 row += `<text x="${nx}" y="${cy + 4}" font-family="${family}" font-size="${px}" ${fill('var(--ink)')}>${esc(name)}</text>`
109 if (W > 300) row += `<text x="${n1(nx + textWidth(name, px, o.mono) + 8)}" y="${cy + 4}" font-family="${family}" font-size="${px}" ${fill(c)}>${esc(a.tool)}</text>`
110 row += `<text x="${W - (o.bg ? 9 : 2)}" y="${cy + 4}" text-anchor="end" font-family="${SANS}" font-size="11" ${fill('var(--muted)')}>${elapsed((a.endedAt ?? now) - a.startedAt)}</text>`
111 rows.push(row)
112 })
113 if (v.hidden.length > 0) {
114 const y = v.shown.length * (RH + GAP)
115 const done = v.hidden.filter(a => a.state === 'done').length
116 rows.push(
117 (o.bg ? `<rect x="0" y="${y}" width="${W}" height="${RH}" rx="${RH / 2}" ${fill('var(--faint)', 0.35)}/>` : '') +
118 `<text x="${o.bg ? 10 : 4}" y="${y + RH / 2 + 4}" font-family="${family}" font-size="${px}" ${fill('var(--muted)')}>+${v.hidden.length} more · ${done} done</text>`,
119 )
120 }
121 const count = rows.length
122 return { body: rows.join(''), height: count ? count * (RH + GAP) - GAP : 0 }
123}
124
125function withStrips(body: string, trackH: number, W: number, now: number, v: Visible | null, o: StripLook): Drawn {
126 if (!v) return { svg: svg(W, trackH, body), height: trackH }
127 const s = strips(v, W, now, o)
128 const off = trackH + 6
129 return { svg: svg(W, off + s.height, `${body}<g transform="translate(0 ${off})">${s.body}</g>`), height: off + s.height }
130}
131
132// ---------- segments: one cell per stage, every stage name visible ----------
133
134const segments: BarStyle = {
135 glyph: p => glyphFor(p, GLYPH_COLOR.clay),
136 right: stepCount,
137 draw(p, W, now, agents) {
138 const H = 22
139 const gap = 3
140 const total = Math.max(1, p.stages.reduce((a, s) => a + s.steps.length, 0))
141 const avail = W - gap * (p.stages.length - 1)
142 const sc = stateVar(p.state, 'var(--clay)')
143 let x = 0
144 let defs = `<linearGradient id="sweep" x1="0" x2="1"><stop offset="0" style="stop-color:${sc};stop-opacity:0"/><stop offset=".6" style="stop-color:${sc};stop-opacity:.55"/><stop offset="1" style="stop-color:${sc};stop-opacity:0"/></linearGradient>`
145 let out = ''
146 p.stages.forEach((s, i) => {
147 const w = (avail * s.steps.length) / total
148 const doneN = s.steps.filter(t => fin(t.status)).length
149 const isDone = doneN === s.steps.length
150 const curIdx = s.steps.findIndex(t => t.status === 'active' || t.status === 'error')
151 const isCur = curIdx >= 0
152 const c = p.state === 'done' ? 'var(--green)' : isCur ? sc : 'var(--clay)'
153 const sw = w / s.steps.length
154 defs += `<clipPath id="s${i}"><rect x="${n1(x)}" y="0" width="${n1(w)}" height="${H}" rx="6"/></clipPath><clipPath id="f${i}"><rect x="${n1(x)}" y="0" width="${n1(isDone ? w : sw * doneN)}" height="${H}"/></clipPath>`
155 let g = `<rect x="${n1(x)}" y="0" width="${n1(w)}" height="${H}" ${fill(isDone ? c : 'var(--track)')}/>`
156 if (!isDone) {
157 if (isCur || doneN) g += `<rect x="${n1(x)}" y="0" width="${n1(w)}" height="${H}" ${fill(c, 0.13)}/><rect x="${n1(x)}" y="0" width="${n1(sw * doneN)}" height="${H}" ${fill(c)}/>`
158 if (isCur) {
159 const ax = x + sw * curIdx
160 if (p.state === 'running') {
161 const bw = Math.max(16, sw * 0.7)
162 defs += `<clipPath id="a${i}"><rect x="${n1(ax)}" y="0" width="${n1(sw)}" height="${H}"/></clipPath>`
163 g += `<rect x="${n1(ax)}" y="0" width="${n1(sw)}" height="${H}" ${fill(c, 0.14)}/>`
164 g += `<g clip-path="url(#a${i})"><rect x="${n1(ax - bw)}" y="0" width="${n1(bw)}" height="${H}" fill="url(#sweep)"><animate attributeName="x" from="${n1(ax - bw)}" to="${n1(ax + sw)}" dur="1.6s" repeatCount="indefinite" calcMode="spline" keyTimes="0;1" keySplines=".25 .1 .25 1"/></rect></g>`
165 } else {
166 g += `<rect x="${n1(ax)}" y="0" width="${n1(sw)}" height="${H}" ${fill(c, 0.5)}>${p.state === 'needs_input' ? '<animate attributeName="fill-opacity" values=".5;.2;.5" dur="1.8s" repeatCount="indefinite"/>' : ''}</rect>`
167 }
168 }
169 for (let j = doneN + 1; j < s.steps.length; j++) g += `<rect x="${n1(x + sw * j - 0.5)}" y="7" width="1" height="8" ${fill('var(--ink)', 0.16)}/>`
170 }
171 const px = 11.5
172 let label = isCur ? `${s.name} ${curIdx + 1}/${s.steps.length}` : s.name
173 if (textWidth(label, px) > w - 16) label = fit(s.name, px, w - 16)
174 const lbl = (c2: string) => (label ? `<text x="${n1(x + 8)}" y="15" font-family="${SANS}" font-size="${px}" font-weight="${isCur ? 600 : 500}" ${fill(c2)}>${esc(label)}</text>` : '')
175 g += lbl(isDone ? 'var(--on)' : isCur ? 'var(--ink)' : 'var(--muted)')
176 if (!isDone && doneN) g += `<g clip-path="url(#f${i})">${lbl('var(--on)')}</g>`
177 out += `<g clip-path="url(#s${i})">${g}</g>`
178 x += w + gap
179 })
180 return withStrips(`<defs>${defs}</defs>${out}`, H, W, now, agents, { accent: 'var(--clay)', bg: true })
181 },
182}
183
184// ---------- hairline: a 2px line with a breathing head, ink until something needs attention ----------
185
186const hairline: BarStyle = {
187 glyph: () => null,
188 right: (p, now) => (p.state === 'done' ? 'Done' : elapsed(now - p.startedAt)),
189 draw(p, W, now, agents) {
190 const H = 22
191 const w = where(p)
192 const done = p.state === 'done'
193 const c = done ? 'var(--green)' : p.state === 'running' ? 'var(--ink)' : stateVar(p.state, 'var(--ink)')
194 const sName = done ? 'Done' : (p.stages[w.stage]?.name ?? '')
195 const stepT = done ? '' : (p.state !== 'running' && p.note) || w.title
196 const nW = textWidth(sName, 12) * 1.05
197 const sT = fit(stepT, 12, Math.max(0, W * 0.42 - nW - 8))
198 const labelW = nW + (sT ? 8 + textWidth(sT, 12) : 0)
199 let out = `<text x="0" y="15" font-family="${SANS}" font-size="12"><tspan font-weight="600" ${fill(done ? 'var(--green)' : 'var(--ink)')}>${esc(sName)}</tspan>${sT ? `<tspan dx="8" ${fill(p.state === 'running' ? 'var(--muted)' : c)}>${esc(sT)}</tspan>` : ''}</text>`
200 const cnt = stepCount(p)
201 const L = labelW + 16
202 const R = W - textWidth(cnt, 11) - 12
203 out += `<text x="${W}" y="15" text-anchor="end" font-family="${SANS}" font-size="11" ${fill('var(--muted)')}>${cnt}</text>`
204 const X = (k: number) => L + (k / Math.max(1, w.total)) * (R - L)
205 const fx = X(done ? w.total : w.pos)
206 let k = 0
207 for (const s of p.stages) {
208 const a = X(k) + (k ? 2 : 0)
209 const end = k + s.steps.length
210 const b = X(end) - (end < w.total ? 2 : 0)
211 out += `<rect x="${n1(a)}" y="10" width="${n1(Math.max(0, b - a))}" height="2" rx="1" ${fill('var(--faint)', 0.55)}/>`
212 if (fx > a) out += `<rect x="${n1(a)}" y="10" width="${n1(Math.max(0, Math.min(b, fx) - a))}" height="2" rx="1" ${fill(done ? 'var(--green)' : 'var(--ink)', done ? 1 : 0.85)}/>`
213 k = end
214 }
215 if (done) {
216 out += `<circle cx="${n1(R)}" cy="11" r="6" ${fill('var(--green)')}/><path d="M${n1(R - 2.6)} 11.2l1.8 1.8 3.4-3.6" style="fill:none;stroke:var(--on);stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round"/>`
217 } else if (p.state === 'needs_input') {
218 out += `<circle cx="${n1(fx)}" cy="11" r="5" ${ring(c, 2)}><animate attributeName="stroke-opacity" values="1;.35;1" dur="1.8s" repeatCount="indefinite"/></circle>`
219 } else {
220 if (p.state === 'running') out += `<circle cx="${n1(fx)}" cy="11" r="4" ${fill(c, 0.3)}><animate attributeName="r" values="4;10" dur="1.8s" repeatCount="indefinite" calcMode="spline" keyTimes="0;1" keySplines=".2 .8 .3 1"/><animate attributeName="fill-opacity" values=".3;0" dur="1.8s" repeatCount="indefinite"/></circle>`
221 out += `<circle cx="${n1(fx)}" cy="11" r="${p.state === 'error' ? 5 : 4}" ${fill(c)}/>`
222 if (p.state === 'error') out += `<path d="M${n1(fx - 2)} 9l4 4M${n1(fx + 2)} 9l-4 4" style="stroke:var(--on);stroke-width:1.4;stroke-linecap:round"/>`
223 }
224 return withStrips(out, H, W, now, agents, { accent: 'var(--ink)', bg: false })
225 },
226}
227
228// ---------- beads: one bead per step, a stage's beads strung together ----------
229
230const beads: BarStyle = {
231 glyph: p => glyphFor(p, GLYPH_COLOR.blue),
232 right: percent,
233 draw(p, W, now, agents) {
234 const H = 22
235 const cy = 11
236 const w = where(p)
237 const done = p.state === 'done'
238 const acc = done ? 'var(--green)' : 'var(--blue)'
239 let sp = 15
240 let sg = 14
241 const need = () => p.stages.reduce((a, s) => a + (s.steps.length - 1) * sp + 10, 0) + (p.stages.length - 1) * sg
242 while (need() > W * 0.55 && sp > 7) {
243 sp -= 1
244 sg = Math.max(8, sg - 0.6)
245 }
246 let x = 6
247 let out = ''
248 for (const s of p.stages) {
249 const xs = s.steps.map((_, j) => x + j * sp)
250 for (let j = 1; j < xs.length; j++) {
251 const on = fin(s.steps[j]?.status ?? 'pending')
252 out += `<rect x="${n1(xs[j - 1] ?? 0)}" y="${cy - 0.75}" width="${n1(sp)}" height="1.5" ${fill(on ? acc : 'var(--faint)', on ? 0.9 : 0.6)}/>`
253 }
254 s.steps.forEach((st, j) => {
255 const bx = n1(xs[j] ?? 0)
256 if (fin(st.status)) out += `<circle cx="${bx}" cy="${cy}" r="4.5" ${fill(acc)}/>`
257 else if (st.status === 'error') out += `<circle cx="${bx}" cy="${cy}" r="5.5" ${fill('var(--red)')}/><path d="M${n1(Number(bx) - 2)} ${cy - 2}l4 4M${n1(Number(bx) + 2)} ${cy - 2}l-4 4" style="stroke:var(--on);stroke-width:1.4;stroke-linecap:round"/>`
258 else if (st.status === 'active') {
259 const c = p.state === 'needs_input' ? 'var(--amber)' : acc
260 out += `<circle cx="${bx}" cy="${cy}" r="5.5" ${ring(c, 1.8)}/><circle cx="${bx}" cy="${cy}" r="2.4" ${fill(c)}><animate attributeName="r" values="2;3.2;2" dur="${p.state === 'needs_input' ? 1.8 : 1.2}s" repeatCount="indefinite"/></circle>`
261 } else out += `<circle cx="${bx}" cy="${cy}" r="3.6" ${ring('var(--faint)', 1.5)}/>`
262 })
263 x = (xs[xs.length - 1] ?? x) + 10 + sg - 4
264 }
265 const lx = x + 4
266 const sName = done ? 'Done' : (p.stages[w.stage]?.name ?? '')
267 const stepT = done ? '' : (p.state !== 'running' && p.note) || w.title
268 const room = W - lx
269 const nW = textWidth(sName, 12) * 1.05
270 const sT = fit(stepT, 12, room - nW - 22)
271 if (room > nW) out += `<text x="${n1(lx)}" y="15" font-family="${SANS}" font-size="12"><tspan font-weight="600" ${fill(done ? 'var(--green)' : 'var(--ink)')}>${esc(sName)}</tspan>${sT ? `<tspan dx="6" ${fill('var(--muted)')}>· ${esc(sT)}</tspan>` : ''}</text>`
272 return withStrips(out, H, W, now, agents, { accent: 'var(--blue)', bg: false })
273 },
274}
275
276// ---------- ledger: mono stage names and a row of cells, the active cell blinks like a cursor ----------
277
278const ledger: BarStyle = {
279 glyph: () => null,
280 right: stepCount,
281 draw(p, W, now, agents) {
282 const H = 22
283 const done = p.state === 'done'
284 const w = where(p)
285 const cw = 6
286 const cg = 3
287 const lg = 7
288 const sgap = 14
289 const px = 10.5
290 const cellsW = (n: number) => n * (cw + cg) - cg
291 const nameOf = (name: string, mode: 'full' | 'short') => (mode === 'short' ? name.slice(0, 3) : name).toUpperCase()
292 const width = (mode: 'full' | 'short' | 'none') =>
293 p.stages.reduce((a, s) => a + (mode === 'none' ? 0 : textWidth(nameOf(s.name, mode), px, true) * 1.04 + lg) + cellsW(s.steps.length), 0) + (p.stages.length - 1) * sgap
294 const mode = width('full') <= W * 0.78 ? 'full' : width('short') <= W * 0.9 ? 'short' : 'none'
295 let x = 0
296 let out = ''
297 p.stages.forEach((s, i) => {
298 const isCur = !done && i === w.stage
299 const allDone = s.steps.every(t => fin(t.status))
300 if (mode !== 'none') {
301 const name = nameOf(s.name, mode)
302 out += `<text x="${n1(x)}" y="15" font-family="${MONO}" font-size="${px}" font-weight="${isCur ? 700 : 500}" letter-spacing=".04em" ${fill(isCur ? 'var(--ink)' : 'var(--muted)', isCur || allDone ? 1 : 0.7)}>${esc(name)}</text>`
303 x += textWidth(name, px, true) * 1.04 + lg
304 }
305 for (const st of s.steps) {
306 if (done) out += `<rect x="${n1(x)}" y="5" width="${cw}" height="12" rx="1.5" ${fill('var(--green)')}/>`
307 else if (fin(st.status)) out += `<rect x="${n1(x)}" y="5" width="${cw}" height="12" rx="1.5" ${fill('var(--ink)', 0.72)}/>`
308 else if (st.status === 'error') out += `<rect x="${n1(x)}" y="5" width="${cw}" height="12" rx="1.5" ${fill('var(--red)')}/>`
309 else if (st.status === 'active') {
310 const c = p.state === 'needs_input' ? 'var(--amber)' : 'var(--ink)'
311 out += `<rect x="${n1(x)}" y="5" width="${cw}" height="12" rx="1.5" ${fill(c)}><animate attributeName="opacity" values="1;1;.15;.15" keyTimes="0;.5;.5;1" dur="1.05s" repeatCount="indefinite"/></rect>`
312 } else out += `<rect x="${n1(x + 0.6)}" y="5.6" width="${cw - 1.2}" height="10.8" rx="1.2" style="fill:none;stroke:var(--faint);stroke-width:1.2"/>`
313 x += cw + cg
314 }
315 x += sgap - cg
316 })
317 const label = done ? 'Done' : (p.state !== 'running' && p.note) || w.title
318 const lc = done ? 'var(--green)' : p.state === 'running' ? 'var(--muted)' : stateVar(p.state, 'var(--ink)')
319 const t = fit(label, 12, W - x - 4)
320 if (t) out += `<text x="${n1(x)}" y="15" font-family="${SANS}" font-size="12" ${fill(lc)}>${esc(t)}</text>`
321 return withStrips(out, H, W, now, agents, { accent: 'var(--ink)', bg: false, mono: true })
322 },
323}
324
325// ---------- transit: stages as stretches of a line, names below, a train moving along ----------
326
327const transit: BarStyle = {
328 glyph: p => glyphFor(p, GLYPH_COLOR.violet),
329 right: percent,
330 draw(p, W, now, agents) {
331 const H = 36
332 const cy = 10
333 const pad = 7
334 const w = where(p)
335 const done = p.state === 'done'
336 const c = done ? 'var(--green)' : stateVar(p.state, 'var(--violet)')
337 const lineC = done ? 'var(--green)' : 'var(--violet)'
338 const L = pad
339 const R = W - pad
340 const X = (k: number) => L + (k / Math.max(1, w.total)) * (R - L)
341 const pos = done ? w.total : w.pos
342 let out = `<rect x="${L}" y="${cy - 1.5}" width="${n1(R - L)}" height="3" rx="1.5" ${fill('var(--faint)', 0.5)}/>`
343 out += `<rect x="${L}" y="${cy - 1.5}" width="${n1(X(pos) - L)}" height="3" rx="1.5" ${fill(lineC)}/>`
344 const bounds = [0]
345 for (const s of p.stages) bounds.push((bounds[bounds.length - 1] ?? 0) + s.steps.length)
346 for (let k = 1; k < w.total; k++) if (!bounds.includes(k)) out += `<circle cx="${n1(X(k))}" cy="${cy}" r="1.4" ${fill(k <= pos ? 'var(--panel)' : 'var(--faint)')}/>`
347 bounds.forEach((k, i) => {
348 const passed = k <= pos
349 const last = i === bounds.length - 1
350 out += `<circle cx="${n1(X(k))}" cy="${cy}" r="${last ? 5.5 : 4.5}" style="fill:${passed ? lineC : 'var(--panel)'};stroke:${passed ? 'var(--panel)' : 'var(--faint)'};stroke-width:${passed ? 2 : 1.6}"/>`
351 if (last && done) out += `<path d="M${n1(X(k) - 2.4)} ${cy + 0.2}l1.6 1.6 3.2-3.4" style="fill:none;stroke:var(--on);stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round"/>`
352 })
353 if (!done) {
354 const tx = X(pos + 0.5)
355 if (p.state === 'running') out += `<circle cx="${n1(tx)}" cy="${cy}" r="6" ${fill(c, 0.28)}><animate attributeName="r" values="6;12" dur="1.8s" repeatCount="indefinite" calcMode="spline" keyTimes="0;1" keySplines=".2 .8 .3 1"/><animate attributeName="fill-opacity" values=".28;0" dur="1.8s" repeatCount="indefinite"/></circle>`
356 out += `<rect x="${n1(tx - 9)}" y="${cy - 5.5}" width="18" height="11" rx="5.5" style="fill:${c};stroke:var(--panel);stroke-width:2"/>`
357 if (p.state === 'needs_input') out += `<text x="${n1(tx)}" y="${cy + 3.6}" text-anchor="middle" font-family="${SANS}" font-size="9.5" font-weight="700" ${fill('var(--on)')}>?</text>`
358 else if (p.state === 'error') out += `<path d="M${n1(tx - 2)} ${cy - 2}l4 4M${n1(tx + 2)} ${cy - 2}l-4 4" style="stroke:var(--on);stroke-width:1.4;stroke-linecap:round"/>`
359 else out += `<circle cx="${n1(tx - 3)}" cy="${cy}" r="1.2" ${fill('var(--on)')}/><circle cx="${n1(tx + 1)}" cy="${cy}" r="1.2" ${fill('var(--on)')}/><circle cx="${n1(tx + 5)}" cy="${cy}" r="1.2" ${fill('var(--on)', 0.5)}/>`
360 }
361 p.stages.forEach((s, i) => {
362 const a = X(bounds[i] ?? 0)
363 const b = X(bounds[i + 1] ?? 0)
364 const isCur = !done && i === w.stage
365 const passed = (bounds[i + 1] ?? 0) <= pos
366 const px = 10.5
367 let label = isCur ? `${s.name} ${w.step}/${w.stageSize}` : s.name
368 if (textWidth(label, px) > b - a - 6) label = fit(s.name, px, b - a - 6)
369 if (label) out += `<text x="${n1((a + b) / 2)}" y="${cy + 21}" text-anchor="middle" font-family="${SANS}" font-size="${px}" font-weight="${isCur ? 600 : 500}" ${fill(isCur ? 'var(--ink)' : 'var(--muted)', isCur || passed ? 1 : 0.7)}>${esc(label)}</text>`
370 })
371 return withStrips(out, H, W, now, agents, { accent: 'var(--violet)', bg: true, tint: 0.1 })
372 },
373}
374
375export const STYLES: Record<Exclude<StyleId, 'original'>, BarStyle> = { segments, hairline, beads, ledger, transit }
376types/index.d.ts 59 lines1export type StepStatus = 'pending' | 'active' | 'done' | 'error' | 'skipped'
2export type PlanSubstep = { title: string; status: StepStatus }
3export type PlanStep = { title: string; status: StepStatus; substeps: PlanSubstep[] }
4export type PlanStage = { name: string; steps: PlanStep[] }
5export type PlanState = 'running' | 'needs_input' | 'error' | 'done'
6// one subagent shown as a state strip under a bar; depth 1 sits under its parent agent
7export type AgentRun = {
8 id: string
9 title: string
10 state: 'running' | 'waiting' | 'done' | 'error'
11 tool: string
12 startedAt: number
13 endedAt: number | null
14 depth: number
15}
16export type Plan = {
17 id: string
18 title: string
19 kind: 'plan' | 'todo'
20 stages: PlanStage[]
21 state: PlanState
22 note: string | null
23 startedAt: number
24 agents?: AgentRun[]
25 // when the current batch of agents all finished; their strips fold a few seconds later
26 agentsDoneAt?: number | null
27 // set when the person's next message retires a finished bar; it fades out, then leaves the list
28 leavingAt?: number | null
29 // the model finished it mid-turn; it turns done, with its sound, once the main turn ends
30 isFinishing?: boolean
31}
32
33// another desktop session, as this session reads it from the shared folder: what it does now and its newest bar
34// isLeaving: the person pressed ✕; the row dims for a moment, then leaves
35export type OtherSession = { hostId: string; folder: string; label: string; state: 'working' | 'done' | 'needs_input'; since: number; bar?: Plan; isLeaving?: boolean }
36export type OthersView = { rows: OtherSession[] }
37
38declare module 'claude-code' {
39 interface PluginState {
40 'plan-progress': {
41 plans: Plan[]
42 // whether the bars show; also in $.store, so it holds across sessions
43 isOpen: boolean
44 // whether the decision, error and done sounds play; also in $.store
45 sounds: boolean
46 // bumped every second while agents run, so elapsed times and folding redraw
47 tick: number
48 // the bar style chosen with /progress-style, one of STYLE_IDS in hooks/styles.ts
49 style: string
50 // how subagent strips show: 'expanded', 'summary' or 'hidden'
51 agentView: string
52 // the other sessions that run, finished or wait on the person, each with its newest bar
53 others: OthersView
54 // whether those other sessions show above the prompt; also in $.store
55 crossSessions: boolean
56 }
57 }
58}
59