长任务进度行:一轮跑满 3 分钟时让模型估一次剩余步骤,之后按工具调用推进状态栏里的步骤、进度条和剩余时间

三个 Claude Code mod:截图面板 shot-view、长任务进度 task-eta、按目录精简 MCP 说明 ctx-slim。三个 mod 各在输入框上方画一行,颜色分别是蓝、紫、绿,不再用 Claude Code 自带的橙色 ⚠ 状态行。

77 秒介绍视频(点图下载 mp4,13.5 MB):
mod 是 Claude Code v2.1.287 引入的一种插件:插件里放一个 JavaScript / TypeScript 模块,注册一组事件处理函数,在 Claude Code 进程里运行。它能在输入框上方的条带和侧边面板里画界面、注册斜杠命令、在工具调用前后插一手;其中画界面只有 mod 能做,settings 里的 hook、skill 和 MCP 都不行。v2.1.287 起默认开启。官方文档:<https://code.claude.com/docs/en/plugins/mods/overview>
需要 Claude Code 2.1.287 或更新版本。
claude plugin marketplace add Bearisbug/cc-mods
claude plugin install shot-view@cc-mods
claude plugin install task-eta@cc-mods
claude plugin install ctx-slim@cc-mods
已经开着的会话要运行 /reload-plugins 或新开会话才会加载。
两个面板也可以用快捷键打开。在 ~/.claude/keybindings.json 里加上(command:<名字> 等于输入对应的斜杠命令,只能放在 Chat 上下文):
{
"bindings": [
{
"context": "Chat",
"bindings": { "ctrl+x s": "command:shots", "ctrl+x p": "command:steps" }
}
]
}
不配快捷键时,输入 /shots、/steps 效果相同。

