SLOPSHOPPER

task-eta

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

newpanebandguardcommandmodel
v0.6.2MITupdated 2026-10-06Bearisbug/cc-mods/task-eta
A shopper browsing a rack in a slop shop
README

cc-mods

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

三个 mod 同时出现在输入框上方

77 秒介绍视频(点图下载 mp4,13.5 MB):

cc-mods 介绍视频

mod 是什么

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 效果相同。

shot-view · 截图面板

shot-view 面板

  • Claude 用 Read 看了一张 PNG,或者它执行的命令、命令输出里出现了 10 分钟内写出的 PNG(比如 xcrun simctl io booted screenshot、Playwright 截图),这张图就进入列表,最多保留最近 60 张。
  • 有新截图时,输入框上方出现蓝色的「▍截图 新增 xxx.png 未读 N 张 · ctrl+x s 查看」;截图都标为已读后这一行不再出现。窗口窄或右侧停着面板时,这一行仍只占一行:先省掉快捷键提示,再省掉张数,文件名从中间截短,保留结尾的时间戳和 .png。
  • /shots(ctrl+x s)打开右侧面板,Claude 正在工作时也能立刻打开。面板只显示未读的截图,打开时停在最新一张,顶行写着当前是第几张未读、已读几张。n 下一张(更早的),p 上一张(更新的),r 标为已读(看过、不用再看时按,只是翻过不算),u 撤销最近一次已读并跳回那一张,按住 o 在面板里放大当前这张、松开收起,轻按 o 用 macOS 快速查看打开,f 在 Finder 里显示,y 复制路径;Esc 或再按一次 ctrl+x s 关闭,面板开着时 Esc 只关面板,不会中断 Claude。
  • 标为已读的截图不再出现,全部读完时面板显示「✓ 截图都看完了」。Claude 再读一遍没改过的同一个文件,它仍算已读;同一路径被新截图覆盖(修改时间变了),就重新算未读。
  • 截图文件被删除或移走后,约 2 秒内会自动从提示行和面板里去掉,面板注明另有几张已不在;文件被原地覆盖后,面板显示的是新内容。
  • 按住 o 是靠终端在按住时连续补发的重复按键来识别的,mod 收不到「松开」事件。有的终端按住字母时会弹出 macOS 的重音字符菜单,不补发重复按键,这时按住会被当成轻按,打开快速查看;对这个终端运行 defaults write <终端的 bundle id> ApplePressAndHoldEnabled -bool false 再重开,就会改回按住连发。面板内放大受面板宽度限制:横屏截图放大后几乎一样大,竖长的手机截图会明显变大;想看大图就轻按 o。
  • 面板里直接显示图片,要求终端支持 kitty 图形协议。kitty 和 Ghostty 默认开启;其他支持该协议的终端,在 ~/.claude/settings.json 的 env 里加 "CLAUDE_CODE_FORCE_TERMINAL_IMAGES": "1"。终端不支持时面板只显示文件名,轻按 o 用快速查看看。
  • 只能在 macOS 上用:读图片尺寸靠 sips,快速查看靠 qlmanage,在 Finder 里显示靠 open。

task-eta · 长任务进度

task-eta 步骤面板

  • 一轮任务跑满 3 分钟,task-eta 向当前会话的模型发一个旁路问题,估出剩余步骤;不满 3 分钟时按一下 ctrl+x p 会当场估算。
  • 输入框上方出现紫色进度行,例如「▍进度 2/5 跑单元测试 ▰▰▱▱ 已 4 分 · 约剩 6 分 · 下一步 修复失败用例 · ctrl+x p 看步骤」。剩余时间按已完成步骤的实际快慢校准。窗口窄或右侧停着面板时,进度行仍只占一行:放不下的部分按「下一步 → 已用时 → 快捷键提示 → 进度条」的顺序省掉,步骤标题最后才截短。
  • /steps(ctrl+x p)展开「任务步骤」面板:每一步用 ✓ / ▶ / ○ 标状态,「–」表示 Claude 核对后认为这一步后来不需要了;每一步写着实际用时、预计用时和调用次数,当前步骤下面显示 Claude 正在执行的命令。面板里按 c 让 Claude 重新核对;一轮已经结束、清单还没完成时,按 d 把整份清单标为完成。
  • 只有下一步真正开始、或 Claude 核对确认后,才给一步打勾。一轮结束时 Claude 核对一次,进度行显示结果:任务完成、等你回复、未完成、已中断、出错停下;核对试了 3 次都没成功时显示「未能核对」。被中断或没做完时,发下一条消息会接着原来那份清单,不重新估算;/clear 会清掉清单。
  • 估算和过程中的核对通过 $.model.fork 发请求,每轮最多 6 次;一轮结束时的核对不受这个次数限制,失败后隔 5 秒、10 秒各重试一次。请求复用会话的 prompt cache,但仍然计入你的用量。

ctx-slim · 按目录精简 MCP 说明

ctx-slim 提示行

  • 每接一个 MCP 服务器,它的使用说明都会跟着每一轮请求发给模型。ctx-slim 在 prompt.attachment 上拦下 MCP 说明,按你配的规则去掉当前目录用不上的服务器那一节;工具本身照常可用。
  • 每个会话第一次精简时,输入框上方出现一次绿色提示「▍精简 本目录省略了 … 的 MCP 说明 −N 字符」,30 秒后消失。
  • 默认什么都不做,要先配规则。

配置 ctx-slim

规则可以在 /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-viewtool.call、command.run、ui.render$.process.run(只运行 sips、qlmanage、open)、$.fs.stat、$.clock.*、$.ui.*、$.state.*
task-etaturn.start、tool.call、turn.complete、command.run、session.end、ui.render$.model.fork、$.clock.*、$.ui.*
ctx-slimprompt.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 许可之内。

许可

MIT

Source 1 files
hooks/register.tsx 797 lines
1import 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