SLOPSHOPPER

plan-progress

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

newpanebandspinnerguardcommand
v0.5.26MITupdated 2026-10-070x5421/plan-progress/plugins/plan-progress
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · plan-progress
│ ┃ 進度條樣式 ✕ › fix the failing auth test and add an audit log call │ ┃ 進度條 提示音 [ 儲存 │ ┃ ⏺ Read(src/auth.ts) │ ┃ [ 顯示 ] [ 隱藏 ] [ 開 ] [ 關 ] ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ 其他 session ⏺ Bash(bun test) │ ┃ ⎿ Denied by plan-progress: plan-progress: several changes a │ ┃ [ 開 ] [ 關 ] │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ Subagent 顯示 ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ [ 展開 ] [ 摘要 ] [ 隱藏 ] › /progress │ ┃ ⎿ plan-progress: Progress bars hidden. No plan yet. /progress-demo │ ┃ │ ┃ 進度條樣式 │ ┃ │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ 分段 segments [ ✓ 使用中 ] │ │ ┃ ╰──────────────────────────────────────────╯ │ ┃ │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ 細線 hairline [ 使用 ] │ │ ┃ ╰──────────────────────────────────────────╯ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚙︎ ⟨Claude Code's own drawing⟩

Draws

Pane · 進度條樣式
進度條 提示音 [ 儲存並關閉 ] [ 顯示 ] [ 隱藏 ] [ 開 ] [ 關 ] 其他 session [ 開 ] [ 關 ] Subagent 顯示 [ 展開 ] [ 摘要 ] [ 隱藏 ] 進度條樣式 ╭──────────────────────────────────────────────────────────╮ │ 分段 segments [ ✓ 使用中 ] │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ 細線 hairline [ 使用 ] │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ 串珠 beads [ 使用 ] │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ 刻度字 ledger [ 使用 ] │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ 路線圖 transit [ 使用 ] │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ 原版 original [ 使用 ] │ ╰──────────────────────────────────────────────────────────╯
README

plan-progress(多樣式版)

在 Claude Code 輸入框上方顯示即時進度條:Claude 接到多步驟任務時,會把工作拆成幾個階段和步驟,邊做邊更新,你看得到它走到哪。

這個版本是 zycck/claude-mods 裡 plan-progress 外掛的 fork(衍生版本),在原作上加了 6 種可切換的外觀和一個樣式面板。

原作者

Kirill Serditov(@zycck),以 MIT 授權發布。

本專案從原作 commit ea2c96b 複製而來。以下功能都是原作者做的,本版本沿用:

  • 進度條的運作機制:Claude 建立一次完整計畫,之後只送很短的更新(例如「下一步」)
  • 寫進系統提示的使用規則,以及「連續動手幾次還沒建進度條就提醒」的檢查
  • Subagent(子代理)狀態條:每個子代理正在做什麼、跑多久、是否在等你核准
  • 需要你決定、出錯、完成時的提示音
  • 底部的 Progress 按鈕,以及 /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(預設)一個階段一格,所有階段名都看得到,陶土橘
hairline2px 細線+會呼吸的線頭,平常黑白,右邊顯示經過時間
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 授權提供。

Source 3 files
hooks/register.tsx 1446 lines
1import { 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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 lines
1import 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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 }
376
types/index.d.ts 59 lines
1export 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