SLOPSHOPPER

task-flow

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

newpaneguardcommandprompttool
v0.1.0no licenseupdated 2026-10-07Chinsin/claude-code-mods/task-flow
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · task-flow
│ ┃ 任務流程 ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ 還沒有流程 ⏺ Read(src/auth.ts) │ ┃ 送出一個多步驟的需求,Claude ⎿ Read 6 lines │ ┃ 會先在這裡列出計畫; ⏺ Update(src/auth.ts) │ ┃ 之後可以把任何一步引用到你的訊息裡。 ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /flow │ ⎿ task-flow: 任務流程面板已打開。 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 任務流程
還沒有流程 送出一個多步驟的需求,Claude 會先在這裡列出計畫; 之後可以把任何一步引用到你的訊息裡。
README

claude-code-mods

兩個 Claude Code 擴展(function hooks 模組),終端和 Claude 桌面版(Code 分頁)都能用:

  • cc-pulse:輸入框上方的動畫狀態列,顯示用量、額度、花費,以及提示緩存還剩多久過期
  • task-flow:側邊面板,把 Claude 的計畫畫成縱向時間軸,可以引用任何一步來提出修改

Claude Code 的擴展 API 目前是搶先體驗版,版本更新時可能改變。這兩個擴展在 Claude Code 2.1.289 上開發與測試。

cc-pulse:用量與緩存狀態列

<img alt="cc-pulse:輸入框上方的狀態列,滑鼠移到 cache 上時在下方展開明細" src="docs/cc-pulse-light.svg">

<sub>圖片是會動的 SVG,用擴展本身的繪製程式和範例資料產生:緩存倒數逐秒跳動,進度條上有光澤掃過。</sub>

輸入框上方的一行:

項目內容
ctx上下文已用的百分比
5h / 7d訂閱方案的 5 小時與 7 天額度;用量超過 80% 時一併顯示重置時間
cache提示緩存剩下的時間。Claude 工作時顯示 live,過期後顯示 cold
$這個會話目前的花費
  • 滑鼠移到任一項上,下方會展開明細,例如「5-hour window · 4% used · resets at 18:33」。
  • 數字變化有過渡動畫。桌面版用 SVG 繪製:進度條平滑變化,緩存倒數像里程表一樣逐秒滾動,最後一分鐘會閃爍。

保溫開關:cache 旁邊的「保溫」開關,預設關閉。打開後,閒置時會在緩存過期前約 30 秒送出一個極短的請求,讓緩存重新計時,每段閒置最多 3 次;你送出下一則訊息後次數歸零。每次保溫都會以緩存讀取的價格把整段上下文計費一次,上下文越大越貴。

指令:/pulse 顯示或隱藏狀態列。

設定放在 ~/.claude/settings.json 的 pluginConfigs:

欄位預設值說明
cacheTtlauto緩存有效期:auto(自動推測)、5m、1h
warnBeforeExpirytrue緩存過期前一分鐘跳出提示
keepWarmfalse新會話是否一開始就打開保溫
keepWarmMax3每段閒置最多保溫幾次
{
  "pluginConfigs": {
    "cc-pulse": { "options": { "cacheTtl": "1h" } }
  }
}

task-flow:任務流程面板

<img alt="task-flow:側邊面板的縱向時間軸,步驟 3 進行中、步驟 4 受阻、步驟 5 已引用" src="docs/task-flow-light.svg">

Claude 接到多步驟的需求時,會先在側邊面板畫出計畫,做的過程中逐步更新每一步的狀態。

  • 縱向時間軸(桌面版):所有步驟掛在同一條線上,子步驟從主線分枝。線的顏色就是進度:完成的段落綠色、進行中橘色、還沒走到灰色、跳過的是虛線。
  • 動效:進行中的節點不斷擴散漣漪,一道光沿著線往下流;剛完成的步驟節點彈出、打勾,綠色往下灌到下一步;受阻的步驟有紅色光暈。
  • 標題區:進度環,全部完成時顯示「完成 · 共 N 步 · 用時…」。
  • 引用:每一步右側的「引用」會把 [步驟 2.1「…」] 插進輸入框,接著寫你想怎麼改。寫不進輸入框時,改成附在你的下一則訊息裡一起送出。
  • 終端版用樹狀線和彩色符號顯示。

指令:/flow 打開面板。

Claude 透過擴展註冊的兩個工具 mcp__task-flow__plan、mcp__task-flow__step 畫出和更新流程;擴展會在對話的第一則訊息附上使用說明,Claude 不需要額外設定。

安裝

  1. 下載到 ~/.claude/mods:
   git clone https://github.com/Chinsin/claude-code-mods.git ~/.claude/mods
  1. 在 ~/.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 讓擴展的檔案存檔後自動重新載入,不需要的話可以省略。

  1. 完全退出 Claude 再重新打開(終端版則重新啟動 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 <擴展資料夾> 做型別檢查。這個資料夾每次載入都會重新產生,所以沒有放進倉庫。

Source 4 files
hooks/register.tsx 747 lines
1import { 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}
747
hooks/icons.ts 101 lines
1import 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}
101
hooks/rail.ts 211 lines
1import 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}
211
types/index.d.ts 36 lines
1export 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