SLOPSHOPPER

decide

等你拍板的事攒成一个列表:Claude 需要你定的事项会登记进来并在右上角提醒,/decide 打开面板,按数字选或写答复,攒几项一起提交给 Claude

newpanebandguardcommandtoast
v0.1.0MITupdated 2026-10-22Bearisbug/cc-mods/decide
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · decide
│ ┃ decide ✕ › fix the failing auth test and add an audit log call ╭──────────╮ │ ┃ ▍待定 0 项等你拍板 数字键选 · ↑↓ 移动 · Esc │ decide │ │ ┃ 选一项或写了答复后才能提交 ⏺ Read(src/auth.ts) │ 没有等你拍板的事 │ │ ⎿ 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 │ │ › /decide │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · decide
▍待定 0 项等你拍板 数字键选 · ↑↓ 移动 · Esc 关闭 选一项或写了答复后才能提交
README

cc-mods

五个 Claude Code mod:截图面板 shot-view、长任务进度 task-eta、按目录精简 MCP 说明 ctx-slim、自定义命令速查 cmds、等你拍板的待定面板 decide。前三个各在输入框上方画一行,颜色分别是蓝、紫、绿,不再用 Claude Code 自带的橙色 ⚠ 状态行;cmds 用 /cmds 打开速查面板,空闲时在提示行末尾介绍一条命令;decide 在 Claude 登记了要你定的事时在右上角弹出提醒,/decide 打开面板作答。

三个 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
claude plugin install cmds@cc-mods
claude plugin install decide@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 的前端项目里保留。

cmds · 自定义命令速查

  • /cmds 打开速查面板,再输一次关掉;Claude 回答到一半时输入也立刻生效,不用等这一轮结束。面板里列出 ~/.claude/commands/(设了 CLAUDE_CONFIG_DIR 时是它下面的 commands/)和当前项目 .claude/commands/ 里的命令:第一行是命令名和 frontmatter 里的 argument-hint,第二行是用途,取 description 在括号外第一个冒号或分号之前的那段。同名命令以项目里的为准。
  • 面板打开就拿着键盘:↑↓ 滚动,Esc 关掉,这时的 Esc 只关面板,不会中断 Claude。和 shot-view 的截图面板一样,全屏模式下窗口有 110 列以上时停靠在右侧、占满整个高度,其余时候在输入框上方,最高 16 行。
  • 输入框空着、Claude 没在工作时,提示行末尾轮换介绍一条命令,例如「/trim 精简代码 · /cmds 看全部」,每过一轮换下一条。
  • 新加的命令文件在下次打开面板或新开会话时出现。只读两个目录顶层的 .md 文件,子目录里的命令不列。
  • 没配分组时按名字排成一组。

配置 cmds

分组可以在 /config 里的「短命令分组」填一行,或者运行 /plugin configure cmds@cc-mods,也可以直接写进 ~/.claude/settings.json:

{
  "pluginConfigs": {
    "cmds@cc-mods": {
      "options": {
        "groups": "开工前: catchup flow raise dev; 交给它做: autopilot wf; 改代码与提交: trim scalpel ship; 线上: triage prod-tune; 后台与其他窗口: progress relay; 本机与账号: key disk weekly"
      }
    }
  }
}

格式是 组名: 命令 命令; 组名: 命令,组之间用分号或换行隔开,命令之间用空格、逗号或顿号,命令名前的 / 可写可不写。分组按写的顺序显示,没写进任何组的命令排在最后的「其他」,写了但本机没有的命令跳过。上面那段是作者自己的分组。

