SLOPSHOPPER

chat-inventory

In this chat 侧边栏:目标与待办、调用了哪些连接器、用了哪些技能、读写了哪些文件(纯追踪,零 Token)

newpaneguardcommandtoastprompt
v0.1.0no licenseupdated 2026-10-23weizhongl/claude-code-mods/chat-inventory
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · chat-inventory
│ ┃ In this chat ✕ › fix the failing auth test and add an audit log call │ ┃ Goal & To-dos │ ┃ fix the failing auth test and add an audit ⏺ Read(src/auth.ts) │ ┃ log call ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Connectors ⎿ Added 2 lines, removed 1 line │ ┃ 还没调用任何连接器 ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ Active Skills │ ┃ 还没用到技能 ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ Files ✻ Worked for 42s · done 4:20 PM │ ┃ 已读取 1 · 新建/修改 3(点文件 = │ ┃ 在文件管理器里定位) › /inventory │ ┃ ✎ ⎿ chat-inventory: In this chat 侧边栏已打开。 │ ┃ + │ ┃ + │ ┃ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · In this chat
Goal & To-dos fix the failing auth test and add an audit log call Connectors 还没调用任何连接器 Active Skills 还没用到技能 Files 已读取 1 · 新建/修改 3(点文件 = 在文件管理器里定位) ✎ + +
README

Claude Code Mods

十个给 Claude Code 用的 Mod。只能装在 Claude Code(终端和桌面 App 的 Code 页)。Codex、Anti-Gravity 没有 Mod 接口,装不了。

先装 switchboard(总控台),其余随意挑。

安装

终端里:

/plugin install switchboard --marketplace weizhongl/claude-code-mods

第一次会问 Add marketplace?,回 y,再选 user scope。其余 Mod 把名字换掉即可:usage-gauge、brain-dump、helper-desk、cache-clock、handoff、reflect、chat-inventory、screen-share、replay-theater。

在 shell 里:

claude plugin install switchboard --marketplace weizhongl/claude-code-mods

桌面 App(/plugin 在那里不能用):点输入框旁的 +,选 Plugins → Add marketplace,填 weizhongl/claude-code-mods,再逐个 Add plugin。

每个 Mod 怎么用

Mod怎么用注意
switchboard输入 /mods 打开总控台。每个 Mod 一行:On/Off、Hide UI Panel。输入框上方那一行条(额度、缓存倒计时、任务进度、👁 按钮)也是它画的:平时只占一行,别的 Mod 有事才在它旁边多出一行。条的右端有按钮:「收起」变成一小行(只剩几个百分比和倒计时),再点「隐藏」只留一个「📊 展开」按钮;也可以输入 /bar(或 /bar full、/bar mini、/bar off)切换,你的选择会记住。进度条在桌面版是细线,在终端里是小方块。也可以 /mods off 名字、/mods on 名字、/mods hide 名字、/mods show 名字。新放进同一个文件夹的 Mod 会自动出现。Off = 该 Mod 的钩子立刻停手;Hide = 后台照跑,只收起面板。
usage-gauge自动。显示在总控台那一行条里:对话上下文用了多少、5 小时(会话)额度、本周额度,各有短进度条、颜色(绿 / 黄 / 红)和重置时间,最后是本次对话花费。/gauge 看文字版。读的是引擎自己的数字(和状态栏同一份),零 Token。额度数字要等发过一条消息、引擎读到后才出现。
brain-dump直接打字或语音说一大段(默认 180 字以上、没有排版)。它会拦住,先问你 2–3 个问题;你在输入框直接回答(回「跳过」= 不补充),然后重写成 The Task / The Why / Guardrails / What Done Looks Like 再发给 Claude。输入框上方有 跳过提问 / 原话直接发 / 放弃 三个按钮,也可用 /dump skip、/dump raw、/dump cancel。消息开头加 >> 可以跳过拦截。会用到两次小的模型调用(提问用 haiku,重写用 sonnet)。
helper-desk自动。Claude 有待办清单时,一行条里显示「任务 n/m」和短进度条;Claude 派出子代理时,侧边栏出现 Helper Desk,每个子代理一张卡(进度、正在做什么、读入/输出 token、花费)。/helpers 手动打开。子代理花费是按公开标价估算;整段对话总花费取自引擎账本。零额外 Token。
cache-clock自动。第一条回复之后,一行条里显示「⏱ mm:ss」。点旁边的 Keep Warm 或输入 /keep warm,快冷之前会自动发一条极小的心跳续命。你发下一条真实消息,或连续保温满 2 小时,会自动关闭。拿不到服务器的缓存时间,所以倒计时是推算(订阅登入 60 分钟,API 5 分钟,并按真实缓存命中自动校正)。设置里的「缓存有效分钟数」可固定。
handoff对话累计到 250,000 token、或缓存变冷时,输入框上方提醒你。按「生成交接」或输入 /handoff,会写出一份详细的交接提示词(存在 ~/.claude/handoffs/,可直接发给同事),再按 Start Fresh Chat with Handoff 带去新对话。生成会花 Token,默认只提醒、不自动生成(设置里「触发后做什么」选 generate 才会自动生成)。桌面 App 不允许自动开新对话时,会改成复制到剪贴板。
reflect你在对话里说「以后永远不要…」「每次都要…」「记住…」这类话,输入框上方弹出小卡片,一键存进:内置 Memory / 项目 CLAUDE.md(没有就 AGENTS.md)/ 你指定的文件 / 仅本次忽略。只在句子里有规则关键词时才调用一次小模型判断。设置里可填默认的第二大脑文件路径。
chat-inventory自动。侧边栏「In this chat」:目标与待办、调用过的连接器(含次数)、用过的技能、读过和新建/修改过的文件(点文件在文件管理器里定位)。/inventory 手动打开。纯追踪,零 Token。
screen-share输入 /screen share 开或关,或点一行条右端的 👁 按钮。开启后,画面上的邮箱、电话、金额、API key、公司名、人名遮成灰条。只改画面,不改真实数据;不遮输入框里正在打的字。人名只遮设置里填的名单和「客户:xxx」「陈小姐」这类写法,没有真正的人名识别。
replay-theater自动录下 Claude 用 Edit / Write / NotebookEdit 对文件做的每次修改。输入 /replay 打开:选文件、播放/暂停、上一步/下一步、点步骤号跳转,看每一步的 diff。Revert 按钮按一次变成「再按一次确认」,第二次才会写回文件。/replay clear 清空。用 Bash 改文件(如 sed)录不到;单个文件超过 20 万字不录。记录只在当前会话。

