SLOPSHOPPER

cmds

自定义斜杠命令速查:/cmds 打开面板,按你配的分组列出 ~/.claude/commands 和项目 .claude/commands 里的命令、参数和用途;空闲时提示行末尾轮换介绍一条

newpanespinnercommand
v0.1.0MITupdated 2026-10-22Bearisbug/cc-mods/cmds
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cmds
│ ┃ 短命令 ✕ › fix the failing auth test and add an audit log call │ ┃ 没找到自定义命令:~/.claude/commands/ │ ┃ 和项目的 .claude/commands/ 下都没有 .md 文件 ⏺ 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 │ │ › /cmds │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 短命令
没找到自定义命令:~/.claude/commands/ 和项目的 ↑↓ 滚动 · Esc .claude/commands/ 下都没有 .md 文件
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 173 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { CmdsEntry } from '../types'
5
6const OTHER = '其他'
7const PANE = 'cmds'
8const ROWS = 16
9
10const tip = atom({ plugin: 'cmds', key: 'tip' } as const, 0)
11const list = atom({ plugin: 'cmds', key: 'list' } as const, [] as CmdsEntry[])
12
13type Group = { title: string; names: string[] }
14
15// 「组名: 命令 命令; 组名: 命令」,组之间用分号或换行隔开,命令之间用空格、逗号或顿号
16function parseGroups(text: string): Group[] {
17  return text
18    .split(/[;;\n]/)
19    .map(part => part.match(/^\s*([^::]+?)\s*[::](.*)$/))
20    .filter(m => m !== null)
21    .map(m => ({
22      title: m[1] ?? '',
23      names: (m[2] ?? '').split(/[\s,,、]+/).map(n => n.replace(/^\//, '')).filter(Boolean),
24    }))
25    .filter(g => g.title && g.names.length > 0)
26}
27
28// description 在括号外的第一个冒号或分号之前那段
29function firstClause(text: string): string {
30  let depth = 0
31  for (let i = 0; i < text.length; i++) {
32    const ch = text[i] ?? ''
33    if ('((【[「'.includes(ch)) depth++
34    else if ('))】]」'.includes(ch)) depth = Math.max(0, depth - 1)
35    else if (depth === 0 && '::;;'.includes(ch) && i > 0) return text.slice(0, i).trim()
36  }
37  return text.trim()
38}
39
40function field(front: string, key: string): string {
41  const m = front.match(new RegExp(`^${key}:[ \\t]*(.*)$`, 'm'))
42  return (m?.[1] ?? '').trim().replace(/^(['"])(.*)\1$/, '$2')
43}
44
45async function readDir($: EngineInterface, dir: string): Promise<CmdsEntry[]> {
46  const entries = await $.fs.list(dir).catch(() => [])
47  const found: CmdsEntry[] = []
48  for (const entry of entries) {
49    if (!entry.name.endsWith('.md') || (entry.kind !== 'file' && !entry.isLink)) continue
50    const text = await $.fs.read(`${dir}/${entry.name}`).catch(() => '')
51    const front = typeof text === 'string' ? (text.match(/^---\n([\s\S]*?)\n---/)?.[1] ?? '') : ''
52    found.push({
53      name: entry.name.slice(0, -3),
54      args: field(front, 'argument-hint'),
55      purpose: firstClause(field(front, 'description')),
56    })
57  }
58  return found
59}
60
61// 用户级 ~/.claude/commands(或 $CLAUDE_CONFIG_DIR/commands)和当前项目的 .claude/commands,同名以项目的为准
62async function load($: EngineInterface): Promise<void> {
63  const config = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('HOME')) ?? ''}/.claude`
64  const byName = new Map<string, CmdsEntry>()
65  const project = `${await $.session.cwd()}/.claude/commands`
66  for (const entry of [...(await readDir($, `${config}/commands`)), ...(await readDir($, project))]) {
67    byName.set(entry.name, entry)
68  }
69  await update($, list, () => [...byName.values()].sort((a, b) => a.name.localeCompare(b.name)))
70}
71
72function grouped(entries: CmdsEntry[], groups: Group[]): { title: string; items: CmdsEntry[] }[] {
73  const byName = new Map(entries.map(e => [e.name, e]))
74  const placed = new Set<string>()
75  const result = groups.map(g => ({
76    title: g.title,
77    items: g.names.flatMap(n => {
78      const entry = byName.get(n)
79      if (!entry || placed.has(n)) return []
80      placed.add(n)
81      return [entry]
82    }),
83  }))
84  const rest = entries.filter(e => !placed.has(e.name))
85  result.push({ title: groups.length > 0 ? OTHER : '全部命令', items: rest })
86  return result.filter(g => g.items.length > 0)
87}
88
89export const register: Register = (on, options) => {
90  const groups = parseGroups(typeof options.groups === 'string' ? options.groups : '')
91
92  on('session.start', async ($, e, next) => {
93    // immediate:Claude 回答中途输入也立刻开关,不排到这一轮结束
94    await $.command.register({ name: 'cmds', description: '打开 / 关闭自定义短命令速查面板', immediate: true })
95    await load($)
96
97    return next(e)
98  })
99
100  // 同一个命令再输一次就关面板;面板拿着键盘:打开就能 ↑↓ 滚动,Esc 只关面板,不会中断 Claude
101  on('command.run', { command: 'cmds' }, async $ => {
102    if ((await $.ui.panes()).some(p => p.id === PANE)) {
103      await $.ui.close({ id: PANE })
104    } else {
105      await load($)
106      await $.ui.open({ id: PANE, title: '短命令', focus: true, closeOnEscape: true, rows: ROWS })
107    }
108
109    return {}
110  })
111
112  on('turn.complete', async ($, e, next) => {
113    await update($, tip, n => n + 1)
114
115    return next(e)
116  })
117
118  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
119    if (e.props.isDraft || e.props.isWorking) {
120      return next(e)
121    }
122    const items = grouped(await read($, list), groups).flatMap(g => g.items)
123    const item = items[(await read($, tip)) % items.length]
124    if (!item) {
125      return next(e)
126    }
127
128    return next({ ...e, props: { ...e.props, tail: `/${item.name} ${item.purpose} · /cmds 看全部` } })
129  })
130
131  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
132    const { Box, Text } = $.ui.resolve(e)
133    const sections = grouped(await read($, list), groups)
134    // 右上角留给 Claude Code 自己画的关闭标记 ✕
135    const hint = (
136      <Box marginRight={3}>
137        <Text dimColor>↑↓ 滚动 · Esc 关闭</Text>
138      </Box>
139    )
140
141    if (sections.length === 0) {
142      return (
143        <Box flexDirection="row" justifyContent="space-between">
144          <Text dimColor>没找到自定义命令:~/.claude/commands/ 和项目的 .claude/commands/ 下都没有 .md 文件</Text>
145          {hint}
146        </Box>
147      )
148    }
149
150    return (
151      <Box flexDirection="column">
152        {sections.map((g, n) => (
153          <Box key={g.title} flexDirection="column" marginBottom={n === sections.length - 1 ? 0 : 1}>
154            <Box flexDirection="row" justifyContent="space-between">
155              <Text bold>{g.title}</Text>
156              {n === 0 ? hint : null}
157            </Box>
158            {g.items.map(item => (
159              <Box key={item.name} flexDirection="column">
160                <Text>
161                  <Text bold color="cyan">/{item.name}</Text>
162                  {item.args ? <Text dimColor> {item.args}</Text> : null}
163                </Text>
164                {item.purpose ? <Text>  {item.purpose}</Text> : null}
165              </Box>
166            ))}
167          </Box>
168        ))}
169      </Box>
170    )
171  })
172}
173
types/index.d.ts 8 lines
1export type CmdsEntry = { name: string; args: string; purpose: string }
2
3declare module 'claude-code' {
4  interface PluginState {
5    cmds: { tip: number; list: CmdsEntry[] }
6  }
7}
8