decide · 等你拍板

  • Claude 遇到只有你能定、不能按推荐直接做的事,除了照常写在回复里,还会调用 decide 提供的工具 pending 登记:一句问题、可选答案(推荐项排第一)、推荐哪个及理由、做决定需要的背景。
  • 登记后右上角弹出提醒,例如「2 项等你拍板 · /decide 回答:D1 要不要改名 /prod-tune?;D2 …」,最多三行,20 秒后消失,点一下提前关掉。每轮结束时还有没答的会再弹一次,上一条还没消失时不重复弹。输入框上方一直显示黄色的「▍待定 N 项等你拍板 /decide 打开」,你可以先去做别的,想好了再回来答。
  • /decide 打开面板列出全部待定项,再输一次关掉,面板开着时输入框上方那一行不显示。和 /cmds 一样,Claude 回答到一半时也立刻生效,面板打开就拿着键盘,焦点落在第一个选项上,Esc 关掉,放的位置也一样。每项列出问题、背景和推荐;有选项的每个选项是一个按钮,旁边的「补充」栏可以写说明;开放问题在「答复」栏写;不用再定的按「移除」。
  • 选项按顺序配了数字 1–9,直接按数字就选上,再按一次取消;底部「0: 提交已答的 N 项」按 0 提交。↑↓ 或 Tab 在选项、答复栏和按钮之间移动,Enter 选定或记下答复。
  • 焦点停在按钮上时打的字会进 Claude Code 的输入框,这时按 Enter 会把它当成一条消息发出去。要写答复,先用 ↓ 移到答复栏再打字。
  • 提交时答过的项合成一条你的消息发给 Claude,例如「我在待定面板里对下面几项的答复(这几项已从面板移除,不用再 resolve):1. D1 要不要改名 /prod-tune? → 保持原名」。Claude 正在工作时这条消息排队,它空闲后才收到;没答的项留在列表里;全部答完后面板自己关掉。
  • 你在对话里直接答了某项,或某项已经不用定了,Claude 按系统提示里的说明用同一个工具把它撤掉;它不确定还有哪些待定时,可以不带参数调用这个工具查看。
  • 待定项按会话存在 mod 的存储里,插件重载后还在;新会话从空开始。
  • 系统提示末尾多一段固定说明(222 字),整个会话不变,不破坏 prompt cache。pending 工具不经权限确认直接运行,它只改面板里的列表。
  • 列表只在终端和桌面端显示;mobile 端在对话里直接回答。