xcrun simctl io booted screenshot、Playwright 截图),这张图就进入列表,最多保留最近 60 张。.png。/shots(ctrl+x s)打开右侧面板,Claude 正在工作时也能立刻打开。面板只显示未读的截图,打开时停在最新一张,顶行写着当前是第几张未读、已读几张。n 下一张(更早的),p 上一张(更新的),r 标为已读(看过、不用再看时按,只是翻过不算),u 撤销最近一次已读并跳回那一张,按住 o 在面板里放大当前这张、松开收起,轻按 o 用 macOS 快速查看打开,f 在 Finder 里显示,y 复制路径;Esc 或再按一次 ctrl+x s 关闭,面板开着时 Esc 只关面板,不会中断 Claude。o 是靠终端在按住时连续补发的重复按键来识别的,mod 收不到「松开」事件。有的终端按住字母时会弹出 macOS 的重音字符菜单,不补发重复按键,这时按住会被当成轻按,打开快速查看;对这个终端运行 defaults write <终端的 bundle id> ApplePressAndHoldEnabled -bool false 再重开,就会改回按住连发。面板内放大受面板宽度限制:横屏截图放大后几乎一样大,竖长的手机截图会明显变大;想看大图就轻按 o。~/.claude/settings.json 的 env 里加 "CLAUDE_CODE_FORCE_TERMINAL_IMAGES": "1"。终端不支持时面板只显示文件名,轻按 o 用快速查看看。sips,快速查看靠 qlmanage,在 Finder 里显示靠 open。
/steps(ctrl+x p)展开「任务步骤」面板:每一步用 ✓ / ▶ / ○ 标状态,「–」表示 Claude 核对后认为这一步后来不需要了;每一步写着实际用时、预计用时和调用次数,当前步骤下面显示 Claude 正在执行的命令。面板里按 c 让 Claude 重新核对;一轮已经结束、清单还没完成时,按 d 把整份清单标为完成。/clear 会清掉清单。$.model.fork 发请求,每轮最多 6 次;一轮结束时的核对不受这个次数限制,失败后隔 5 秒、10 秒各重试一次。请求复用会话的 prompt cache,但仍然计入你的用量。
prompt.attachment 上拦下 MCP 说明,按你配的规则去掉当前目录用不上的服务器那一节;工具本身照常可用。规则可以在 /config 里的「MCP 说明保留规则」填一行,或者运行 /plugin configure ctx-slim@cc-mods,也可以直接写进 ~/.claude/settings.json:
{
"pluginConfigs": {
"ctx-slim@cc-mods": {
"options": {
"rules": "kando: path~kando; synco: text=<!-- synco:project-context:start | path~synco; shadcn-io: file=package.json"
}
}
}
}
规则格式是 服务器: 条件 | 条件; 服务器: 条件,多条规则用分号或换行隔开:
| 写法 | 含义 |
|---|---|
| 服务器名 | MCP 说明里 ## 后面的名字,不区分大小写 |
path~子串 | 当前目录路径里包含这个子串,不区分大小写 |
file=文件名 | 从当前目录往上能找到这个文件;当前目录在 HOME 下时只找到 HOME 为止 |
text=文字 | 当前目录及上级目录的 CLAUDE.md 或 AGENTS.md 里含有这段文字 |
列出的服务器只要有一个条件成立就保留它的说明,否则省略;没列出的服务器一律保留。条件里不能出现 ; 和 |,写错的部分会被跳过,不会因此误删说明。
上面那段示例是作者自己的规则:kando 的说明只在路径含 kando 时保留,synco 的说明在 CLAUDE.md 里有 Synco 的 project-context 标记、或路径含 synco 时保留,shadcn-io 的说明只在能找到 package.json 的前端项目里保留。
mod 以你的用户权限在 Claude Code 进程里运行,不在沙箱里。装之前可以对插件目录运行 claude plugin validate <目录>,它不执行代码,只列出 mod 处理哪些事件、调用哪些接口:
| mod | 处理的事件 | 调用的接口 |
|---|---|---|
| shot-view | tool.call、command.run、ui.render | $.process.run(只运行 sips、qlmanage、open)、$.fs.stat、$.clock.*、$.ui.*、$.state.* |
| task-eta | turn.start、tool.call、turn.complete、command.run、session.end、ui.render | $.model.fork、$.clock.*、$.ui.* |
| ctx-slim | prompt.attachment、ui.render(没配规则时不注册任何钩子) | $.fs.exists、$.fs.ancestors、$.env.get、$.ui.*、$.state.* |
claude plugin validate shot-view # 静态检查
claude plugin test shot-view # 跑 tests/ 里的测试
claude --plugin-dir ./shot-view # 只在这一个会话里加载,存盘自动重载
brand/promo/intro/ 是介绍视频的源码(HyperFrames 0.8.96 + GSAP),分镜在 storyboard.md,录屏数据在 assets/term.js。配乐文件不在仓库里,先用 bash tools/cut_music.sh <原曲> 从原曲剪出 assets/audio.wav,再在该目录运行 npm ci、npx hyperframes render . -o renders/cc-mods-intro.mp4 --strict 重新出片。
README 和视频里的终端画面来自真实会话的录屏数据;shot-view 面板里的图片区域是后期合成的,因为录屏用的终端模拟器不支持 kitty 图形协议。视频里的计数器 mod 是官方文档的示例,拦截 rm -rf 的是官方示例 blast-radius。配乐:《栖谷来信》(Letter from an old friend),制造木屋/陈越龙,版权归原作者,不在本仓库的 MIT 许可之内。
hooks/register.tsx 797 lines1import type { EngineInterface, Register, Timer } from 'claude-code'
2
3type Api = EngineInterface
4type Step = { title: string; signals: string[]; minutes: number }
5type Item = Step & {
6 // skipped:Claude 核对说这一步后来不需要了
7 status: 'done' | 'skipped' | 'active' | 'todo'
8 startedAt: number
9 endedAt: number
10 tools: number
11 // 打勾时没有观察到它的起止(由 Claude 核对确认完成),不显示用时
12 isUntimed: boolean
13}
14// paused=没做完就停了;waiting=停下来等你回复或等后台任务;error=API 出错或拒答;unchecked=收尾核对试了 3 次都没成功
15type Ending = 'running' | 'aborted' | 'error' | 'waiting' | 'paused' | 'unchecked' | 'done'
16type Outcome = 'finished' | 'waiting' | 'unfinished'
17// 一个任务可以跨好几轮:被中断、或停下来问你之后,下一条消息接着同一份清单
18type Task = {
19 text: string
20 activeMs: number
21 segmentStart: number
22 pausedAt: number
23 ending: Ending
24 tools: number
25 items: Item[]
26 isCurrentHit: boolean
27 activity: string
28 checkedAt: number
29 calls: number
30 firstEstimate: number
31 isBusy: boolean
32 isAsked: boolean
33 isWaitingFirstReply: boolean
34 // 正常收尾后等 Claude 核对的那几秒,显示「正在核对」而不是「未完成」
35 isFinalChecking: boolean
36 // 这一轮 Claude 最后的回复,收尾核对和面板里按 c 重新核对时用
37 lastAnswer: string
38 // 这一轮执行过的工具调用(一行一条),估算时交给 Claude,免得把做完的步骤再列一遍
39 trail: string[]
40 timer: Timer | null
41}
42type Check = { isSameTask: boolean; outcome: Outcome | null; done: number[]; skip: number[]; current: number; add: Step[] }
43
44const PLAN_AFTER_MS = 3 * 60_000
45const CHECK_GAP_MS = 3 * 60_000
46const TICK_MS = 15_000
47const MAX_MODEL_CALLS = 6
48const MAX_ITEMS = 10
49const BAND_AFTER_MS = 60_000
50const MINUTE = 60_000
51const TOGGLE_KEY = 'ctrl+x p'
52const PANE = 'steps'
53
54// 所有步骤都可能出现的通用命令名,当关键词用会让每条命令都把清单往前推一格
55const GENERIC_SIGNALS = new Set(['sleep', 'bash', 'sh', 'echo', 'cd', 'ls', 'cat', 'pwd', 'date', 'true', 'cp', 'mv', 'rm', 'mkdir', 'grep', 'find', 'sed', 'awk', 'git', 'npm', 'npx', 'node', 'python', 'python3', 'curl', 'read', 'edit', 'write'])
56
57export function planPrompt(trail: string[]): string {
58 const done = trail.length === 0 ? '(还没有执行任何工具调用)' : trail.map((c, i) => `${i + 1}. ${c}`).join('\n')
59 return `这是一个旁路问题:只回答,不要调用任何工具。
60这一轮到目前为止已经执行过的工具调用(按顺序):
61${done}
62
63对照用户最近一条消息交代的任务和上面已经执行过的调用,估计完成这个任务还剩哪些步骤(正在执行的那一步算第一步;上面已经执行完的步骤不要再列)。只输出一个 JSON 对象,不要任何别的文字:
64{"steps":[{"title":"不超过 14 个字的步骤名","signals":["做这一步时会出现在命令、文件路径或工具名里的 1 到 3 个关键词"],"minutes":预计分钟数}]}
65关键词要能把这一步和别的步骤区分开:不要用 sleep、cd、ls、echo、cat、git、npm 这类每一步都可能出现的通用命令名。
66minutes 按命令的真实耗时估,几十秒的步骤写 0.5 这样的小数。最多 8 步,按执行顺序。`
67}
68
69let task: Task | null = null
70let bandUntil = 0
71let bandTimer: Timer | null = null
72
73export function parseSteps(raw: unknown): Step[] {
74 if (!Array.isArray(raw)) return []
75 return raw
76 .flatMap(s => {
77 const step = s as Partial<Step>
78 if (typeof step.title !== 'string' || typeof step.minutes !== 'number') return []
79 const signals = (Array.isArray(step.signals) ? step.signals : [])
80 .filter((x): x is string => typeof x === 'string' && x.trim().length >= 2)
81 .map(x => x.trim().toLowerCase())
82 .filter(x => !GENERIC_SIGNALS.has(x))
83 return [{ title: step.title.trim().slice(0, 24), signals, minutes: Math.max(0.5, step.minutes) }]
84 })
85 .slice(0, 8)
86}
87
88function jsonOf(text: string): Record<string, unknown> | null {
89 const json = /\{[\s\S]*\}/.exec(text)?.[0]
90 if (!json) return null
91 try {
92 return JSON.parse(json) as Record<string, unknown>
93 } catch {
94 return null
95 }
96}
97
98export function parsePlan(text: string): Step[] {
99 return parseSteps(jsonOf(text)?.steps)
100}
101
102export function checkPrompt(items: Item[], context: { newText?: string; answer?: string } = {}): string {
103 const list = items.map((s, i) => `${i + 1}. ${s.title}`).join('\n')
104 const latest =
105 context.newText === undefined
106 ? ''
107 : `\n用户刚发来的新消息是:「${context.newText}」。如果它是在接着或调整这份清单的任务,same_task 为 true;如果换成了另一件事,same_task 为 false。\n`
108 const isFinal = context.answer !== undefined
109 const ended = isFinal
110 ? `\n这一轮已经结束,你最后的回复是:「${(context.answer ?? '').slice(0, 400)}」。
111outcome 是用户交代的整件事现在的状态:finished=已经做完(清单里有的步骤后来没做、也不需要了,照样算 finished);waiting=停下来等用户回复、确认或操作,或者在等后台任务跑完;unfinished=没做完就停了(出错、放弃、只做了一半)。\n`
112 : ''
113 return `这是一个旁路问题:只回答,不要调用任何工具。
114这一轮任务的步骤清单如下(编号从 1 开始):
115${list}
116${latest}${ended}
117对照你到目前为止实际做过的工具调用和它们的结果,核对每一步:这一步要做的命令已经执行完、没有报错,就算完成;没有输出的命令(如 sleep、等待)执行完也算完成;还没执行或报错了的不算。只输出一个 JSON 对象,不要任何别的文字:
118{"same_task":true,${isFinal ? '"outcome":"finished",' : ''}"done":[已完成的编号],"skip":[后来不需要做的编号],"current":正在做或下一步要做的编号,"add":[{"title":"不超过 14 个字","signals":["1 到 3 个关键词"],"minutes":预计分钟数}]}
119skip 只放确定不用做了的步骤;add 只列清单里没有、但完成任务还必须做的步骤;没有就给空数组。`
120}
121
122// 模型偶尔把编号写成字符串,或直接写步骤名:都按编号收下
123function toIndex(v: unknown, titles: string[]): number {
124 if (typeof v === 'number' && Number.isInteger(v)) return v
125 if (typeof v !== 'string') return 0
126 const n = Number(v.trim())
127 if (Number.isInteger(n) && n > 0) return n
128 return titles.indexOf(v.trim()) + 1
129}
130
131export function parseCheck(text: string, titles: string[] = []): Check | null {
132 const raw = jsonOf(text)
133 if (!raw) return null
134 const indexes = (v: unknown) => (Array.isArray(v) ? v : []).map(x => toIndex(x, titles)).filter(n => n > 0)
135 const outcome = typeof raw.outcome === 'string' ? raw.outcome.trim().toLowerCase() : ''
136 return {
137 isSameTask: raw.same_task !== false && raw.same_task !== 'false',
138 outcome: outcome === 'finished' || outcome === 'waiting' || outcome === 'unfinished' ? outcome : null,
139 done: indexes(raw.done),
140 skip: indexes(raw.skip),
141 current: toIndex(raw.current, titles),
142 add: parseSteps(raw.add),
143 }
144}
145
146export function hitsStep(step: Step | undefined, haystack: string): boolean {
147 const text = haystack.toLowerCase()
148 return step?.signals.some(s => text.includes(s)) ?? false
149}
150
151// 只往前走一步:当前步骤已经被命中过(确实在做),而下一步的关键词出现了,当前步骤才打勾。
152// 前后两步共用一个关键词时(连跑三次 sleep),当前步骤命中过以后再命中就算进入下一步。
153export function stepAfter(cur: Step | undefined, nxt: Step | undefined, call: string, isCurrentHit: boolean): 'advance' | 'hit' | 'stay' {
154 if (nxt && isCurrentHit && hitsStep(nxt, call)) return 'advance'
155 if (hitsStep(cur, call)) return 'hit'
156 return 'stay'
157}
158
159// pace:已完成步骤的实际用时 ÷ 预估用时,用来校准剩余步骤
160export function remainingMinutes(plan: Step[], current: number, inStepMs: number, pace = 1): number {
161 const step = plan[current]
162 if (!step) return 0
163 const later = plan.slice(current + 1).reduce((acc, s) => acc + s.minutes, 0)
164 return Math.max(0, step.minutes * pace - inStepMs / MINUTE) + later * pace
165}
166
167// 预估时长:不到 1 分钟按秒,否则按整分钟
168export function duration(minutes: number): string {
169 if (minutes < 1) return `${Math.max(1, Math.round(minutes * 60))} 秒`
170 return `${Math.round(minutes)} 分`
171}
172
173// 实际用时:10 分钟以内精确到秒
174export function spent(ms: number): string {
175 const sec = Math.max(0, Math.round(ms / 1000))
176 if (sec < 60) return `${sec} 秒`
177 if (sec < 600) return sec % 60 === 0 ? `${sec / 60} 分` : `${Math.floor(sec / 60)} 分 ${sec % 60} 秒`
178 return `${Math.round(sec / 60)} 分`
179}
180
181// 把一次工具调用压成一行「Claude 此刻在做什么」
182export function describe(e: { tool: string; [key: string]: unknown }): string {
183 const str = (k: string) => (typeof e[k] === 'string' ? (e[k] as string) : '')
184 const base = (p: string) => p.slice(p.lastIndexOf('/') + 1)
185 const firstLine = (s: string) => s.trim().split('\n')[0] ?? ''
186 if (e.tool === 'Bash') return `$ ${firstLine(str('command'))}`
187 if (['Read', 'Edit', 'Write', 'NotebookEdit'].includes(e.tool)) return `${e.tool} ${base(str('file_path') || str('notebook_path'))}`
188 if (e.tool === 'Grep' || e.tool === 'Glob') return `${e.tool} ${str('pattern')}`
189 if (e.tool === 'Agent') return `子代理:${str('description')}`
190 if (e.tool === 'WebFetch') return `WebFetch ${str('url')}`
191 if (e.tool === 'WebSearch') return `WebSearch ${str('query')}`
192 const mcp = /^mcp__(.+?)__(.+)$/.exec(e.tool)
193 if (mcp) return `${mcp[1]}:${mcp[2]}`
194 return e.tool
195}
196
197function taskTitle(text: string): string {
198 const line = text.trim().split('\n')[0] ?? ''
199 if (line === '') return '(接着上一轮继续)'
200 return [...line].length > 60 ? `${[...line].slice(0, 60).join('')}…` : line
201}
202
203function newTask(text: string): Task {
204 return {
205 text,
206 activeMs: 0,
207 segmentStart: Date.now(),
208 pausedAt: 0,
209 ending: 'running',
210 tools: 0,
211 items: [],
212 isCurrentHit: false,
213 activity: '',
214 checkedAt: 0,
215 calls: 0,
216 firstEstimate: 0,
217 isBusy: false,
218 isAsked: false,
219 isWaitingFirstReply: false,
220 isFinalChecking: false,
221 lastAnswer: '',
222 trail: [],
223 timer: null,
224 }
225}
226
227function elapsed(t: Task, now: number): number {
228 return t.activeMs + (t.segmentStart > 0 ? now - t.segmentStart : 0)
229}
230
231function active(t: Task): number {
232 return t.items.findIndex(i => i.status === 'active')
233}
234
235function isAllDone(t: Task): boolean {
236 return t.items.length > 0 && t.items.every(i => i.status === 'done' || i.status === 'skipped')
237}
238
239function finishedCount(t: Task): number {
240 return t.items.filter(i => i.status === 'done' || i.status === 'skipped').length
241}
242
243// 一轮结束后条带和面板上的状态词
244export function endingLabel(ending: Ending, isFinalChecking: boolean): string {
245 if (isFinalChecking) return '正在核对…'
246 return { running: '进行中', aborted: '已中断', error: '出错停下', waiting: '等你回复', paused: '未完成', unchecked: '未能核对', done: '任务完成' }[ending]
247}
248
249function pace(t: Task): number {
250 const timed = t.items.filter(i => i.status === 'done' && !i.isUntimed)
251 const planned = timed.reduce((acc, i) => acc + i.minutes, 0)
252 if (planned === 0) return 1
253 const actual = timed.reduce((acc, i) => acc + (i.endedAt - i.startedAt), 0) / MINUTE
254 return Math.min(5, Math.max(0.1, actual / planned))
255}
256
257function left(t: Task, now: number): number {
258 const at = active(t)
259 const cur = t.items[at]
260 if (!cur) return 0
261 return remainingMinutes(t.items, at, now - cur.startedAt, pace(t))
262}
263
264function bar(done: number, total: number, cells = 8): string {
265 const filled = total > 0 ? Math.min(cells, Math.round((done / total) * cells)) : 0
266 return '▰'.repeat(filled) + '▱'.repeat(cells - filled)
267}
268
269// 终端里汉字、全角标点和 emoji 占两格
270export function cells(s: string): number {
271 let n = 0
272 for (const ch of s) {
273 const c = ch.codePointAt(0) ?? 0
274 const isWide =
275 (c >= 0x1100 && c <= 0x115f) ||
276 (c >= 0x2e80 && c <= 0xa4cf) ||
277 (c >= 0xac00 && c <= 0xd7a3) ||
278 (c >= 0xf900 && c <= 0xfaff) ||
279 (c >= 0xfe30 && c <= 0xfe4f) ||
280 (c >= 0xff00 && c <= 0xff60) ||
281 (c >= 0xffe0 && c <= 0xffe6) ||
282 c >= 0x1f300
283 n += isWide ? 2 : 1
284 }
285 return n
286}
287
288function clip(s: string, width: number): string {
289 if (cells(s) <= width) return s
290 let out = ''
291 for (const ch of s) {
292 if (cells(out + ch) > width - 1) break
293 out += ch
294 }
295 return out + '…'
296}
297
298// 条带里的一段:rank 越小越要紧;相邻两段都是 isDim 时用「 · 」连成一段文字,否则隔一个空格
299export type Part = { text: string; rank: number; color?: string; isDim?: boolean }
300const BAND_LABEL = '▍进度'
301const MIN_HEAD = 24
302
303function lineWidth(parts: Part[], headWidth: number): number {
304 return parts.reduce(
305 (n, p, i) => n + (i > 0 && p.isDim && parts[i - 1]?.isDim ? 3 : 1) + (i === 0 ? headWidth : cells(p.text)),
306 cells(BAND_LABEL),
307 )
308}
309
310// 条带固定一行(右边停着面板时可用宽度只剩一半):第一段必留,其余按 rank 从小到大放得下才放,
311// 放不下的整段不显示;第一段最后按剩下的宽度截断,挑段时先给它留 MIN_HEAD 格
312export function fitBand(parts: Part[], width: number): Part[] {
313 const [head, ...rest] = parts
314 if (!head) return []
315 let kept: Part[] = [head]
316 const headMin = Math.min(cells(head.text), MIN_HEAD)
317 for (const p of [...rest].sort((a, b) => a.rank - b.rank)) {
318 const next = parts.filter(q => q === p || kept.includes(q))
319 if (lineWidth(next, headMin) <= width) kept = next
320 }
321 const room = width - lineWidth(kept, 0)
322 return [{ ...head, text: clip(head.text, Math.max(2, room)) }, ...kept.slice(1)]
323}
324
325function isRunningVisible(t: Task, now: number): boolean {
326 return t.isAsked || t.items.length > 0 || t.isBusy || elapsed(t, now) >= PLAN_AFTER_MS
327}
328
329function redraw($: Api) {
330 $.ui.invalidate('ui.render')
331}
332
333function item(step: Step, status: Item['status'], now: number): Item {
334 return { ...step, status, startedAt: status === 'active' ? now : 0, endedAt: 0, tools: 0, isUntimed: false }
335}
336
337// 收尾核对和手动核对是 forced:不受 MAX_MODEL_CALLS 限制,也不因为别的调用在跑就跳过
338async function ask($: Api, t: Task, prompt: string, isForced = false): Promise<string | null> {
339 if (!isForced && (t.isBusy || t.calls >= MAX_MODEL_CALLS)) return null
340 if (!isForced) t.isBusy = true
341 redraw($)
342 const reply = await $.model.fork({ prompt }).catch(() => undefined)
343 if (!isForced) t.isBusy = false
344 // 新会话第一条回复出来之前没有可分叉的对话:不算一次调用,稍后自动重试
345 t.isWaitingFirstReply = reply !== undefined && !reply.isAnswered && reply.reason === 'nothing-to-fork'
346 if (!t.isWaitingFirstReply) {
347 t.calls += 1
348 t.checkedAt = Date.now()
349 }
350 return reply?.isAnswered ? reply.text : null
351}
352
353// 上一个模型调用还没回来时先等一等,而不是直接跳过(正常收尾的核对不能丢)
354function whenIdle($: Api, t: Task, run: () => void, tries = 20) {
355 if (task !== t) return
356 if (t.isBusy && tries > 0) {
357 $.clock.after(3000, () => whenIdle($, t, run, tries - 1))
358 return
359 }
360 run()
361}
362
363// 第一次:没有清单时估算一份
364async function estimate($: Api, t: Task) {
365 const text = await ask($, t, planPrompt(t.trail))
366 const steps = text ? parsePlan(text) : []
367 if (task === t && steps.length > 0 && t.items.length === 0) {
368 const now = Date.now()
369 t.items = steps.map((s, n) => item(s, n === 0 ? 'active' : 'todo', now))
370 // 估算约定「正在执行的那一步算第一步」:已经有工具调用时,第一步就算在做了
371 t.isCurrentHit = t.tools > 0
372 t.firstEstimate = Math.round(elapsed(t, now) / MINUTE + steps.reduce((acc, s) => acc + s.minutes, 0))
373 // 估算回来时这一轮已经结束(短任务常见):补上收尾,不然清单会一直停在第 1 步
374 if (t.ending !== 'running') {
375 showEnding($)
376 if (t.ending === 'paused') {
377 t.isFinalChecking = true
378 finalCheck($, t)
379 }
380 }
381 }
382 redraw($)
383}
384
385// 之后:不再重估,只请 Claude 核对现有清单——哪些真的做完了、正在做哪步、要不要补步骤
386async function check($: Api, t: Task, context: { newText?: string; answer?: string } = {}): Promise<Check | null> {
387 if (t.items.length === 0) return null
388 const newText = context.newText
389 const text = await ask($, t, checkPrompt(t.items, context), context.answer !== undefined)
390 const result = text ? parseCheck(text, t.items.map(i => i.title)) : null
391 if (task !== t || !result) {
392 redraw($)
393 return result
394 }
395 if (!result.isSameTask && newText !== undefined) {
396 const fresh = newTask(newText)
397 fresh.timer = t.timer
398 task = fresh
399 void estimate($, fresh)
400 return result
401 }
402 applyCheck(t, result, Date.now())
403 redraw($)
404 return result
405}
406
407function showEnding($: Api) {
408 bandUntil = Math.max(bandUntil, Date.now() + BAND_AFTER_MS)
409 bandTimer?.cancel()
410 bandTimer = $.clock.after(BAND_AFTER_MS, () => redraw($))
411 redraw($)
412}
413
414// 收尾核对:等手上的估算或核对回来(最多 60 秒),一定核对一次;失败隔 5 秒、10 秒各重试一次
415function finalCheck($: Api, t: Task, attempt = 0, waited = 0) {
416 if (task !== t || t.ending === 'running') return
417 if (t.isBusy && waited < 20) {
418 $.clock.after(3000, () => finalCheck($, t, attempt, waited + 1))
419 return
420 }
421 t.isFinalChecking = true
422 redraw($)
423 void check($, t, { answer: t.lastAnswer }).then(result => {
424 if (task !== t || t.ending === 'running') return
425 if (!result && attempt < 2) {
426 $.clock.after(5000 * (attempt + 1), () => finalCheck($, t, attempt + 1, waited))
427 return
428 }
429 t.isFinalChecking = false
430 t.ending = !result ? 'unchecked' : isAllDone(t) ? 'done' : result.outcome === 'waiting' ? 'waiting' : 'paused'
431 showEnding($)
432 })
433}
434
435export function applyCheck(t: { items: Item[]; isCurrentHit: boolean }, result: Check, now: number) {
436 const done = new Set(result.done.map(n => n - 1))
437 const skip = new Set(result.skip.map(n => n - 1).filter(i => !done.has(i)))
438 // 整件事已经做完:剩下没打勾的步骤都算「不需要了」,新补的步骤也不再加
439 const isFinished = result.outcome === 'finished'
440 const added = isFinished ? [] : result.add.slice(0, Math.max(0, MAX_ITEMS - t.items.length)).map(s => item(s, 'todo', now))
441 t.items.push(...added)
442 t.items.forEach((it, i) => {
443 if (done.has(i)) {
444 if (it.status === 'active') Object.assign(it, { status: 'done', endedAt: now })
445 else if (it.status !== 'done') Object.assign(it, { status: 'done', startedAt: now, endedAt: now, isUntimed: true })
446 } else if (skip.has(i) || isFinished) {
447 if (it.status !== 'done' || !isFinished) Object.assign(it, { status: 'skipped', endedAt: now, isUntimed: true })
448 } else if (it.status === 'done' || it.status === 'skipped') {
449 Object.assign(it, { status: 'todo', startedAt: 0, endedAt: 0, isUntimed: false })
450 }
451 })
452 const isOpen = (it: Item | undefined) => it !== undefined && (it.status === 'todo' || it.status === 'active')
453 const wanted = result.current - 1
454 const target = isOpen(t.items[wanted]) ? wanted : t.items.findIndex(isOpen)
455 const previous = active(t as Task)
456 t.items.forEach((it, i) => {
457 if (!isOpen(it)) return
458 if (i === target) {
459 if (it.status !== 'active') Object.assign(it, { status: 'active', startedAt: now })
460 } else if (it.status === 'active') {
461 Object.assign(it, { status: 'todo', startedAt: 0 })
462 }
463 })
464 if (target !== previous) t.isCurrentHit = false
465}
466
467function tick($: Api) {
468 const t = task
469 if (!t || t.ending !== 'running') return
470 const now = Date.now()
471 const cur = t.items[active(t)]
472 if (t.items.length === 0 && (t.isAsked || elapsed(t, now) >= PLAN_AFTER_MS)) {
473 void estimate($, t)
474 } else if (cur && now - t.checkedAt >= CHECK_GAP_MS && now - cur.startedAt > Math.max(2 * MINUTE, cur.minutes * 2 * MINUTE)) {
475 void check($, t)
476 }
477 if (isRunningVisible(t, now)) redraw($)
478}
479
480// 面板里按 c:跑着的时候普通核对;结束后按收尾核对重新判一次
481async function recheck($: Api, t: Task) {
482 if (t.ending === 'running') {
483 await check($, t)
484 return
485 }
486 finalCheck($, t)
487}
488
489// 面板里按 d:你确认整件事做完了,没打勾的都算完成
490function finishByHand($: Api, t: Task) {
491 const now = Date.now()
492 for (const it of t.items) {
493 if (it.status !== 'done' && it.status !== 'skipped') Object.assign(it, { status: 'done', endedAt: now, isUntimed: it.status !== 'active' })
494 }
495 t.ending = 'done'
496 t.isFinalChecking = false
497 showEnding($)
498}
499
500export const register: Register = on => {
501 on('session.start', async ($, e, next) => {
502 await $.command.register({
503 name: 'steps',
504 description: '打开或关闭任务步骤面板(快捷键 ctrl+x p)',
505 immediate: true,
506 })
507 return next(e)
508 })
509
510 // 清单放在拿键盘焦点的面板里:Claude 工作时按 Esc 先关面板,不会打到「中断本轮」上
511 on('command.run', { command: 'steps' }, async $ => {
512 if ((await $.ui.panes()).some(p => p.id === PANE)) {
513 await $.ui.close({ id: PANE })
514 return {}
515 }
516 const t = task
517 if (t && t.ending === 'running') {
518 t.isAsked = true
519 if (t.items.length === 0) void estimate($, t)
520 }
521 await $.ui.open({ id: PANE, title: '任务步骤', focus: true, closeOnEscape: true, rows: Math.max(t?.items.length ?? 0, 4) + 10 })
522 redraw($)
523 return {}
524 })
525
526 // /clear 或会话结束:清掉清单,不把上一段对话的进度带进新对话
527 on('session.end', async ($, e, next) => {
528 task?.timer?.cancel()
529 bandTimer?.cancel()
530 task = null
531 bandUntil = 0
532 redraw($)
533 return next(e)
534 })
535
536 on('turn.start', async ($, e, next) => {
537 const started = await next(e)
538 bandTimer?.cancel()
539 bandUntil = 0
540 const now = Date.now()
541 const prev = task
542 if (prev && prev.ending !== 'running' && prev.items.length > 0 && !isAllDone(prev)) {
543 // 上一轮没做完(被中断或停下来问你):接着同一份清单,空闲时间不计入
544 const cur = prev.items[active(prev)]
545 if (cur) cur.startedAt += now - prev.pausedAt
546 prev.segmentStart = now
547 prev.ending = 'running'
548 prev.isFinalChecking = false
549 prev.timer = $.clock.every(TICK_MS, () => tick($))
550 whenIdle($, prev, () => void check($, prev, { newText: e.text }))
551 } else {
552 prev?.timer?.cancel()
553 task = newTask(e.text)
554 task.timer = $.clock.every(TICK_MS, () => tick($))
555 }
556 redraw($)
557 return started
558 })
559
560 on('tool.call', ($, e, next) => {
561 const t = task
562 if (t && t.ending === 'running' && e.agentId === undefined) {
563 const now = Date.now()
564 t.tools += 1
565 t.activity = describe(e)
566 t.trail = [...t.trail, t.activity].slice(-30)
567 const at = active(t)
568 const cur = t.items[at]
569 const nxt = t.items[at + 1]
570 const move = stepAfter(cur, nxt, JSON.stringify(e), t.isCurrentHit)
571 if (move === 'advance' && cur && nxt) {
572 Object.assign(cur, { status: 'done', endedAt: now })
573 Object.assign(nxt, { status: 'active', startedAt: now })
574 } else if (move === 'hit') {
575 t.isCurrentHit = true
576 }
577 const now2 = t.items[active(t)]
578 if (now2) now2.tools += 1
579 if (t.items.length === 0 && t.isWaitingFirstReply && !t.isBusy) void estimate($, t)
580 redraw($)
581 }
582 return next(e)
583 })
584
585 on('turn.complete', async ($, e, next) => {
586 const done = await next(e)
587 const t = task
588 if (e.agentId === undefined && t && t.ending === 'running') {
589 const now = Date.now()
590 t.timer?.cancel()
591 t.timer = null
592 t.activeMs += now - t.segmentStart
593 t.segmentStart = 0
594 t.pausedAt = now
595 t.lastAnswer = e.answer
596 // 被 Esc 打断、API 出错或拒答:不去核对(多半也会失败),下一条消息接着这份清单
597 t.ending = e.reason === 'aborted' ? 'aborted' : e.reason === 'error' || e.reason === 'refusal' ? 'error' : 'paused'
598 if (t.items.length > 0) {
599 bandUntil = now + BAND_AFTER_MS
600 bandTimer = $.clock.after(BAND_AFTER_MS, () => redraw($))
601 if (t.ending === 'paused') {
602 t.isFinalChecking = true
603 finalCheck($, t)
604 }
605 }
606 redraw($)
607 }
608 return done
609 })
610
611 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
612 const below = await next(e)
613 const now = Date.now()
614 const t = task
615 if (!t || e.props.hasSurvey) return below
616 const isRunning = t.ending === 'running'
617 if (isRunning ? !isRunningVisible(t, now) : now >= bandUntil) return below
618 const { Box, Text } = $.ui.resolve(e)
619 const at = active(t)
620 const cur = t.items[at]
621 const doneCount = finishedCount(t)
622
623 const hint: Part = { text: `${TOGGLE_KEY} 看步骤`, rank: 2, isDim: true }
624 let parts: Part[]
625 if (!isRunning) {
626 const total = t.items.length
627 const skipped = t.items.filter(i => i.status === 'skipped').length
628 const word: Part = { text: endingLabel(t.ending, t.isFinalChecking), rank: 0 }
629 parts = t.isFinalChecking
630 ? [word, { text: 'Claude 在确认哪些步骤真的做完了', rank: 3, isDim: true }, { text: `已打勾 ${doneCount}/${total}`, rank: 1, isDim: true }, hint]
631 : t.ending === 'done'
632 ? [word, { text: `用时 ${spent(elapsed(t, now))}`, rank: 1, isDim: true }, ...(skipped > 0 ? [{ text: `${skipped} 步后来不需要了`, rank: 3, isDim: true }] : []), hint]
633 : t.ending === 'unchecked'
634 ? [word, { text: `完成 ${doneCount}/${total} 步(未经 Claude 确认)`, rank: 1, isDim: true }, { text: '面板里按 c 重试', rank: 3, isDim: true }, hint]
635 : [word, { text: `完成 ${doneCount}/${total} 步`, rank: 1, isDim: true }, { text: '下一条消息接着这份清单', rank: 4, isDim: true }, hint]
636 } else if (!cur) {
637 parts = [
638 { text: t.isBusy ? '正在估算步骤…' : t.isWaitingFirstReply ? '等 Claude 第一次回复后估算' : '还没有步骤估算', rank: 0 },
639 { text: `已 ${spent(elapsed(t, now))}`, rank: 3, isDim: true },
640 { text: `${t.tools} 次工具调用`, rank: 4, isDim: true },
641 { ...hint, rank: 1 },
642 ]
643 } else {
644 const remain = left(t, now)
645 const nextItem = t.items[at + 1]
646 parts = [
647 { text: `${at + 1}/${t.items.length} ${cur.title}`, rank: 0 },
648 { text: bar(doneCount, t.items.length), rank: 2, color: 'autoAccept' },
649 { text: `已 ${spent(elapsed(t, now))}`, rank: 4, isDim: true },
650 { text: remain > 0 ? `约剩 ${duration(remain)}` : '已超出预估', rank: 1, isDim: true },
651 ...(nextItem ? [{ text: `下一步 ${nextItem.title}`, rank: 5, isDim: true }] : []),
652 { ...hint, rank: 3 },
653 ]
654 }
655 const runs: Part[] = []
656 // 右端 4 格是 Claude Code 自己画的折叠按钮「 [-]」,bodyColumns 里含着它
657 for (const p of fitBand(parts, e.props.bodyColumns - 4)) {
658 const last = runs.at(-1)
659 if (last?.isDim && p.isDim) last.text += ` · ${p.text}`
660 else runs.push({ ...p })
661 }
662 return (
663 <Box flexDirection="column">
664 <Box flexDirection="row" gap={1}>
665 <Box flexShrink={0}>
666 <Text color="autoAccept" bold>
667 {BAND_LABEL}
668 </Text>
669 </Box>
670 {runs.map((r, i) => (
671 <Text key={String(i)} color={r.color} dimColor={r.isDim} wrap="truncate-end">
672 {r.text}
673 </Text>
674 ))}
675 </Box>
676 {below}
677 </Box>
678 )
679 })
680
681 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
682 const { Box, Text, Button } = $.ui.resolve(e)
683 const now = Date.now()
684 const t = task
685 const width = Math.max(40, e.props.bodyColumns - 2)
686 const titleWidth = Math.min(30, Math.max(14, Math.floor(width * 0.4)))
687
688 const footer = (
689 <Box flexDirection="column" marginTop={1}>
690 <Text dimColor>✓ 只在下一步真正开始、或 Claude 核对确认后才打勾;中断后发下一条消息,接着这份清单,不重新估算。</Text>
691 <Text dimColor>Esc 或 {TOGGLE_KEY} 关闭(面板开着时 Esc 只关面板,不会中断 Claude)</Text>
692 </Box>
693 )
694 if (!t) {
695 return (
696 <Box flexDirection="column" paddingX={1}>
697 <Text>还没有任务记录。Claude 开始干活后,这里会显示步骤。</Text>
698 {footer}
699 </Box>
700 )
701 }
702
703 const at = active(t)
704 const doneCount = finishedCount(t)
705 const parts = [`已 ${spent(elapsed(t, now))}`]
706 if (t.ending === 'running') {
707 if (at >= 0) parts.push(left(t, now) > 0 ? `约剩 ${duration(left(t, now))}` : '已超出预估', `第 ${at + 1}/${t.items.length} 步`)
708 else parts.push(`${t.tools} 次工具调用`)
709 if (t.isBusy && t.items.length > 0) parts.push('Claude 正在核对进度…')
710 } else {
711 parts.unshift(endingLabel(t.ending, t.isFinalChecking))
712 parts.push(`完成 ${doneCount}/${t.items.length} 步`)
713 if (t.ending === 'unchecked') parts.push('没能让 Claude 确认,按 c 重试')
714 else if (t.ending !== 'done' && !t.isFinalChecking) parts.push('下一条消息接着这份清单')
715 }
716
717 const stepRow = (it: Item, n: number) => {
718 const isNow = it.status === 'active'
719 const isLive = isNow && t.ending === 'running'
720 const mark = it.status === 'done' ? '✓' : it.status === 'skipped' ? '–' : isNow ? '▶' : '○'
721 const markColor = it.status === 'done' ? 'success' : isNow ? 'autoAccept' : undefined
722 const inStep = (t.ending === 'running' ? now : t.pausedAt) - it.startedAt
723 const calls = it.tools > 0 ? ` · ${it.tools} 次调用` : ''
724 let note
725 if (it.status === 'done') note = it.isUntimed ? '已完成(核对确认)' : `${spent(it.endedAt - it.startedAt)}(预计 ${duration(it.minutes)})${calls}`
726 else if (it.status === 'skipped') note = '后来不需要了(核对确认)'
727 else if (isNow) note = `${spent(inStep)} / 预计 ${duration(it.minutes)}${calls}${isLive ? '' : ' · 已暂停'}`
728 else note = `预计 ${duration(it.minutes)}`
729 return (
730 <Box flexDirection="column" key={`step-${n}`}>
731 <Box flexDirection="row" gap={1}>
732 <Text color={markColor} bold={isNow}>
733 {mark}
734 </Text>
735 <Box width={2}>
736 <Text dimColor={!isNow}>{String(n + 1)}</Text>
737 </Box>
738 <Box width={titleWidth}>
739 <Text bold={isNow} dimColor={it.status === 'done' || it.status === 'skipped'} strikethrough={it.status === 'skipped'}>
740 {it.title}
741 </Text>
742 </Box>
743 {isNow && <Text color="autoAccept">{bar(inStep, it.minutes * MINUTE, 5)}</Text>}
744 <Text dimColor>{note}</Text>
745 </Box>
746 {isLive && t.activity !== '' && (
747 <Box paddingLeft={5}>
748 <Text color="ide">└ {t.activity}</Text>
749 </Box>
750 )}
751 </Box>
752 )
753 }
754
755 return (
756 <Box flexDirection="column" paddingX={1}>
757 <Box flexDirection="row" gap={1}>
758 <Text color="autoAccept" bold>
759 任务
760 </Text>
761 <Text>{taskTitle(t.text)}</Text>
762 </Box>
763 <Box flexDirection="row" gap={1} marginBottom={1}>
764 <Text dimColor>{parts.join(' · ')}</Text>
765 {t.items.length > 0 && <Text color="autoAccept">{bar(doneCount, t.items.length)}</Text>}
766 </Box>
767 {t.items.map(stepRow)}
768 {t.items.length === 0 && (
769 <Box flexDirection="column">
770 <Text dimColor>
771 {t.isBusy
772 ? '正在估算步骤,几秒后出现清单…'
773 : t.isWaitingFirstReply
774 ? '等 Claude 第一次回复后估算'
775 : '还没有步骤估算(跑满 3 分钟或按 ctrl+x p 时估算)'}
776 </Text>
777 {t.ending === 'running' && t.activity !== '' && <Text color="ide">└ {t.activity}</Text>}
778 </Box>
779 )}
780 {t.items.length > 0 && (
781 <Box flexDirection="row" gap={2} marginTop={1}>
782 <Button key="recheck" plain hotkey="c" onPress={() => void recheck($, t)}>
783 让 Claude 重新核对
784 </Button>
785 {t.ending !== 'running' && t.ending !== 'done' && (
786 <Button key="finish" plain hotkey="d" onPress={() => finishByHand($, t)}>
787 整份标为完成
788 </Button>
789 )}
790 </Box>
791 )}
792 {footer}
793 </Box>
794 )
795 })
796}
797