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

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

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

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 的前端项目里保留。
/cmds 打开速查面板,再输一次关掉;Claude 回答到一半时输入也立刻生效,不用等这一轮结束。面板里列出 ~/.claude/commands/(设了 CLAUDE_CONFIG_DIR 时是它下面的 commands/)和当前项目 .claude/commands/ 里的命令:第一行是命令名和 frontmatter 里的 argument-hint,第二行是用途,取 description 在括号外第一个冒号或分号之前的那段。同名命令以项目里的为准。.md 文件,子目录里的命令不列。分组可以在 /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"
}
}
}
}
格式是 组名: 命令 命令; 组名: 命令,组之间用分号或换行隔开,命令之间用空格、逗号或顿号,命令名前的 / 可写可不写。分组按写的顺序显示,没写进任何组的命令排在最后的「其他」,写了但本机没有的命令跳过。上面那段是作者自己的分组。
pending 登记:一句问题、可选答案(推荐项排第一)、推荐哪个及理由、做决定需要的背景。/decide 打开面板列出全部待定项,再输一次关掉,面板开着时输入框上方那一行不显示。和 /cmds 一样,Claude 回答到一半时也立刻生效,面板打开就拿着键盘,焦点落在第一个选项上,Esc 关掉,放的位置也一样。每项列出问题、背景和推荐;有选项的每个选项是一个按钮,旁边的「补充」栏可以写说明;开放问题在「答复」栏写;不用再定的按「移除」。pending 工具不经权限确认直接运行,它只改面板里的列表。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.* |
| cmds | session.start、command.run、turn.complete、ui.render | $.command.register、$.session.cwd、$.env.get(只读 HOME、CLAUDE_CONFIG_DIR)、$.fs.list、$.fs.read(只读两个命令目录)、$.ui.*、$.state.* |
| decide | session.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 许可之内。
hooks/register.tsx 280 lines1import { 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}
280types/index.d.ts 16 lines1export 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