A side pane with Claude's plan as a task tree; quote, redirect or skip any step and Claude follows the change

兩個 Claude Code 擴展(function hooks 模組),終端和 Claude 桌面版(Code 分頁)都能用:
Claude Code 的擴展 API 目前是搶先體驗版,版本更新時可能改變。這兩個擴展在 Claude Code 2.1.289 上開發與測試。
<img alt="cc-pulse:輸入框上方的狀態列,滑鼠移到 cache 上時在下方展開明細" src="docs/cc-pulse-light.svg">
<sub>圖片是會動的 SVG,用擴展本身的繪製程式和範例資料產生:緩存倒數逐秒跳動,進度條上有光澤掃過。</sub>
輸入框上方的一行:
| 項目 | 內容 |
|---|---|
| ctx | 上下文已用的百分比 |
| 5h / 7d | 訂閱方案的 5 小時與 7 天額度;用量超過 80% 時一併顯示重置時間 |
| cache | 提示緩存剩下的時間。Claude 工作時顯示 live,過期後顯示 cold |
| $ | 這個會話目前的花費 |
保溫開關:cache 旁邊的「保溫」開關,預設關閉。打開後,閒置時會在緩存過期前約 30 秒送出一個極短的請求,讓緩存重新計時,每段閒置最多 3 次;你送出下一則訊息後次數歸零。每次保溫都會以緩存讀取的價格把整段上下文計費一次,上下文越大越貴。
指令:/pulse 顯示或隱藏狀態列。
設定放在 ~/.claude/settings.json 的 pluginConfigs:
| 欄位 | 預設值 | 說明 |
|---|---|---|
cacheTtl | auto | 緩存有效期:auto(自動推測)、5m、1h |
warnBeforeExpiry | true | 緩存過期前一分鐘跳出提示 |
keepWarm | false | 新會話是否一開始就打開保溫 |
keepWarmMax | 3 | 每段閒置最多保溫幾次 |
{
"pluginConfigs": {
"cc-pulse": { "options": { "cacheTtl": "1h" } }
}
}
<img alt="task-flow:側邊面板的縱向時間軸,步驟 3 進行中、步驟 4 受阻、步驟 5 已引用" src="docs/task-flow-light.svg">
Claude 接到多步驟的需求時,會先在側邊面板畫出計畫,做的過程中逐步更新每一步的狀態。
[步驟 2.1「…」] 插進輸入框,接著寫你想怎麼改。寫不進輸入框時,改成附在你的下一則訊息裡一起送出。指令:/flow 打開面板。
Claude 透過擴展註冊的兩個工具 mcp__task-flow__plan、mcp__task-flow__step 畫出和更新流程;擴展會在對話的第一則訊息附上使用說明,Claude 不需要額外設定。
~/.claude/mods: git clone https://github.com/Chinsin/claude-code-mods.git ~/.claude/mods
~/.claude/settings.json 的 env 加上這兩行(多個路徑用冒號分隔): {
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods/cc-pulse:~/.claude/mods/task-flow",
"CLAUDE_CODE_PLUGIN_DIR_WATCH": "1"
}
}
CLAUDE_CODE_PLUGIN_DIR_WATCH 讓擴展的檔案存檔後自動重新載入,不需要的話可以省略。
claude)。之後新開的會話都會載入這兩個擴展。只想試用一次,不改設定:
claude --plugin-dir ~/.claude/mods/cc-pulse --plugin-dir ~/.claude/mods/task-flow
claude plugin validate ~/.claude/mods/task-flow # 檢查設定與程式
claude plugin test ~/.claude/mods/task-flow # 執行測試
擴展載入一次後,Claude Code 會在每個擴展的 .claude-plugin/types/ 產生型別定義,之後就能用 tsc -p <擴展資料夾> 做型別檢查。這個資料夾每次載入都會重新產生,所以沒有放進倉庫。
hooks/register.tsx 747 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement } from 'claude-code'
3
4import type { Plan, Step, StepStatus } from '../types'
5import { ACCENT, DONE, RING, progressRing } from './icons'
6import { FRESH_MS, RAIL_H, SWEEP_MS, railCells, railSvg, segOf } from './rail'
7import type { Half, RailLine, Seg } from './rail'
8
9// The plan as a task tree in a side pane. The model draws it through two
10// tools of this plugin; the person quotes any step from the pane into the
11// message they write next.
12
13const PANE = 'task-flow'
14const TITLE = '任務流程'
15const PLAN_TOOL = 'mcp__task-flow__plan'
16const STEP_TOOL = 'mcp__task-flow__step'
17const STATUSES: readonly StepStatus[] = ['pending', 'active', 'done', 'skipped', 'blocked']
18
19const planAtom = atom({ plugin: 'task-flow', key: 'plan' } as const, null)
20const guidedAtom = atom({ plugin: 'task-flow', key: 'guided' } as const, false)
21const quotedAtom = atom({ plugin: 'task-flow', key: 'quoted' } as const, [] as string[])
22
23const GUIDE = `# Task flow pane
24The person follows your plan in a side pane, drawn from the ${PLAN_TOOL} tool.
25- For a request that takes more than a few steps, call ${PLAN_TOOL} before you start: a goal and a tree of steps (ids "1", "1.1", "2"; sub-steps as children). Titles short and in the person's language; specifics go in detail. Skip it for one-step requests.
26- As you work, call ${STEP_TOOL} to mark a step active when you start it and done when it is finished (blocked or skipped with a note when that is what happened). Keep ids stable.
27- A quote like [步驟 2.1「title」] in the person's message, or a note starting "[task-flow]" that lists quoted steps, names those steps of the current plan. When what the person says changes the plan, call ${PLAN_TOOL} again with the revised steps, keeping the ids of steps that did not change.
28- If these tools are not in your tool list yet (deferred), load them first with ToolSearch: select:${PLAN_TOOL},${STEP_TOOL}.`
29
30const stepSchema = (depth: number): Record<string, unknown> => ({
31 type: 'object',
32 properties: {
33 id: { type: 'string', description: 'Stable id: "1", "1.2", "2"' },
34 title: { type: 'string', description: 'Short title, in the person\'s language' },
35 detail: { type: 'string', description: 'What the step does, specifically' },
36 status: { type: 'string', enum: STATUSES },
37 children: depth > 0 ? { type: 'array', items: stepSchema(depth - 1) } : { type: 'array', items: { type: 'object' } },
38 },
39 required: ['id', 'title'],
40})
41
42// ---- the tree ----
43
44function walk(steps: readonly Step[], fn: (s: Step) => Step): Step[] {
45 return steps.map(s => fn({ ...s, children: walk(s.children, fn) }))
46}
47
48function find(steps: readonly Step[], id: string): Step | undefined {
49 for (const s of steps) {
50 if (s.id === id) return s
51 const hit = find(s.children, id)
52 if (hit) return hit
53 }
54 return undefined
55}
56
57function flatten(steps: readonly Step[]): Step[] {
58 return steps.flatMap(s => [s, ...flatten(s.children)])
59}
60
61/** The plan with `finishedAt` set the moment every step has settled. */
62export function stamped(plan: Plan, now: number): Plan {
63 const all = flatten(plan.steps)
64 const isFinished = all.length > 0 && all.every(s => s.status === 'done' || s.status === 'skipped')
65 if (!isFinished) return plan.finishedAt === undefined ? plan : { ...plan, finishedAt: undefined }
66 return plan.finishedAt === undefined ? { ...plan, finishedAt: now } : plan
67}
68
69/** A span of time, the way the summary reads it: 45 秒, 3 分鐘, 1 小時 12 分. */
70export function spanOf(ms: number): string {
71 const s = Math.max(1, Math.round(ms / 1000))
72 if (s < 60) return `${s} 秒`
73 const m = Math.round(s / 60)
74 if (m < 60) return `${m} 分鐘`
75 return `${Math.floor(m / 60)} 小時${m % 60 ? ` ${m % 60} 分` : ''}`
76}
77
78/** The model's steps as the pane keeps them. */
79export function toSteps(raw: unknown): Step[] {
80 if (!Array.isArray(raw)) return []
81 return raw.flatMap((item, i): Step[] => {
82 if (typeof item !== 'object' || item === null) return []
83 const r = item as Record<string, unknown>
84 const id = typeof r.id === 'string' && r.id.trim() ? r.id.trim() : String(i + 1)
85 const status = STATUSES.includes(r.status as StepStatus) ? (r.status as StepStatus) : 'pending'
86 return [
87 {
88 id,
89 title: typeof r.title === 'string' ? r.title : id,
90 ...(typeof r.detail === 'string' && r.detail ? { detail: r.detail } : {}),
91 ...(typeof r.note === 'string' && r.note ? { note: r.note } : {}),
92 status,
93 children: toSteps(r.children),
94 },
95 ]
96 })
97}
98
99/** The new steps, each keeping when its status last changed, or stamped now. */
100export function carryChanges(steps: readonly Step[], before: readonly Step[], now: number): Step[] {
101 return walk(steps, s => {
102 const old = find(before, s.id)
103 const changedAt = old ? (old.status === s.status ? old.changedAt : now) : undefined
104 return changedAt === undefined ? s : { ...s, changedAt }
105 })
106}
107
108// ---- what the person does in the pane ----
109
110const STATUS_NAME: Record<StepStatus, string> = {
111 pending: '待辦',
112 active: '進行中',
113 done: '已完成',
114 skipped: '已跳過',
115 blocked: '受阻',
116}
117
118// 引用: the reference goes into the prompt box. Where the box refuses it (a
119// dialog holds it, a host draws none), the step joins the quoted list and the
120// next prompt carries it; a second press on a quoted step takes it off.
121async function quoteStep($: EngineInterface, step: Step) {
122 if ((await read($, quotedAtom)).includes(step.id)) return unquote($, step.id)
123 const filled = await $.prompt
124 .fill({ text: `[步驟 ${step.id}「${step.title}」] `, mode: 'insert' })
125 .catch(() => ({ isFilled: false }))
126 if (filled.isFilled) return
127 // No toast: the row's ❝ and the bar at the pane's foot say it, and a toast
128 // per press would pile up while several steps are quoted in a row.
129 await update($, quotedAtom, q => [...q, step.id])
130}
131
132async function unquote($: EngineInterface, id: string | null) {
133 await update($, quotedAtom, q => (id === null ? [] : q.filter(x => x !== id)))
134}
135
136/** The note the next prompt carries for the quoted steps, or null. */
137export function quoteNote(plan: Plan | null, ids: readonly string[]): string | null {
138 const steps = plan ? ids.flatMap(id => find(plan.steps, id) ?? []) : []
139 if (steps.length === 0) return null
140 return (
141 '[task-flow] 使用者在這則訊息中引用了目前計畫的步驟:\n' +
142 steps
143 .map(s => `- ${s.id}「${s.title}」(${STATUS_NAME[s.status]})${s.detail ? `:${s.detail}` : ''}`)
144 .join('\n')
145 )
146}
147
148// ---- drawing ----
149
150// The desktop breaks lines by its own estimate of the width, which counts a
151// wide (CJK) glyph as two cells while the font draws it a little wider, so
152// long lines spill past the pane. The pane folds them itself instead: wide
153// glyphs at a conservative width, Latin words kept whole, and no line opened
154// by closing punctuation.
155const WIDE = 2.3
156const CLOSERS = new Set([...',。、;:!?)」』】》〉,.;:!?)'])
157const isWide = (cp: number) =>
158 (cp >= 0x1100 && cp <= 0x115f) ||
159 (cp >= 0x2e80 && cp <= 0xa4cf) ||
160 (cp >= 0xac00 && cp <= 0xd7a3) ||
161 (cp >= 0xf900 && cp <= 0xfaff) ||
162 (cp >= 0xfe30 && cp <= 0xfe4f) ||
163 (cp >= 0xff00 && cp <= 0xff60) ||
164 (cp >= 0xffe0 && cp <= 0xffe6) ||
165 (cp >= 0x1f300 && cp <= 0x1faff) ||
166 (cp >= 0x20000 && cp <= 0x3fffd)
167const widthOf = (text: string) => [...text].reduce((n, ch) => n + (isWide(ch.codePointAt(0) ?? 0) ? WIDE : 1), 0)
168
169/** `text` broken into lines no wider than `cols` cells, by the estimate above. */
170export function fold(text: string, cols: number): string {
171 const lines: string[] = []
172 for (const paragraph of text.split('\n')) {
173 let line = ''
174 let width = 0
175 const flush = () => {
176 lines.push(line.trimEnd())
177 line = ''
178 width = 0
179 }
180 const put = (piece: string, w: number) => {
181 line += piece
182 width += w
183 }
184 for (const token of paragraph.match(/[A-Za-z0-9_./@%+#&=-]+|\s+|./gsu) ?? []) {
185 const w = widthOf(token)
186 if (/^\s+$/.test(token)) {
187 // A space ends a line rather than opening the next.
188 if (line && width + w <= cols) put(token, w)
189 else if (line) flush()
190 continue
191 }
192 if (width + w <= cols) put(token, w)
193 else if (CLOSERS.has(token) && line) put(token, w)
194 else if (w <= cols) {
195 if (line) flush()
196 put(token, w)
197 } else {
198 // A word wider than a line: cut it, glyph by glyph.
199 for (const ch of token) {
200 const cw = widthOf(ch)
201 if (width + cw > cols && line) flush()
202 put(ch, cw)
203 }
204 }
205 }
206 lines.push(line.trimEnd())
207 // No lone wide glyph on a last line: it takes one more from the line above.
208 const last = lines[lines.length - 1] ?? ''
209 const above = lines[lines.length - 2]
210 const [lone] = [...last]
211 const moved = above === undefined ? undefined : [...above].at(-1)
212 if (
213 above !== undefined &&
214 [...last].length === 1 &&
215 isWide(lone!.codePointAt(0) ?? 0) &&
216 moved !== undefined &&
217 isWide(moved.codePointAt(0) ?? 0) &&
218 !CLOSERS.has(moved) &&
219 [...above].length > 2
220 ) {
221 lines[lines.length - 2] = [...above].slice(0, -1).join('')
222 lines[lines.length - 1] = moved + last
223 }
224 }
225 return lines.join('\n')
226}
227
228const GLYPH: Record<StepStatus, string> = { pending: '○', active: '◉', done: '✓', skipped: '⊘', blocked: '!' }
229const COLOR: Record<StepStatus, string | undefined> = {
230 pending: undefined,
231 active: ACCENT,
232 done: DONE,
233 skipped: undefined,
234 blocked: '#f7768e',
235}
236// Row tints on the desktop: the running step, and any row under the pointer.
237const ACTIVE_BG = '#d977571c'
238const HOVER_BG = '#8080801a'
239const BAR = 24
240
241type Row = { step: Step; depth: number; lead: string; rest: string }
242
243// A step's hover group: its row heats it, its collapsed detail reveals in it.
244const groupOf = (id: string) => `task-flow:${id}`.slice(0, 64)
245
246// Each step with its depth and tree lines: `lead` before its own row, `rest`
247// before its detail beneath.
248function rowsOf(steps: readonly Step[], prefix = '', depth = 0): Row[] {
249 return steps.flatMap((step, i) => {
250 const isLast = i === steps.length - 1
251 const lead = prefix + (isLast ? '└─ ' : '├─ ')
252 const rest = prefix + (isLast ? ' ' : '│ ')
253 return [{ step, depth, lead, rest }, ...rowsOf(step.children, rest, depth + 1)]
254 })
255}
256
257/** One line of the desktop timeline: its slice of the spine, then its text. */
258export type TimelineLine = {
259 key: string
260 step: Step
261 depth: number
262 kind: 'title' | 'more' | 'detail' | 'gap'
263 text: string
264 rail: RailLine
265 /** A settled step's detail: folded away until the pointer is over the step. */
266 isFolded: boolean
267}
268
269/**
270 * The plan as the desktop's timeline: titles and details folded to the pane's
271 * `cols`, a gap before each top-level step, each line's slice of the spine,
272 * and the motion over stretches of it at `now`: light flowing down from a
273 * running step, green pouring down from one just finished.
274 */
275export function timelineLines(plan: Plan, cols: number, now: number): TimelineLine[] {
276 const rows = rowsOf(plan.steps)
277 const lines: TimelineLine[] = []
278 rows.forEach(({ step, depth }, i) => {
279 const up: Seg = i === 0 ? 'none' : segOf(rows[i - 1]!.step.status)
280 const down: Seg = i === rows.length - 1 ? 'none' : segOf(step.status)
281 if (depth === 0 && i > 0) {
282 lines.push({ key: `gap:${step.id}`, step, depth, kind: 'gap', text: '', rail: { up, down: up }, isFolded: false })
283 }
284 const since = step.changedAt === undefined ? Infinity : now - step.changedAt
285 const freshMs = step.status === 'done' && since >= 0 && since < FRESH_MS ? since : undefined
286 // Room for the 引用 button and a ❝ on the first line.
287 fold(`${step.id} ${step.title}`, Math.max(8, cols - railCells(depth) - 1 - 6))
288 .split('\n')
289 .forEach((text, k) =>
290 lines.push({
291 key: `t:${step.id}:${k}`,
292 step,
293 depth,
294 kind: k === 0 ? 'title' : 'more',
295 text,
296 rail:
297 k === 0
298 ? { up, down, node: { status: step.status, depth, ...(freshMs === undefined ? {} : { freshMs }) } }
299 : { up: down, down },
300 isFolded: false,
301 }),
302 )
303 const detail = [step.detail, step.note && `— ${step.note}`].filter(Boolean).join(' ')
304 const isSettled = step.status === 'done' || step.status === 'skipped'
305 if (detail) {
306 fold(detail, Math.max(8, cols - railCells(depth) - 1 - 3))
307 .split('\n')
308 .forEach((text, k) =>
309 lines.push({ key: `d:${step.id}:${k}`, step, depth, kind: 'detail', text, rail: { up: down, down }, isFolded: isSettled }),
310 )
311 }
312 })
313
314 // A stretch of spine: from a node's line down to the next node's, over the
315 // lines that show.
316 const shown = lines.filter(l => !l.isFolded)
317 const stretch = (at: number) => {
318 const out: { line: TimelineLine; range: Half }[] = [{ line: shown[at]!, range: 'down' }]
319 for (let j = at + 1; j < shown.length; j++) {
320 const l = shown[j]!
321 if (l.kind === 'title') {
322 out.push({ line: l, range: 'up' })
323 break
324 }
325 out.push({ line: l, range: 'both' })
326 }
327 return out
328 }
329 shown.forEach((l, at) => {
330 const node = l.rail.node
331 if (!node || l.rail.down === 'none') return
332 const since = l.step.changedAt === undefined ? Infinity : now - l.step.changedAt
333 if (node.status === 'active') {
334 const parts = stretch(at)
335 parts.forEach(({ line, range }, index) => {
336 line.rail = { ...line.rail, wave: { index, count: parts.length, range } }
337 })
338 } else if (node.status === 'done' && since >= 0 && since < SWEEP_MS) {
339 const parts = stretch(at)
340 parts.forEach(({ line, range }, index) => {
341 line.rail = { ...line.rail, sweep: { index, count: parts.length, range, elapsedMs: since } }
342 })
343 }
344 })
345 return lines
346}
347
348export const register: Register = on => {
349 on('session.start', async ($, e, next) => {
350 // A reload runs this again: a pane drawn by the module before it keeps its
351 // old tree, whose buttons point at that module's handlers and whose reads
352 // no longer redraw it. Draw it again with this one.
353 $.ui.invalidate('ui.render')
354 await $.command.register({ name: 'flow', description: '打開任務流程面板' })
355 await $.tool.register({
356 name: 'plan',
357 description:
358 'Shows the person your plan for the current request as a task tree in their side pane, replacing the one shown. ' +
359 'Call it before starting multi-step work, and again whenever the plan changes (keep ids of unchanged steps).',
360 inputSchema: {
361 type: 'object',
362 properties: {
363 goal: { type: 'string', description: 'The request, in one line, in the person\'s language' },
364 steps: { type: 'array', items: stepSchema(3) },
365 },
366 required: ['goal', 'steps'],
367 },
368 })
369 await $.tool.register({
370 name: 'step',
371 description: 'Updates one step of the plan shown in the task flow pane: its status, and optionally a short note.',
372 inputSchema: {
373 type: 'object',
374 properties: {
375 id: { type: 'string' },
376 status: { type: 'string', enum: STATUSES },
377 note: { type: 'string', description: 'What happened, when worth saying (why blocked, what was found)' },
378 },
379 required: ['id', 'status'],
380 },
381 })
382 return next(e)
383 })
384
385 on('prompt.compose', async ($, e, next) => {
386 const composed = await next(e)
387 return { sections: [...composed.sections, { id: 'task-flow:guide', text: GUIDE, scope: 'session' as const }] }
388 })
389
390 on('command.run', { command: 'flow' }, async $ => {
391 await $.ui.open({ id: PANE, title: TITLE, focus: true })
392 return { text: '任務流程面板已打開。' }
393 })
394
395 on('tool.call', { tool: PLAN_TOOL }, async ($, e) => {
396 const input = e as unknown as Record<string, unknown>
397 const before = await read($, planAtom)
398 const now = await $.clock.now()
399 const steps = carryChanges(toSteps(input.steps), before?.steps ?? [], now)
400 if (steps.length === 0) return { deny: 'steps must be a non-empty array of { id, title }' }
401 // A revision of unfinished work keeps its start; anything else starts now.
402 const startedAt = before && before.finishedAt === undefined ? (before.startedAt ?? before.updatedAt) : now
403 const plan = stamped({ goal: typeof input.goal === 'string' ? input.goal : '', steps, updatedAt: now, startedAt }, now)
404 await update($, planAtom, () => plan)
405 void $.ui.open({ id: PANE, title: TITLE })
406 $.ui.invalidate('ui.render')
407 return { result: `The plan (${flatten(steps).length} steps) is shown in the person's task flow pane.` }
408 })
409
410 on('tool.call', { tool: STEP_TOOL }, async ($, e) => {
411 const input = e as unknown as Record<string, unknown>
412 const id = String(input.id ?? '')
413 const status = input.status as StepStatus
414 if (!STATUSES.includes(status)) return { deny: `status must be one of ${STATUSES.join(', ')}` }
415 const plan = await read($, planAtom)
416 if (!plan || !find(plan.steps, id)) return { deny: `no step ${id} in the plan; call ${PLAN_TOOL} first` }
417 const note = typeof input.note === 'string' && input.note ? input.note : undefined
418 const now = await $.clock.now()
419 await update($, planAtom, p =>
420 p
421 ? stamped(
422 {
423 ...p,
424 steps: walk(p.steps, s =>
425 s.id === id
426 ? { ...s, status, ...(note ? { note } : {}), ...(s.status !== status ? { changedAt: now } : {}) }
427 : s,
428 ),
429 },
430 now,
431 )
432 : p,
433 )
434 $.ui.invalidate('ui.render')
435 return { result: `Step ${id} is now ${status}.` }
436 })
437
438 // Quoted steps ride the person's next prompt, as context only the model
439 // reads. The guide rides the conversation's first prompt too: where the system
440 // prompt's own sections ride the first message (the desktop app, the SDK),
441 // a resumed conversation would otherwise never read it.
442 on('prompt.submit', async ($, e, next) => {
443 const isOwn = e.origin?.kind === 'plugin' && e.origin.name === 'task-flow'
444 const quotedIds = isOwn ? [] : await read($, quotedAtom)
445 const quote = quotedIds.length > 0 ? quoteNote(await read($, planAtom), quotedIds) : null
446 const isGuided = await read($, guidedAtom)
447 if (quotedIds.length === 0 && isGuided) return next(e)
448 if (quotedIds.length > 0) await update($, quotedAtom, () => [])
449 if (!isGuided) await update($, guidedAtom, () => true)
450 return next({
451 ...e,
452 context: [...(e.context ?? []), ...(isGuided ? [] : [GUIDE]), ...(quote ? [quote] : [])],
453 })
454 })
455
456 // /clear starts the conversation over: the next prompt carries the guide again.
457 on('session.end', async ($, e, next) => {
458 if (e.reason === 'clear') await update($, guidedAtom, () => false)
459 return next(e)
460 })
461
462 // The ring's last figure, so the next draw sweeps from it.
463 let shownProgress = 0
464
465 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
466 const els = $.ui.resolve(e)
467 const { Box, Text, Button } = els
468 // The terminal draws no SVG: its tree stays in box-drawing lines.
469 const Svg = e.surface !== 'terminal' && 'Svg' in els ? els.Svg : undefined
470 const plan = await read($, planAtom)
471 const quoted = await read($, quotedAtom)
472
473 if (!plan) {
474 return (
475 <Box flexDirection="column" paddingY={1}>
476 <Text bold>還沒有流程</Text>
477 <Text dimColor>送出一個多步驟的需求,Claude 會先在這裡列出計畫;</Text>
478 <Text dimColor>之後可以把任何一步引用到你的訊息裡。</Text>
479 </Box>
480 )
481 }
482
483 const all = flatten(plan.steps)
484 const settled = all.filter(s => s.status === 'done' || s.status === 'skipped').length
485 const active = all.find(s => s.status === 'active')
486 const progress = all.length ? settled / all.length : 0
487 const from = shownProgress
488 shownProgress = progress
489 const skipped = all.filter(s => s.status === 'skipped').length
490 const isFinished = plan.finishedAt !== undefined
491 const took = isFinished ? plan.finishedAt! - (plan.startedAt ?? plan.updatedAt) : 0
492 const summary = isFinished
493 ? `完成 · 共 ${all.length} 步${skipped ? ` · 跳過 ${skipped}` : ''}${took > 0 ? ` · 用時 ${spanOf(took)}` : ''}`
494 : `${settled}/${all.length} 完成${active ? ' · 進行中:' : ''}`
495
496 const actions = (step: Step) => (
497 <Box flexDirection="row" flexShrink={0}>
498 <Button
499 key={`quote:${step.id}`}
500 label={quoted.includes(step.id) ? '已引用' : '引用'}
501 plain
502 dimColor={!quoted.includes(step.id)}
503 onPress={() => void quoteStep($, step)}
504 />
505 </Box>
506 )
507
508 // The detail beneath a step, after `indent`; the desktop wraps it whole,
509 // the terminal keeps its tree lines and cuts.
510 const beneath = (step: Step, indent: (key: string) => RenderElement, wrap: 'wrap' | 'truncate', cols?: number) => {
511 const shape = (text: string) => (cols === undefined ? text : fold(text, cols))
512 return [
513 // A settled step keeps only its title; its detail opens while the
514 // pointer is over the step's row (the row's hover group).
515 (step.detail || step.note) && (
516 <Box key={`detail:${step.id}`} flexDirection="column">
517 <Box
518 flexDirection="row"
519 {...(step.status === 'done' || step.status === 'skipped'
520 ? { display: 'none' as const, hover: { scope: groupOf(step.id), display: 'flex' as const } }
521 : {})}
522 >
523 {indent('d')}
524 <Box flexShrink={1} minWidth={0} paddingRight={wrap === 'wrap' ? 2 : 0}>
525 <Text dimColor wrap={wrap}>
526 {shape([step.detail, step.note && `— ${step.note}`].filter(Boolean).join(' '))}
527 </Text>
528 </Box>
529 </Box>
530 </Box>
531 ),
532 ]
533 }
534
535 // Steps waiting to ride the next prompt, in one line at the pane's foot:
536 // growing there moves none of the rows above, which the next press aims at.
537 // A step leaves it by its own 已引用 button.
538 const quotedSteps = quoted.flatMap(id => find(plan.steps, id) ?? [])
539 const quoteBar = quotedSteps.length > 0 && (
540 <Box flexDirection="row" marginTop={1}>
541 <Text color={ACCENT}>❝ </Text>
542 <Box flexGrow={1} flexShrink={1} minWidth={0}>
543 <Text wrap="truncate">
544 下一則訊息會引用:{quotedSteps.map(s => s.id).join('、')}
545 </Text>
546 </Box>
547 <Text> </Text>
548 <Button key="unquote-all" label="全部取消" plain dimColor onPress={() => void unquote($, null)} />
549 </Box>
550 )
551
552 // Desktop and the other drawn surfaces: a timeline down the pane's
553 // height. Every line is a row of its own: its slice of the spine (stretched
554 // to the row and clipped, so the slices join), then its text, folded here
555 // to the pane's width (the desktop's own wrapping runs wide on CJK).
556 if (Svg) {
557 const lines = timelineLines(plan, e.props.bodyColumns, await $.clock.now())
558
559 const rail = (line: TimelineLine) => {
560 const slice = railSvg(line.rail)
561 return (
562 <Box width={railCells(line.depth)} flexShrink={0} position="relative">
563 <Box position="absolute" top={0} left={0} right={0} bottom={0} alignItems="center" overflow="hidden">
564 <Svg
565 source={slice.source}
566 alt={slice.alt}
567 width={slice.width}
568 height={RAIL_H}
569 isInteractive={slice.isInteractive || undefined}
570 />
571 </Box>
572 </Box>
573 )
574 }
575
576 const row = (line: TimelineLine) => {
577 const step = line.step
578 const isActive = step.status === 'active'
579 const isQuiet = step.status === 'done' || step.status === 'skipped'
580 const titleStyle = {
581 bold: isActive,
582 color: isActive ? ACCENT : undefined,
583 dimColor: isQuiet,
584 strikethrough: step.status === 'skipped',
585 }
586 let content: RenderElement
587 if (line.kind === 'title') {
588 const hasId = line.text.startsWith(`${step.id} `)
589 content = (
590 <Box flexDirection="row" alignItems="center" flexGrow={1} flexShrink={1} minWidth={0}>
591 {hasId && <Text dimColor>{step.id} </Text>}
592 <Box flexShrink={1} minWidth={0}>
593 <Text {...titleStyle} wrap="wrap">
594 {hasId ? line.text.slice(step.id.length + 1) : line.text}
595 </Text>
596 </Box>
597 {quoted.includes(step.id) && <Text color={ACCENT}> ❝</Text>}
598 <Box flexGrow={1} />
599 {actions(step)}
600 </Box>
601 )
602 } else if (line.kind === 'more') {
603 content = (
604 <Text {...titleStyle} wrap="wrap">
605 {line.text}
606 </Text>
607 )
608 } else if (line.kind === 'detail') {
609 content = (
610 <Text dimColor wrap="wrap">
611 {line.text}
612 </Text>
613 )
614 } else {
615 content = <Text> </Text>
616 }
617 return (
618 <Box key={line.key} flexDirection="row">
619 {rail(line)}
620 <Box flexDirection="row" alignItems="center" marginLeft={1} flexGrow={1} flexShrink={1} minWidth={0}>
621 {content}
622 </Box>
623 </Box>
624 )
625 }
626
627 // A block per step: its own lines, tinted while it runs, lit under the
628 // pointer; a settled step's detail folded inside, opened in its group.
629 const blocks: RenderElement[] = []
630 for (let i = 0; i < lines.length; ) {
631 const line = lines[i]!
632 if (line.kind === 'gap') {
633 blocks.push(row(line))
634 i++
635 continue
636 }
637 const step = line.step
638 const own: TimelineLine[] = []
639 while (i < lines.length && lines[i]!.step === step && lines[i]!.kind !== 'gap') own.push(lines[i++]!)
640 const isActive = step.status === 'active'
641 const folded = own.filter(l => l.isFolded)
642 blocks.push(
643 <Box
644 key={`step:${step.id}`}
645 flexDirection="column"
646 backgroundColor={isActive ? ACTIVE_BG : undefined}
647 hover={{ scope: groupOf(step.id), backgroundColor: isActive ? ACTIVE_BG : HOVER_BG }}
648 >
649 {own.filter(l => !l.isFolded).map(row)}
650 {folded.length > 0 && (
651 <Box flexDirection="column" display="none" hover={{ scope: groupOf(step.id), display: 'flex' }}>
652 {folded.map(row)}
653 </Box>
654 )}
655 </Box>,
656 )
657 }
658
659 return (
660 <Box flexDirection="column">
661 <Box flexDirection="row" alignItems="center">
662 <Svg
663 key="ring"
664 source={progressRing(from, progress, Boolean(active))}
665 alt={`${Math.round(progress * 100)}%`}
666 width={RING}
667 height={RING}
668 isInteractive
669 />
670 <Box flexDirection="column" marginLeft={1} flexGrow={1} flexShrink={1} minWidth={0}>
671 <Text bold wrap="truncate">
672 {plan.goal || '目前的計畫'}
673 </Text>
674 {/* The running step is named by its id: its block below is lit. */}
675 <Text color={isFinished ? DONE : undefined} dimColor={!isFinished} wrap="truncate">
676 {isFinished ? summary : `${settled}/${all.length} 完成${active ? ` · 進行中 ${active.id}` : ''}`}
677 </Text>
678 </Box>
679 </Box>
680 <Box flexDirection="column" marginTop={1}>
681 {blocks}
682 </Box>
683 {quoteBar}
684 </Box>
685 )
686 }
687
688 // Terminal: the tree in box-drawing lines, a bar for progress.
689 const filled = Math.round(progress * BAR)
690 return (
691 <Box flexDirection="column">
692 <Text bold wrap="truncate">
693 {plan.goal || '目前的計畫'}
694 </Text>
695 <Box flexDirection="row">
696 <Text color={progress >= 1 ? DONE : ACCENT}>{'━'.repeat(filled)}</Text>
697 <Text dimColor>{'─'.repeat(BAR - filled)}</Text>
698 <Text dimColor> {Math.round(progress * 100)}%</Text>
699 </Box>
700 <Box flexDirection="row">
701 <Text color={isFinished ? DONE : undefined} dimColor={!isFinished}>{summary}</Text>
702 {active && (
703 <Text color={ACCENT} wrap="truncate">
704 {active.id} {active.title}
705 </Text>
706 )}
707 </Box>
708 <Text> </Text>
709 {rowsOf(plan.steps).map(({ step, lead, rest }) => {
710 const isActive = step.status === 'active'
711 const isQuiet = step.status === 'done' || step.status === 'skipped'
712 const indent = (key: string) => (
713 <Text key={`i${key}`} dimColor>
714 {rest}{' '}
715 </Text>
716 )
717 return (
718 <Box key={`step:${step.id}`} flexDirection="column">
719 <Box flexDirection="row" hover={{ scope: groupOf(step.id) }}>
720 <Text dimColor>{lead}</Text>
721 <Text color={COLOR[step.status]} dimColor={step.status === 'pending' || step.status === 'skipped'}>
722 {GLYPH[step.status]}{' '}
723 </Text>
724 <Box flexGrow={1} flexShrink={1} minWidth={0}>
725 <Text
726 bold={isActive}
727 color={isActive ? ACCENT : undefined}
728 dimColor={isQuiet}
729 strikethrough={step.status === 'skipped'}
730 wrap="truncate"
731 >
732 {step.id} {step.title}
733 </Text>
734 </Box>
735 {quoted.includes(step.id) && <Text color={ACCENT}>❝ </Text>}
736 {actions(step)}
737 </Box>
738 {beneath(step, indent, 'truncate')}
739 </Box>
740 )
741 })}
742 {quoteBar}
743 </Box>
744 )
745 })
746}
747hooks/icons.ts 101 lines1import type { StepStatus } from '../types'
2
3// The desktop pane's vector pieces: a status icon per step and the progress
4// ring of the header. Motion is SMIL, so it runs with no redraw.
5
6export const ACCENT = '#d97757'
7export const DONE = '#5fd38d'
8export const BLOCKED = '#f7768e'
9export const MUTED = '#9aa0a6'
10export const NOTE = '#e0af68'
11
12const n = (v: number) => String(Math.round(v * 100) / 100)
13const svg = (w: number, h: number, body: string) =>
14 `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">` +
15 '<style>:root{color-scheme:light dark}text{font:600 10px -apple-system,BlinkMacSystemFont,"SF Pro Text",system-ui,sans-serif;fill:#24292f}' +
16 '@media (prefers-color-scheme:dark){text{fill:#e6e6e6}}</style>' +
17 body +
18 '</svg>'
19
20export const ICON = 16
21
22/** A step's status as a 16px icon; only `active` moves (a turning arc). */
23export function statusIcon(status: StepStatus): string {
24 const c = ICON / 2
25 switch (status) {
26 case 'pending':
27 return svg(ICON, ICON, `<circle cx="${c}" cy="${c}" r="5.5" fill="none" stroke="${MUTED}" stroke-width="1.5"/>`)
28 case 'active':
29 return svg(
30 ICON,
31 ICON,
32 `<circle cx="${c}" cy="${c}" r="6" fill="none" stroke="${ACCENT}" stroke-opacity=".22" stroke-width="2"/>` +
33 `<path d="M${c} ${c - 6} A6 6 0 0 1 ${c + 6} ${c}" fill="none" stroke="${ACCENT}" stroke-width="2" stroke-linecap="round">` +
34 `<animateTransform attributeName="transform" type="rotate" from="0 ${c} ${c}" to="360 ${c} ${c}" dur="1s" repeatCount="indefinite"/></path>` +
35 `<circle cx="${c}" cy="${c}" r="2" fill="${ACCENT}">` +
36 '<animate attributeName="opacity" values="1;.35;1" dur="1.4s" repeatCount="indefinite"/></circle>',
37 )
38 case 'done':
39 return svg(
40 ICON,
41 ICON,
42 `<circle cx="${c}" cy="${c}" r="6.5" fill="${DONE}"/>` +
43 `<path d="M5 8.3 L7.1 10.4 L11 6.1" fill="none" stroke="#fff" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/>`,
44 )
45 case 'skipped':
46 return svg(
47 ICON,
48 ICON,
49 `<circle cx="${c}" cy="${c}" r="5.5" fill="none" stroke="${MUTED}" stroke-width="1.5"/>` +
50 `<path d="M4.4 11.6 L11.6 4.4" stroke="${MUTED}" stroke-width="1.5" stroke-linecap="round"/>`,
51 )
52 case 'blocked':
53 return svg(
54 ICON,
55 ICON,
56 `<circle cx="${c}" cy="${c}" r="6.5" fill="${BLOCKED}"/>` +
57 `<path d="M${c} 4.6 V9" stroke="#fff" stroke-width="1.8" stroke-linecap="round"/>` +
58 `<circle cx="${c}" cy="11.4" r="1.05" fill="#fff"/>`,
59 )
60 }
61}
62
63export const RING = 40
64
65/**
66 * The header's progress ring: the share of steps settled, sweeping from
67 * `from` to `to` (0..1) with the figure in its middle; green once complete.
68 */
69export function progressRing(from: number, to: number, active: boolean): string {
70 const c = RING / 2
71 const r = 15.5
72 const length = 2 * Math.PI * r
73 const offset = (k: number) => n(length * (1 - Math.max(0, Math.min(1, k))))
74 const isComplete = to >= 1
75 const stroke = isComplete ? DONE : 'url(#g)'
76 const sweep =
77 from === to
78 ? ''
79 : `<animate attributeName="stroke-dashoffset" from="${offset(from)}" to="${offset(to)}" dur=".9s" fill="freeze" ` +
80 'calcMode="spline" keyTimes="0;1" keySplines=".22 1 .36 1"/>'
81 // While a step runs, a faint halo turns around the ring.
82 const halo = active && !isComplete
83 ? `<circle cx="${c}" cy="${c}" r="${r + 3.5}" fill="none" stroke="${ACCENT}" stroke-opacity=".18" stroke-width="1" stroke-dasharray="3 5">` +
84 `<animateTransform attributeName="transform" type="rotate" from="0 ${c} ${c}" to="360 ${c} ${c}" dur="12s" repeatCount="indefinite"/></circle>`
85 : ''
86 return svg(
87 RING,
88 RING,
89 `<linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="${ACCENT}"/><stop offset="1" stop-color="#e8a33d"/></linearGradient>` +
90 halo +
91 `<circle cx="${c}" cy="${c}" r="${r}" fill="none" stroke="#808080" stroke-opacity=".2" stroke-width="3.5"/>` +
92 `<circle cx="${c}" cy="${c}" r="${r}" fill="none" stroke="${stroke}" stroke-width="3.5" stroke-linecap="round" ` +
93 `stroke-dasharray="${n(length)}" stroke-dashoffset="${offset(to)}" transform="rotate(-90 ${c} ${c})">${sweep}</circle>` +
94 // Complete, a check takes the middle: "100%" would crowd the ring.
95 (isComplete
96 ? `<path d="M${c - 5.5} ${c + 0.5} L${c - 1.5} ${c + 4.5} L${c + 6} ${c - 4}" fill="none" stroke="${DONE}" ` +
97 'stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>'
98 : `<text x="${c}" y="${c + 3.5}" text-anchor="middle">${Math.round(to * 100)}%</text>`),
99 )
100}
101hooks/rail.ts 211 lines1import type { StepStatus } from '../types'
2import { ACCENT, BLOCKED, DONE, MUTED } from './icons'
3
4// The desktop pane's timeline: one spine down the pane, every step a node on
5// it, sub-steps branching off it. Each line of the pane draws its own slice of
6// the spine as an SVG taller than any line; the line's box clips it to its own
7// height, centred, so the slices join into one rail whatever height a line
8// turns out to have. Motion is SMIL, inside each slice.
9
10/** A slice's height in px: taller than a line, clipped to it. */
11export const RAIL_H = 40
12const MID = RAIL_H / 2
13/** Half the height a line is expected to show; motion is timed to it. */
14const SEEN = 11
15/** The spine's x, and how far each level of sub-steps branches out. */
16const SPINE = 11
17const BRANCH = 17
18
19/** The cells a line's rail takes before its text, by the step's depth. */
20export const railCells = (depth: number) => 3 + depth * 2
21const nodeX = (depth: number) => SPINE + depth * BRANCH
22const radius = (depth: number) => (depth === 0 ? 6 : 4.2)
23
24/** How one stretch of spine reads: by the step it leaves. */
25export type Seg = 'none' | 'todo' | 'done' | 'flow' | 'skip'
26/** Which half of a line a stretch of motion covers. */
27export type Half = 'up' | 'down' | 'both'
28
29export type RailLine = {
30 /** The spine above the line's middle, and below it. */
31 up: Seg
32 down: Seg
33 /** The step's node, on its title's first line. */
34 node?: { status: StepStatus; depth: number; freshMs?: number }
35 /** A place in the running step's stretch: light flows down it, line by line. */
36 wave?: { index: number; count: number; range: Half }
37 /** A place in a just-finished step's stretch: green pours down it once. */
38 sweep?: { index: number; count: number; range: Half; elapsedMs: number }
39}
40
41/** How long a finished node pops and draws its check, and green pours down. */
42export const FRESH_MS = 900
43export const SWEEP_MS = 800
44
45export const segOf = (status: StepStatus): Seg =>
46 status === 'done' ? 'done' : status === 'skipped' ? 'skip' : status === 'active' ? 'flow' : 'todo'
47
48const n = (v: number) => String(Math.round(v * 100) / 100)
49const s = (ms: number) => `${n(ms / 1000)}s`
50
51function piece(y1: number, y2: number, seg: Seg): string {
52 if (seg === 'none' || y2 - y1 < 0.5) return ''
53 const at = `x1="${SPINE}" x2="${SPINE}" y1="${n(y1)}" y2="${n(y2)}" stroke-width="2"`
54 switch (seg) {
55 case 'done':
56 return `<line ${at} stroke="${DONE}"/>`
57 case 'flow':
58 return `<line ${at} stroke="${ACCENT}" stroke-opacity=".35"/>`
59 case 'skip':
60 return `<line ${at} stroke="${MUTED}" stroke-opacity=".5" stroke-dasharray="2 3"/>`
61 default:
62 return `<line ${at} stroke="${MUTED}" stroke-opacity=".35"/>`
63 }
64}
65
66// keyTimes for a window [a, b] of a cycle, without repeated stops at its ends.
67function windowed(a: number, b: number, before: number, from: number, to: number, after: number) {
68 const times: number[] = [0]
69 const values: number[] = [before]
70 if (a > 0) {
71 times.push(a)
72 values.push(from)
73 } else values[0] = from
74 times.push(b)
75 values.push(to)
76 if (b < 1) {
77 times.push(1)
78 values.push(after)
79 }
80 return { keyTimes: times.map(t => n(t)).join(';'), values: values.map(v => n(v)).join(';') }
81}
82
83function nodeSvg(status: StepStatus, depth: number, freshMs?: number): string {
84 const cx = nodeX(depth)
85 const r = radius(depth)
86 const at = `cx="${n(cx)}" cy="${MID}"`
87 switch (status) {
88 case 'pending':
89 return `<circle ${at} r="${n(r)}" fill="none" stroke="${MUTED}" stroke-width="1.6"/>`
90 case 'active':
91 // A ripple leaving the node, a breathing core.
92 return (
93 `<circle ${at} r="${n(r)}" fill="none" stroke="${ACCENT}" stroke-width="1.4" opacity="0">` +
94 `<animate attributeName="r" from="${n(r)}" to="${n(r + 4)}" dur="1.8s" repeatCount="indefinite"/>` +
95 '<animate attributeName="opacity" values=".7;0" dur="1.8s" repeatCount="indefinite"/></circle>' +
96 `<circle ${at} r="${n(r)}" fill="none" stroke="${ACCENT}" stroke-width="2"/>` +
97 `<circle ${at} r="${n(r * 0.42)}" fill="${ACCENT}">` +
98 '<animate attributeName="opacity" values="1;.35;1" dur="1.4s" repeatCount="indefinite"/></circle>'
99 )
100 case 'done': {
101 const check =
102 `M${n(cx - 0.45 * r)} ${n(MID + 0.05 * r)} L${n(cx - 0.1 * r)} ${n(MID + 0.4 * r)} L${n(cx + 0.47 * r)} ${n(MID - 0.33 * r)}`
103 if (freshMs === undefined) {
104 return (
105 `<circle ${at} r="${n(r)}" fill="${DONE}"/>` +
106 `<path d="${check}" fill="none" stroke="#fff" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>`
107 )
108 }
109 // Just finished: the node pops, a ring flashes out, the check draws in.
110 // Begun in the past by what has elapsed, so a redraw carries on.
111 const ago = -freshMs
112 return (
113 `<circle ${at} r="${n(r)}" fill="none" stroke="${DONE}" stroke-width="1.5" opacity="0">` +
114 `<animate attributeName="r" from="${n(r)}" to="${n(r + 4.5)}" dur=".6s" begin="${s(ago)}" fill="freeze"/>` +
115 `<animate attributeName="opacity" values=".8;0" dur=".6s" begin="${s(ago)}" fill="freeze"/></circle>` +
116 `<circle ${at} r="${n(r)}" fill="${DONE}">` +
117 `<animate attributeName="r" values="0;${n(r * 1.3)};${n(r)}" keyTimes="0;.6;1" dur=".45s" begin="${s(ago)}" fill="freeze"/></circle>` +
118 `<path d="${check}" fill="none" stroke="#fff" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" ` +
119 'pathLength="1" stroke-dasharray="1" stroke-dashoffset="1">' +
120 `<animate attributeName="stroke-dashoffset" from="1" to="0" dur=".35s" begin="${s(ago + 250)}" fill="freeze"/></path>`
121 )
122 }
123 case 'skipped':
124 return (
125 `<circle ${at} r="${n(r)}" fill="none" stroke="${MUTED}" stroke-width="1.4" stroke-dasharray="2 1.6"/>` +
126 `<path d="M${n(cx - 0.55 * r)} ${n(MID + 0.55 * r)} L${n(cx + 0.55 * r)} ${n(MID - 0.55 * r)}" stroke="${MUTED}" stroke-width="1.4" stroke-linecap="round"/>`
127 )
128 case 'blocked':
129 // A slow red halo: the step waits on something.
130 return (
131 `<circle ${at} r="${n(r + 3)}" fill="${BLOCKED}" opacity="0">` +
132 '<animate attributeName="opacity" values="0;.28;0" dur="2.2s" repeatCount="indefinite"/></circle>' +
133 `<circle ${at} r="${n(r)}" fill="${BLOCKED}"/>` +
134 `<path d="M${n(cx)} ${n(MID - 0.5 * r)} V${n(MID + 0.12 * r)}" stroke="#fff" stroke-width="1.6" stroke-linecap="round"/>` +
135 `<circle cx="${n(cx)}" cy="${n(MID + 0.45 * r)}" r=".95" fill="#fff"/>`
136 )
137 }
138}
139
140/** What a slice says to a reader that cannot see it; never empty. */
141export const SPINE_ALT = '時間軸'
142
143/** One line's slice of the timeline, and whether it moves (and so needs a live frame). */
144export function railSvg(line: RailLine): { source: string; width: number; alt: string; isInteractive: boolean } {
145 const node = line.node
146 const depth = node?.depth ?? 0
147 const r = node ? radius(node.depth) : 0
148 const width = node ? Math.max(SPINE * 2, nodeX(depth) + r + 6) : SPINE * 2
149 // A node on the spine itself parts it; a sub-step's node sits off to the side.
150 const gap = node && node.depth === 0 ? r + 1.5 : 0
151 const range = (half: Half): [number, number] =>
152 half === 'up' ? [0, MID - gap] : half === 'down' ? [MID + gap, RAIL_H] : [0, RAIL_H]
153
154 // Where green is about to pour, the spine starts grey beneath it.
155 const sweep = line.sweep && line.sweep.elapsedMs < SWEEP_MS ? line.sweep : undefined
156 const under = (half: 'up' | 'down', seg: Seg): Seg =>
157 sweep && seg === 'done' && (sweep.range === half || sweep.range === 'both') ? 'todo' : seg
158 let body = piece(0, MID - gap, under('up', line.up)) + piece(MID + gap, RAIL_H, under('down', line.down))
159
160 if (node && node.depth > 0) {
161 const stroke = node.status === 'done' ? DONE : node.status === 'active' ? ACCENT : MUTED
162 const opacity = node.status === 'done' || node.status === 'active' ? '1' : '.45'
163 body +=
164 `<path d="M${SPINE} ${MID - 5} Q${SPINE} ${MID} ${SPINE + 5} ${MID} H${n(nodeX(depth) - r - 1)}" fill="none" ` +
165 `stroke="${stroke}" stroke-opacity="${opacity}" stroke-width="1.5"/>`
166 }
167
168 if (line.wave) {
169 // A band of light, passing through this line in its share of the cycle.
170 const { index, count, range: half } = line.wave
171 const [y1, y2] = range(half)
172 const band = 14
173 const cycle = Math.max(1.4, count * 0.34)
174 const from = Math.max(y1, MID - SEEN) - band
175 const to = Math.min(y2, MID + SEEN)
176 const k = windowed(index / count, (index + 1) / count, from, from, to, to)
177 body +=
178 '<linearGradient id="w" x1="0" y1="0" x2="0" y2="1">' +
179 `<stop offset="0" stop-color="${ACCENT}" stop-opacity="0"/><stop offset=".55" stop-color="${ACCENT}"/>` +
180 `<stop offset="1" stop-color="${ACCENT}" stop-opacity="0"/></linearGradient>` +
181 `<clipPath id="wc"><rect x="0" y="${n(y1)}" width="${width}" height="${n(y2 - y1)}"/></clipPath>` +
182 `<rect x="${SPINE - 1.5}" y="${n(from)}" width="3" height="${band}" rx="1.5" fill="url(#w)" clip-path="url(#wc)">` +
183 `<animate attributeName="y" values="${k.values}" keyTimes="${k.keyTimes}" dur="${n(cycle)}s" repeatCount="indefinite"/></rect>`
184 }
185
186 if (sweep) {
187 // Green pouring down, in this line's share of the pour.
188 const [y1, y2] = range(sweep.range)
189 const k = windowed(sweep.index / sweep.count, (sweep.index + 1) / sweep.count, y1, y1, y2, y2)
190 body +=
191 `<line x1="${SPINE}" x2="${SPINE}" y1="${n(y1)}" y2="${n(y1)}" stroke="${DONE}" stroke-width="2">` +
192 `<animate attributeName="y2" values="${k.values}" keyTimes="${k.keyTimes}" dur="${s(SWEEP_MS)}" ` +
193 `begin="${s(-sweep.elapsedMs)}" fill="freeze"/></line>`
194 }
195
196 if (node) body += nodeSvg(node.status, node.depth, node.freshMs)
197
198 const isInteractive = Boolean(
199 line.wave ||
200 sweep ||
201 (node && (node.status === 'active' || node.status === 'blocked' || node.freshMs !== undefined)),
202 )
203 const source =
204 `<svg xmlns="http://www.w3.org/2000/svg" width="${n(width)}" height="${RAIL_H}" viewBox="0 0 ${n(width)} ${RAIL_H}">` +
205 '<style>:root{color-scheme:light dark}</style>' +
206 body +
207 '</svg>'
208 // Never an empty alt: the desktop draws nothing for a slice that has one.
209 return { source, width, alt: node?.status ?? SPINE_ALT, isInteractive }
210}
211types/index.d.ts 36 lines1export type StepStatus = 'pending' | 'active' | 'done' | 'skipped' | 'blocked'
2
3export type Step = {
4 id: string
5 title: string
6 detail?: string
7 /** The model's note on the step's outcome (why it is blocked, what it found). */
8 note?: string
9 status: StepStatus
10 children: Step[]
11 /** When the status last changed, so a step just finished animates once. */
12 changedAt?: number
13}
14
15export type Plan = {
16 goal: string
17 steps: Step[]
18 updatedAt: number
19 /** When this plan's work began: its first publish, kept across revisions. */
20 startedAt?: number
21 /** When every step settled (done or skipped); cleared if one reopens. */
22 finishedAt?: number
23}
24
25declare module 'claude-code' {
26 interface PluginState {
27 'task-flow': {
28 plan: Plan | null
29 /** The guide has ridden a prompt of this conversation already. */
30 guided: boolean
31 /** Steps quoted where the prompt box could not take the quote; the next prompt carries them. */
32 quoted: string[]
33 }
34 }
35}
36