目录

每个 Mod 一个文件夹:.claude-plugin/plugin.json、hooks/hooks.json、hooks/register.tsx,有状态的还有 types/index.d.ts。switchboard、screen-share、replay-theater 带自动测试(claude plugin test <文件夹>)。

各 Mod 里的 hooks/flag.ts 是同一小段代码(读总控台的开关),Mod 之间不能共享文件,所以每个放一份。

Source 3 files
hooks/register.tsx 272 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, ToolCallInput } from 'claude-code'
3
4import type { InventoryTodo } from '../types'
5import { flagOf } from './flag'
6
7const MOD = 'chat-inventory'
8const PANE = 'chat-inventory'
9const SHOWN = 40
10
11const goal = atom({ plugin: 'chat-inventory', key: 'goal' } as const, '')
12const todos = atom({ plugin: 'chat-inventory', key: 'todos' } as const, [])
13const connectors = atom({ plugin: 'chat-inventory', key: 'connectors' } as const, {})
14const skills = atom({ plugin: 'chat-inventory', key: 'skills' } as const, [])
15const filesRead = atom({ plugin: 'chat-inventory', key: 'filesRead' } as const, [])
16const filesChanged = atom({ plugin: 'chat-inventory', key: 'filesChanged' } as const, {})
17
18type PlanInput = {
19  items?: { content: string; status: InventoryTodo['status'] }[]
20  advance?: boolean
21  done?: number[]
22  start?: number
23}
24
25const ICONS: [RegExp, string][] = [
26  [/mail/i, '✉'],
27  [/calendar/i, '📅'],
28  [/drive|file/i, '📁'],
29  [/slack/i, '💬'],
30  [/github/i, '🐙'],
31  [/chrome|browser/i, '🌐'],
32  [/wordpress/i, '📝'],
33  [/notion/i, '📓'],
34  [/figma|canva|design|pencil/i, '🎨'],
35  [/exa|search|research/i, '🔎'],
36  [/ads|meta|facebook/i, '📣'],
37  [/pdf|word|powerpoint/i, '📄'],
38]
39
40const iconOf = (server: string) =>
41  ICONS.find(([pattern]) => pattern.test(server))?.[1] ?? '🔌'
42
43const shortServer = (server: string) =>
44  /^[0-9a-f]{8}-[0-9a-f]{4}-/.test(server) ? `${server.slice(0, 8)}…` : server
45
46const withoutRepeat = (list: string[], item: string, cap = 200) =>
47  [...list.filter(one => one !== item), item].slice(-cap)
48
49const applyPlan = (list: InventoryTodo[], input: PlanInput): InventoryTodo[] => {
50  if (input.items !== undefined) {
51    return input.items.map(one => ({ content: one.content, status: one.status }))
52  }
53  const next = list.map(one => ({ ...one }))
54  if (input.advance === true) {
55    const active = next.find(one => one.status === 'in_progress')
56    if (active !== undefined) active.status = 'completed'
57    const waiting = next.find(one => one.status === 'pending')
58    if (waiting !== undefined) waiting.status = 'in_progress'
59  }
60  for (const n of input.done ?? []) {
61    const one = next[n - 1]
62    if (one !== undefined) one.status = 'completed'
63  }
64  const started = input.start === undefined ? undefined : next[input.start - 1]
65  if (started !== undefined) started.status = 'in_progress'
66
67  return next
68}
69
70const record = async ($: EngineInterface, e: ToolCallInput, isNewFile: boolean) => {
71  if (e.tool === 'Read') {
72    await update($, filesRead, list => withoutRepeat(list, e.file_path))
73  } else if (e.tool === 'Write') {
74    await update($, filesChanged, all => ({
75      ...all,
76      [e.file_path]: all[e.file_path] ?? (isNewFile ? 'created' : 'edited'),
77    }))
78  } else if (e.tool === 'Edit') {
79    await update($, filesChanged, all => ({
80      ...all,
81      [e.file_path]: all[e.file_path] ?? 'edited',
82    }))
83  } else if (e.tool === 'NotebookEdit') {
84    await update($, filesChanged, all => ({
85      ...all,
86      [e.notebook_path]: all[e.notebook_path] ?? 'edited',
87    }))
88  } else if (e.tool === 'Skill') {
89    await update($, skills, list => withoutRepeat(list, e.skill, 50))
90  } else if (e.tool === 'TodoWrite') {
91    await update($, todos, () =>
92      e.todos.map(one => ({ content: one.content, status: one.status })),
93    )
94  } else if (e.tool === 'TaskCreate') {
95    await update($, todos, list => [
96      ...list,
97      { content: e.subject, status: 'pending' as const },
98    ])
99  } else if (e.tool === 'TaskUpdate') {
100    await update($, todos, list =>
101      list.flatMap((one, at) => {
102        if (String(at + 1) !== e.taskId) return [one]
103        if (e.status === 'deleted') return []
104        return [
105          {
106            content: e.subject ?? one.content,
107            status: e.status ?? one.status,
108          },
109        ]
110      }),
111    )
112  } else if (e.tool.startsWith('mcp__')) {
113    const server = e.tool.split('__')[1] ?? e.tool
114    if (/^ccd_/.test(server)) {
115      if (e.tool === 'mcp__ccd_turn__update_plan') {
116        const input = e as unknown as PlanInput
117        await update($, todos, list => applyPlan(list, input))
118      }
119
120      return
121    }
122    await update($, connectors, all => ({ ...all, [server]: (all[server] ?? 0) + 1 }))
123  }
124}
125
126const locate = async ($: EngineInterface, path: string) => {
127  try {
128    const isWindows = (await $.env.get('OS')) === 'Windows_NT'
129    const kernel = isWindows
130      ? 'Windows'
131      : (await $.process.run(['uname', '-s'])).stdout.trim()
132    const folder = path.replace(/[\\/][^\\/]*$/, '')
133    const argv =
134      kernel === 'Windows'
135        ? ['explorer', `/select,${path.replaceAll('/', '\\')}`]
136        : kernel === 'Darwin'
137          ? ['open', '-R', path]
138          : ['xdg-open', folder]
139    await $.process.run(argv)
140  } catch {
141    await $.ui.copy({ text: path })
142    $.ui.toast(`路径已复制:${path}`)
143  }
144}
145
146const tail = (path: string) => {
147  const parts = path.split(/[\\/]/)
148
149  return parts.length <= 3 ? path : `…/${parts.slice(-3).join('/')}`
150}
151
152export const register: Register = on => {
153  on('session.start', async ($, e, next) => {
154    await $.command.register({
155      name: 'inventory',
156      description: '打开「In this chat」侧边栏:目标、连接器、技能、文件',
157    })
158    void $.ui.open({ id: PANE, title: 'In this chat' })
159
160    return next(e)
161  })
162
163  on('command.run', { command: 'inventory' }, async $ => {
164    await $.ui.open({ id: PANE, title: 'In this chat' })
165
166    return { text: 'In this chat 侧边栏已打开。' }
167  })
168
169  on('prompt.submit', async ($, e, next) => {
170    const isTyped = e.command === undefined && e.isMeta !== true
171    if (isTyped && e.origin.kind === 'composer' && e.text.trim().length >= 12) {
172      if ((await read($, goal)) === '') {
173        await update($, goal, () => e.text.trim().replace(/\s+/g, ' ').slice(0, 160))
174      }
175    }
176
177    return next(e)
178  }).catch(($, e, next) => next(e))
179
180  on('tool.call', async ($, e, next) => {
181    if (!(await flagOf($, MOD)).isOn) return next(e)
182    const isNewFile =
183      e.tool === 'Write' && !(await $.fs.exists(e.file_path).catch(() => true))
184    const ran = await next(e)
185    if (ran.deny === undefined) await record($, e, isNewFile)
186
187    return ran
188  }).catch(($, e, next) => next(e))
189
190  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
191    const { Box, Text, Button } = $.ui.resolve(e)
192    const flag = await flagOf($, MOD)
193    if (!flag.isOn || flag.isHidden) {
194      return (
195        <Text dimColor>
196          In this chat 已{flag.isOn ? '隐藏' : '关闭'}(/mods 可重新打开)
197        </Text>
198      )
199    }
200
201    const [aim, steps, servers, used, read_, changed] = [
202      await read($, goal),
203      await read($, todos),
204      await read($, connectors),
205      await read($, skills),
206      await read($, filesRead),
207      await read($, filesChanged),
208    ]
209    const done = steps.filter(one => one.status === 'completed').length
210    const connectorRows = Object.entries(servers).sort((a, b) => b[1] - a[1])
211    const changedRows = Object.entries(changed)
212
213    const FileRow = ({ path, tag }: { path: string; tag?: string }) => (
214      <Button
215        key={`${tag ?? 'r'}:${path}`}
216        plain
217        onPress={() => locate($, path)}
218      >
219        {tag === undefined ? '' : `${tag} `}
220        {tail(path)}
221      </Button>
222    )
223
224    return (
225      <Box flexDirection="column" gap={1}>
226        <Box flexDirection="column">
227          <Text bold>
228            Goal & To-dos {steps.length > 0 ? `(${done}/${steps.length})` : ''}
229          </Text>
230          <Text>{aim === '' ? '(还没有目标)' : aim}</Text>
231          {steps.map((step, at) => (
232            <Text key={`todo:${at}`} dimColor={step.status === 'completed'}>
233              {step.status === 'completed'
234                ? '☑'
235                : step.status === 'in_progress'
236                  ? '◐'
237                  : '☐'}{' '}
238              {step.content}
239            </Text>
240          ))}
241        </Box>
242        <Box flexDirection="column">
243          <Text bold>Connectors</Text>
244          {connectorRows.length === 0 && <Text dimColor>还没调用任何连接器</Text>}
245          {connectorRows.map(([server, count]) => (
246            <Text key={`mcp:${server}`}>
247              {iconOf(server)} {shortServer(server)} <Text dimColor>×{count}</Text>
248            </Text>
249          ))}
250        </Box>
251        <Box flexDirection="column">
252          <Text bold>Active Skills</Text>
253          {used.length === 0 && <Text dimColor>还没用到技能</Text>}
254          {used.map(name => (
255            <Text key={`skill:${name}`}>• {name}</Text>
256          ))}
257        </Box>
258        <Box flexDirection="column">
259          <Text bold>Files</Text>
260          <Text dimColor>已读取 {read_.length} · 新建/修改 {changedRows.length}(点文件 = 在文件管理器里定位)</Text>
261          {changedRows.slice(-SHOWN).map(([path, kind]) => (
262            <FileRow path={path} tag={kind === 'created' ? '+' : '✎'} />
263          ))}
264          {read_.slice(-SHOWN).map(path => (
265            <FileRow path={path} />
266          ))}
267        </Box>
268      </Box>
269    )
270  })
271}
272
hooks/flag.ts 14 lines
1import type { EngineInterface } from 'claude-code'
2
3const FLAGS = { plugin: 'switchboard', key: 'flags' } as const
4
5export type Flag = { isOn: boolean; isHidden: boolean }
6
7// Switchboard owns the flags. A mod it has never heard of counts as on, shown.
8export async function flagOf($: EngineInterface, mod: string): Promise<Flag> {
9  const { value } = await $.state.get(FLAGS)
10  const own = value?.[mod]
11
12  return { isOn: own?.isOn ?? true, isHidden: own?.isHidden ?? false }
13}
14
types/index.d.ts 20 lines
1export type InventoryTodo = {
2  content: string
3  status: 'pending' | 'in_progress' | 'completed'
4}
5
6declare module 'claude-code' {
7  interface PluginState {
8    'chat-inventory': {
9      goal: string
10      todos: InventoryTodo[]
11      connectors: Record<string, number>
12      skills: string[]
13      filesRead: string[]
14      filesChanged: Record<string, 'created' | 'edited'>
15    }
16    // Read-only view of Switchboard's value (it alone writes it).
17    switchboard: { flags: Record<string, { isOn: boolean; isHidden: boolean }> }
18  }
19}
20