SLOPSHOPPER

ctx-slim

按目录规则去掉用不上的 MCP 服务器说明,省上下文;规则在 userConfig 的 rules 里配置,没配规则时什么都不做

newbandprompttimer
v0.3.0MITupdated 2026-10-04Bearisbug/cc-mods/ctx-slim
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 2 files
hooks/register.tsx 130 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4type Api = EngineInterface
5type Keep = (server: string) => boolean
6
7const NOTICE_MS = 30_000
8
9// 每个会话只提示一次:记在会话级 state 里,/reload-plugins 后也不会再提示
10const notified = atom({ plugin: 'ctx-slim', key: 'notified' } as const, false)
11
12// 状态条带里的提示,显示 NOTICE_MS 后清掉
13let notice: { servers: string[]; saved: number } | null = null
14
15// 说明文本是一段总述,后面每个服务器一节 `## <名字>`
16export function filterInstructions(text: string, keep: Keep): string | null {
17  const [head = '', ...blocks] = text.split(/\n(?=## )/)
18  if (blocks.length === 0) return text
19  const kept = blocks.filter(b => keep((b.split('\n')[0] ?? '').slice(3).trim()))
20  if (kept.length === blocks.length) return text
21  return kept.length === 0 ? null : [head, ...kept].join('\n')
22}
23
24export function droppedServers(text: string, keep: Keep): string[] {
25  return text
26    .split(/\n(?=## )/)
27    .slice(1)
28    .map(b => (b.split('\n')[0] ?? '').slice(3).trim())
29    .filter(name => !keep(name))
30}
31
32export type Condition = { kind: 'path' | 'file' | 'text'; value: string }
33export type Rule = { server: string; conditions: Condition[] }
34
35// 规则串:「服务器: 条件 | 条件; 服务器: 条件」。条件:path~子串、file=文件名、text=文字。
36// 写错的段直接跳过;一条有效条件都没有的规则也跳过(不会因此把说明删掉)。
37export function parseRules(spec: string): Rule[] {
38  return spec
39    .split(/[;\n]/)
40    .map(segment => segment.trim())
41    .flatMap(segment => {
42      const colon = segment.indexOf(':')
43      if (colon <= 0) return []
44      const server = segment.slice(0, colon).trim().toLowerCase()
45      const conditions = segment
46        .slice(colon + 1)
47        .split('|')
48        .flatMap((raw): Condition[] => {
49          const m = /^\s*(path)\s*~\s*(.+?)\s*$|^\s*(file|text)\s*=\s*(.+?)\s*$/.exec(raw)
50          if (!m) return []
51          if (m[1] && m[2]) return [{ kind: 'path', value: m[2].toLowerCase() }]
52          if (m[3] && m[4]) return [{ kind: m[3] as 'file' | 'text', value: m[4] }]
53          return []
54        })
55      return server && conditions.length > 0 ? [{ server, conditions }] : []
56    })
57}
58
59async function keeper($: Api, rules: Rule[]): Promise<Keep> {
60  const cwd = await $.session.cwd()
61  const home = (await $.env.get('HOME')) ?? ''
62  const lower = cwd.toLowerCase()
63  const conditions = rules.flatMap(r => r.conditions)
64
65  // file=:从当前目录往上找,在 HOME 之下时找到 HOME 为止
66  const files = new Map<string, boolean>()
67  const names = [...new Set(conditions.filter(c => c.kind === 'file').map(c => c.value))]
68  if (names.length > 0) {
69    const stop = home !== '' && cwd.startsWith(home) ? home : ''
70    const dirs: string[] = []
71    for (let d = cwd; d.length > stop.length && d !== '/'; d = d.slice(0, d.lastIndexOf('/')) || '/') dirs.push(d)
72    for (const name of names) {
73      files.set(name, (await Promise.all(dirs.map(d => $.fs.exists(`${d}/${name}`)))).some(Boolean))
74    }
75  }
76  // text=:CLAUDE.md / AGENTS.md(含上级目录与 @include)
77  const needsText = conditions.some(c => c.kind === 'text')
78  const instructions = needsText ? await $.fs.ancestors({ names: ['CLAUDE.md', 'AGENTS.md'] }).catch(() => []) : []
79
80  const holds = (c: Condition) =>
81    c.kind === 'path' ? lower.includes(c.value) : c.kind === 'file' ? files.get(c.value) === true : instructions.some(i => i.content.includes(c.value))
82
83  return server => {
84    const rule = rules.find(r => r.server === server.toLowerCase())
85    return rule === undefined || rule.conditions.some(holds)
86  }
87}
88
89export const register: Register = (on, options) => {
90  // 没配规则就什么都不做:不改说明,也不显示提示
91  const rules = parseRules(typeof options.rules === 'string' ? options.rules : '')
92  if (rules.length === 0) return
93
94  on('prompt.attachment', { type: 'mcp_instructions_delta' }, async ($, e, next) => {
95    const result = await next(e)
96    if (!result.text) return result
97    const keep = await keeper($, rules)
98    const dropped = droppedServers(result.text, keep)
99    if (dropped.length === 0) return result
100    const text = filterInstructions(result.text, keep)
101    if (await read($, notified)) return { ...result, text }
102    await update($, notified, () => true)
103    notice = { servers: dropped, saved: result.text.length - (text?.length ?? 0) }
104    $.ui.invalidate('ui.render')
105    $.clock.after(NOTICE_MS, () => {
106      notice = null
107      $.ui.invalidate('ui.render')
108    })
109    return { ...result, text }
110  })
111
112  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
113    const below = await next(e)
114    if (!notice || e.props.hasSurvey) return below
115    const { Box, Text } = $.ui.resolve(e)
116    return (
117      <Box flexDirection="column">
118        <Box flexDirection="row" gap={1}>
119          <Text color="success" bold>
120            ▍精简
121          </Text>
122          <Text>本目录省略了 {notice.servers.join('、')} 的 MCP 说明</Text>
123          <Text dimColor>−{notice.saved} 字符</Text>
124        </Box>
125        {below}
126      </Box>
127    )
128  })
129}
130
types/index.d.ts 11 lines
1export type CtxSlimNotified = boolean
2
3declare module 'claude-code' {
4  interface PluginState {
5    'ctx-slim': {
6      /** whether this session has already shown the trimming notice */
7      notified: CtxSlimNotified
8    }
9  }
10}
11