安全

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.*
cmdssession.start、command.run、turn.complete、ui.render$.command.register、$.session.cwd、$.env.get(只读 HOME、CLAUDE_CONFIG_DIR)、$.fs.list、$.fs.read(只读两个命令目录)、$.ui.*、$.state.*
decidesession.start、prompt.compose、tool.check(只放行自己的 pending)、tool.call、command.run、ui.close、turn.complete、ui.render$.tool.register、$.command.register、$.prompt.submit(只在你按提交时)、$.session.id、$.clock.now、$.store.*、$.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 2 files
hooks/register.tsx 280 lines
1import { atom, read, update } from 'claude-code'
2import type { ElementTable, EngineInterface, Register } from 'claude-code'
3
4import type { DecideItem } from '../types'
5
6const TOOL = 'mcp__decide__pending'
7const REMIND_MS = 20000
8const PANE = 'decide'
9const ROWS = 16
10
11const items = atom({ plugin: 'decide', key: 'items' } as const, [] as DecideItem[])
12
13// 固定文本:整个会话不变,不破坏 prompt cache
14const GUIDE = [
15  '需要用户拍板的事项(只有用户能定、不能按推荐直接做的),除了照常写在回复里,还要调用 mcp__decide__pending 的 add 登记:',
16  '每项一句问题,可选答案(推荐项放第一个),推荐哪个及理由,做决定需要的背景。',
17  '用户会在「等你拍板」面板里回答,答复以一条用户消息发回。可逆、你已经按推荐做了的事不登记。',
18  '用户在对话里直接答了某项,或某项已经不用定了,用同一工具的 resolve 传它的 id。不确定还有哪些待定时,不带参数调用它查看。',
19].join('')
20
21type Saved = { seq: number; items: DecideItem[] }
22let seq = 0
23// 上一次浮出提醒的时刻;同一插件的提醒会叠着显示,上一条还没消失就不再发
24let remindedAt = -Infinity
25
26const isAnswered = (item: DecideItem) => item.choice !== '' || item.note.trim() !== ''
27const storeKey = async ($: EngineInterface) => `session:${await $.session.id()}`
28
29async function change($: EngineInterface, fn: (list: DecideItem[]) => DecideItem[]): Promise<DecideItem[]> {
30  const next = fn(await read($, items))
31  await update($, items, () => next)
32  const key = await storeKey($)
33  if (next.length > 0) {
34    await $.store.set(key, { seq, items: next } satisfies Saved)
35  } else {
36    await $.store.delete(key)
37    await $.ui.close({ id: PANE })
38  }
39  return next
40}
41
42const paneOpen = async ($: EngineInterface) => (await $.ui.panes()).some(p => p.id === PANE)
43
44const strings = (value: unknown): string[] =>
45  Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string' && v.trim() !== '') : []
46const text = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')
47
48function summary(list: DecideItem[]): string {
49  if (list.length === 0) return '当前没有待定项。'
50  return `当前待定 ${list.length} 项:${list.map(i => `${i.id} ${i.question}`).join(';')}`
51}
52
53function answerText(list: DecideItem[]): string {
54  const lines = list.map((item, n) => {
55    const parts = [item.choice, item.note.trim()].filter(Boolean)
56    return `${n + 1}. ${item.id} ${item.question} → ${parts.join(';')}`
57  })
58  return ['我在待定面板里对下面几项的答复(这几项已从面板移除,不用再 resolve):', ...lines].join('\n')
59}
60
61// 右上角浮出的提醒:最多显示三行,先写条数和怎么回答,问题放后面
62async function remind($: EngineInterface): Promise<void> {
63  const list = await read($, items)
64  const now = await $.clock.now()
65  if (list.length === 0 || now - remindedAt < REMIND_MS) return
66  remindedAt = now
67  const questions = list.map(i => `${i.id} ${i.question}`).join(';')
68  $.ui.toast(`${list.length} 项等你拍板 · /decide 回答:${questions}`, { timeoutMs: REMIND_MS })
69}
70
71type Ui = Pick<ElementTable<'terminal'>, 'Box' | 'Text' | 'Button' | 'Input'>
72
73function head({ Box, Text }: Ui, count: number, hint: string) {
74  return (
75    <Box flexDirection="row" gap={1}>
76      <Text color="warning" bold>
77        ▍待定
78      </Text>
79      <Text>{count} 项等你拍板</Text>
80      <Text dimColor>{hint}</Text>
81    </Box>
82  )
83}
84
85// 面板里的整个列表
86function sheet($: EngineInterface, ui: Ui, list: DecideItem[], hint: string) {
87  const { Box, Text, Button, Input } = ui
88  const answered = list.filter(isAnswered)
89  const edit = (id: string, patch: Partial<DecideItem>) =>
90    change($, current => current.map(i => (i.id === id ? { ...i, ...patch } : i)))
91  // 选项按顺序配数字 1–9,0 是提交
92  let digit = 0
93  const hotkey = () => (++digit <= 9 ? String(digit) : undefined)
94  return (
95    <Box flexDirection="column">
96      {head(ui, list.length, hint)}
97      {list.map((item, n) => (
98        <Box key={item.id} flexDirection="column" paddingLeft={2}>
99          <Text bold>
100            {item.id} {item.question}
101          </Text>
102          {item.context ? <Text dimColor>{item.context}</Text> : null}
103          {item.recommended ? <Text color="success">推荐:{item.recommended}</Text> : null}
104          {item.options.length > 0 ? (
105            <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
106              {item.options.map((o, k) => (
107                <Button
108                  key={`choice-${item.id}-${k}`}
109                  label={`${item.choice === o ? '●' : '○'} ${o}`}
110                  hotkey={hotkey()}
111                  plain
112                  autoFocus={n === 0 && k === 0 ? true : undefined}
113                  onPress={() => void edit(item.id, { choice: item.choice === o ? '' : o })}
114                />
115              ))}
116            </Box>
117          ) : null}
118          <Box flexDirection="row" gap={2}>
119            <Input
120              key={`note-${item.id}`}
121              label={item.options.length > 0 ? '补充' : '答复'}
122              placeholder={item.options.length > 0 ? '可空' : '写你的答复'}
123              value={item.note}
124              submitLabel="记下"
125              autoFocus={n === 0 && item.options.length === 0 ? true : undefined}
126              onInput={value => void edit(item.id, { note: value })}
127              // Enter 会清空输入框,引擎只在画出的值变了时才把值填回去:先置空再写回
128              onSubmit={value => void edit(item.id, { note: '' }).then(() => edit(item.id, { note: value }))}
129            />
130            <Button key={`drop-${item.id}`} label="移除" plain dimColor onPress={() => void change($, c => c.filter(i => i.id !== item.id))} />
131          </Box>
132        </Box>
133      ))}
134      <Box paddingLeft={2}>
135        {answered.length > 0 ? (
136          <Button
137            key="submit"
138            label={`提交已答的 ${answered.length} 项`}
139            hotkey="0"
140            plain
141            onPress={async () => {
142              const done = (await read($, items)).filter(isAnswered)
143              if (done.length === 0) return
144              const ids = new Set(done.map(i => i.id))
145              await change($, current => current.filter(i => !ids.has(i.id)))
146              $.ui.toast(`已提交 ${done.length} 项,Claude 空闲后收到`)
147              // Claude 回答中途提交时,submit 要等这一轮结束才返回;等它会超过按钮处理的 10 秒时限
148              void $.prompt.submit({ text: answerText(done), asUser: true })
149            }}
150          />
151        ) : (
152          <Text dimColor>选一项或写了答复后才能提交</Text>
153        )}
154      </Box>
155    </Box>
156  )
157}
158
159export const register: Register = on => {
160  on('session.start', async ($, e, next) => {
161    // immediate:Claude 回答中途输入也立刻开关,不排到这一轮结束
162    await $.command.register({ name: 'decide', description: '打开 / 关闭「等你拍板」面板:选择、回答、提交待定事项', immediate: true })
163    await $.tool.register({
164      name: 'pending',
165      description: `登记、撤销和查看等用户拍板的事项。${GUIDE}返回当前全部待定项及其 id。`,
166      inputSchema: {
167        type: 'object',
168        properties: {
169          add: {
170            type: 'array',
171            description: '要登记的待定项',
172            items: {
173              type: 'object',
174              properties: {
175                question: { type: 'string', description: '一句话写清要用户定什么' },
176                options: { type: 'array', items: { type: 'string' }, description: '可选答案,推荐项放第一个;开放问题可省略' },
177                recommended: { type: 'string', description: '推荐哪个、为什么,一句话' },
178                context: { type: 'string', description: '做决定需要知道的背景,一两句' },
179              },
180              required: ['question'],
181            },
182          },
183          resolve: { type: 'array', items: { type: 'string' }, description: '不用再等的待定项 id' },
184        },
185      },
186    })
187    const saved = (await $.store.get(await storeKey($))) as Saved | undefined
188    seq = saved?.seq ?? 0
189    await update($, items, () => saved?.items ?? [])
190
191    return next(e)
192  })
193
194  on('prompt.compose', async ($, e, next) => {
195    const composed = await next(e)
196
197    return { ...composed, sections: [...composed.sections, { id: 'decide', text: GUIDE, scope: 'session' }] }
198  })
199
200  on('tool.check', { tool: TOOL }, () => ({ decision: 'allow' }))
201
202  on('tool.call', { tool: TOOL }, async ($, e) => {
203    const args = e as unknown as Record<string, unknown>
204    const added: DecideItem[] = (Array.isArray(args.add) ? args.add : [])
205      .map(raw => (typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}))
206      .filter(raw => text(raw.question) !== '')
207      .map(raw => ({
208        id: `D${++seq}`,
209        question: text(raw.question),
210        options: strings(raw.options),
211        recommended: text(raw.recommended),
212        context: text(raw.context),
213        choice: '',
214        note: '',
215      }))
216    const resolved = new Set(strings(args.resolve))
217    const list = await change($, current => [...current.filter(i => !resolved.has(i.id)), ...added])
218    if (added.length > 0) {
219      await remind($)
220    }
221    const head = added.length > 0 ? `已登记 ${added.map(i => i.id).join('、')}。` : ''
222
223    return { result: `${head}${summary(list)}` }
224  })
225
226  on('command.run', { command: 'decide' }, async $ => {
227    if ((await read($, items)).length === 0) {
228      $.ui.toast('没有等你拍板的事')
229    } else if (await paneOpen($)) {
230      await $.ui.close({ id: PANE })
231    } else {
232      // 面板拿着键盘:焦点落在第一个选项上,Esc 只关面板,不会中断 Claude
233      await $.ui.open({ id: PANE, title: '等你拍板', focus: true, closeOnEscape: true, rows: ROWS })
234      $.ui.invalidate('ui.render')
235    }
236
237    return {}
238  })
239
240  // 面板关掉后,输入框上方那一行重新画出来
241  on('ui.close', async ($, e, next) => {
242    const closed = await next(e)
243    if (e.id === PANE) $.ui.invalidate('ui.render')
244
245    return closed
246  })
247
248  // 每轮结束时还有没答的就再提醒一次
249  on('turn.complete', async ($, e, next) => {
250    await remind($)
251
252    return next(e)
253  })
254
255  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
256    const below = await next(e)
257    const list = await read($, items)
258    // 面板开着时列表在面板里,这一行先不画
259    if (list.length === 0 || e.props.hasSurvey || e.surface === 'mobile' || (await paneOpen($))) return below
260    const ui = $.ui.resolve(e)
261    const { Box } = ui
262
263    return (
264      <Box flexDirection="column">
265        {head(ui, list.length, '/decide 打开')}
266        {below}
267      </Box>
268    )
269  })
270
271  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
272    if (e.surface === 'mobile') {
273      const { Text } = $.ui.resolve(e)
274      return <Text>在对话里直接回答待定事项</Text>
275    }
276
277    return sheet($, $.ui.resolve(e), await read($, items), '数字键选 · ↑↓ 移动 · Esc 关闭')
278  })
279}
280
types/index.d.ts 16 lines
1export type DecideItem = {
2  id: string
3  question: string
4  options: string[]
5  recommended: string
6  context: string
7  choice: string
8  note: string
9}
10
11declare module 'claude-code' {
12  interface PluginState {
13    decide: { items: DecideItem[] }
14  }
15}
16