SLOPSHOPPER

show-mode

hidden 模式仅展示当前思考,回合结束清理临时显示,并保存文件 diff 与思考过程供回看;显式指定 off/hidden 切换

newspinnerrowsguardcommandtoast
v0.3.0no licenseupdated 2026-10-05h1048576/claude-mods/show-mode
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · show-mode
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ 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 › /show-mode ⎿ show-mode: 用法:/show-mode off|hidden|status|view [code|think|all|clear];切换模式必须指定 off 或 hidden ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Claude Code Mods

个人 Claude Code 插件(mods)集合。每个子目录都是一个独立的 Claude Code 插件,通过 hooks 实现界面定制与行为增强,在本会话中热加载,无需重启。

当前包含的插件

插件版本说明
show-mode0.3.0hidden 模式仅实时展示当前思考,隐藏工具调用与过程痕迹,保存文件 diff 与思考过程供回看
shell-mode0.3.0用 /shell-mode 切换 Bash 控制台,显示输出颜色,退出后将命令和结果保留在 CLI 中

环境要求

  • Claude Code(支持插件/mod 热加载的版本)
  • 开发或运行测试时需要 Node.js(TypeScript 类型检查与测试运行)

安装

方式一:克隆到 mods 目录(推荐)

将本仓库克隆到 Claude Code 的 mods 目录 ~/.claude/mods/ 下,插件会在会话中自动热加载:

# Windows(PowerShell)
git clone <本仓库地址> "$env:USERPROFILE\.claude\mods"

# macOS / Linux
git clone <本仓库地址> ~/.claude/mods

注意:如果 ~/.claude/mods 目录已存在,可以克隆到临时目录后把需要的插件子目录(如 show-mode/)复制进去。

克隆后的目录结构:

~/.claude/mods/
└── show-mode/
    ├── .claude-plugin/
    │   ├── plugin.json        # 插件清单(名称、版本、用户配置项)
    │   └── types/             # claude-code API 类型定义
    ├── hooks/
    │   ├── hooks.json         # 模块入口声明
    │   ├── register.tsx       # 插件主逻辑(hooks 注册)
    │   └── register.test.tsx  # 测试
    ├── types/
    │   └── index.d.ts         # 插件状态与数据结构类型
    └── tsconfig.json

方式二:作为本地插件启用

如果只想启用其中某个插件,也可以在 Claude Code 会话内通过 /plugin 命令管理,或将插件目录添加到本地 marketplace 后安装。

配置

显示模式(userConfig)

show-mode 在插件清单(show-mode/.claude-plugin/plugin.json)中声明了一个用户配置项:

配置项类型可选值默认值说明
modestringoff / hiddenhiddenoff=正常显示且不新增记录;hidden=仅展示当前思考,并保存文件 diff 与思考过程
  • off:正常显示所有工具调用、思考与过程叙述,不新增任何记录。
  • hidden:隐藏工具调用行与过程叙述的最终渲染,改为在 Spinner 状态行上方实时展示当前思考(下一块替换上一块,回合结束即清理);同时在后台保存:
  • 文件更新 diff(最多 100 条)
  • 思考块、过程叙述、工具/技能调用记录(最多 100 条,每条正文最多保留 5 行)
  • 超出上限时自动删除最旧的记录
  • 已确认的最终回答恢复原生 Markdown 渲染;所有工具调用、结果和运行提示均隐藏,包括错误及中断。

运行时模式优先于清单配置:通过 /show-mode 命令切换后,以切换后的值为准;未切换时回落到清单配置的默认值。

使用

安装后在会话中使用 /show-mode 命令:

/show-mode off          # 切换到正常显示模式
/show-mode hidden       # 切换到仅展示当前思考模式
/show-mode status       # 查看当前模式与保存记录统计
/show-mode view         # 回看最近的记录(文件 diff + 思考过程,每类最近 10 条)
/show-mode view code    # 只回看文件更新 diff(diff 语法着色,最近 10 条)
/show-mode view think   # 只回看思考过程(含工具/技能调用记录,最近 10 条)
/show-mode view all     # 回看更多记录(每类最近 30 条)
/show-mode view clear   # 清空全部保存的文件 diff 和思考过程记录

说明:

  • 回看不受当前显示模式限制,off 模式下也可以查看之前 hidden 模式保存的记录。
  • 回看默认只显示最近的记录(每类 10 条),view all 可查看每类最近 30 条。
  • 回看输出按记录类型着色渲染:code 记录按 diff 增删行着色,think 记录标题高亮、正文每行截断不折行。
  • 记录按时间排序展示,每条带编号、时间戳、类型与来源(新增/修改、所用工具等)。
  • hidden 模式下出错或被中断的工具调用及结果也会隐藏,off 模式下恢复正常显示。
  • hidden 模式下思考正文与 Spinner 状态行显示在同一列,状态行始终位于思考正文之后。
  • 文件 diff 不依赖 Git,新文件、未暂存文件和非 Git 目录均可回看。

Shell Mode

在项目目录启动 Claude Code 时加载插件:

claude --plugin-dir "$env:USERPROFILE\.claude\mods\shell-mode"

先在 Claude Code 输入框中输入 /shell-mode,然后在打开的控制台 $ 后输入命令:

/shell-mode         # 未开启时进入 Bash 模式
pwd
ls
cd internal
git status
/shell-mode         # 已开启时退出,返回正常 Claude 对话

进入后,控制台取得键盘焦点,在 $ 后输入命令并回车。再次输入 /shell-mode、输入 exit 或按 Esc 关闭控制台,返回 Claude 对话。在 Claude 输入框和 Shell 控制台中输入 /shell-mode 都可以退出已开启的模式。/shell-mode on 和 /shell-mode off 仍可用于明确开启或关闭,无须强制指定参数。控制台位置由 Claude Code 决定:通常位于输入框上方,全屏且窗口足够宽时位于侧边。

控制台直接显示命令和原始输出,不使用带插件名前缀的日志,也不触发 Prompt dropped by a hook 提示,不显示成功提示或退出码:

$ ls
config docs go.mod main.go

$ git status
On branch test
nothing to commit, working tree clean

$

每条命令由 Git Bash 直接执行,不主动启动模型回合。实际命令与输出保存在本地,CLI 的原生命令记录仅保存短引用,模型后续只能读取该引用。支持管道、重定向和连续 cd,后续命令沿用上次结束时的目录。退出后再次进入,从 Claude 会话目录开始。

每条命令完成后增加一个空行,分隔相邻命令块。退出控制台时,把当前保留的命令和结果保存到 %LOCALAPPDATA%/Claude/shell-mode/history/,再通过 CLI 命令记录中的引用读取并渲染,继续显示在对话屏幕上,保留换行和颜色。通过 /shell-mode、exit、Esc 或窗格关闭按钮退出均会保留输出。重绘和插件重载时也从本地存档恢复;清理该目录会使对应的历史输出无法再次显示。

保留 Claude 原生窗格的自动输入焦点。窗格的 × 和输入框的回车图标由 Claude 绘制,当前公开接口没有隐藏开关,因此这两个图标保留。

直接显示的 ls 按控制台宽度多列排版;ll 等别名仍使用 Bash 配置。普通文件、目录、链接等颜色由 ls 和 LS_COLORS 确定。git status、git diff 等直接显示的 Git 输出启用 Git 自带的颜色;其他程序明确输出的 ANSI 颜色也会保留。不根据文件后缀或输出文字猜测颜色,没有颜色信息的内容保持默认颜色。显式 ls -1、ls -l、ls --color=never 等选项优先。管道、重定向、变量展开和复合命令不注入列格式或颜色,以免改变命令处理的数据。

Windows 优先使用 CLAUDE_CODE_GIT_BASH_PATH 指定的 Bash,否则查找 Git for Windows 的常见安装目录,避免误用 WSL 的 bash.exe。

每条命令使用登录 Bash 加载 /etc/profile 和个人登录配置,再开启 alias 展开,因此可以使用配置中的 ll 等别名。通常由 ~/.bash_profile 加载 ~/.bashrc。加载配置后恢复本次工作目录,保持连续 cd 的行为。

每条命令使用独立 Bash 进程,执行结束后显示输出,最多运行十分钟。只保持工作目录,变量、函数等 Shell 状态不跨命令保持;交互式终端程序不适用。插件重载或会话切换后回到正常对话。

命令输出保留原始换行,制表符转换为空格,ANSI 颜色转换为原生 Text 样式;光标移动等控制序列会移除。每次输出最多显示 50,000 字符、200 行,超过限制会显示截断提示。控制台历史最多保留最近 50,000 字符、200 行,并限制颜色片段数量,超出时删除最旧的内容;关闭时保存这部分输出,再释放控制台的临时历史。

开发

cd show-mode
npx tsc --noEmit        # 类型检查
  • 插件入口:show-mode/hooks/register.tsx,通过 register: Register 导出,使用 on() 订阅生命周期与渲染事件。
  • 状态存储:使用 claude-code 提供的 atom / read / update,按 plugin + key 隔离。
  • 类型定义:show-mode/types/index.d.ts 扩展了 claude-code 的 PluginState 接口。
  • 测试:show-mode/hooks/register.test.tsx,基于 claude-code/testing。

目录结构

.
├── README.md
└── show-mode/           # show-mode 插件
    ├── .claude-plugin/  # 插件清单与官方类型
    ├── hooks/           # hooks 逻辑与测试
    ├── types/           # 插件类型声明
    └── tsconfig.json    # TypeScript 配置
Source 2 files
hooks/register.tsx 615 lines
1import { atom, read, update } from 'claude-code'
2import type { Register, EngineInterface, TextProps, ToolCallInput, ToolResultOf } from 'claude-code'
3
4import type { LiveThought, ShowMode, StoredDiff, StoredThought } from '../types'
5
6// 运行时模式:存 $.state;null 表示"未设置",读时回落到清单配置值。
7const mode = atom({ plugin: 'show-mode', key: 'mode' } as const, null as ShowMode | null)
8
9// hidden 模式下保存文件 diff 和思考过程,off 不新增记录。
10const diffs = atom({ plugin: 'show-mode', key: 'diffs' } as const, [] as StoredDiff[])
11const thoughts = atom({ plugin: 'show-mode', key: 'thoughts' } as const, [] as StoredThought[])
12const liveThought = atom({ plugin: 'show-mode', key: 'liveThought' } as const, null as LiveThought | null)
13const processRows = atom(
14  { plugin: 'show-mode', key: 'processRows' } as const,
15  {} as Record<string, boolean>,
16)
17
18// 按主会话/子代理隔离。只有随后确实调用了工具的正文,才标记为过程叙述。
19const pending = new Map<string, Map<string, string>>()
20const loopKey = (agentId?: string) => agentId ?? 'main'
21
22const DIFF_LIMIT = 100
23const THOUGHT_LIMIT = 100
24const VIEW_LIMIT = 10
25const VIEW_ALL_LIMIT = 30
26const THOUGHT_LINE_LIMIT = 5
27const ROW_LIMIT = 2000
28
29// 时:分:秒,记录的时间戳。
30const stamp = (at: number) => {
31  const d = new Date(at)
32  const p = (n: number) => String(n).padStart(2, '0')
33
34  return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
35}
36
37const MODES: readonly ShowMode[] = ['off', 'hidden']
38
39const MODE_LABEL: Record<ShowMode, string> = {
40  off: '正常显示',
41  hidden: '仅展示当前思考',
42}
43
44const parseMode = (text: string): ShowMode | null => {
45  const lower = text.trim().toLowerCase()
46
47  return MODES.includes(lower as ShowMode) ? (lower as ShowMode) : null
48}
49
50type DiffEntry = Omit<StoredDiff, 'id' | 'at'>
51type Hunk = ToolResultOf<'Write'>['structuredPatch'][number]
52
53const diffHeaders = (filePath: string, created: boolean) =>
54  `--- ${created ? '/dev/null' : JSON.stringify(filePath)}\n+++ ${JSON.stringify(filePath)}\n`
55
56const formatPatch = (filePath: string, created: boolean, hunks: readonly Hunk[]) =>
57  diffHeaders(filePath, created) + hunks.map((hunk) =>
58    `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@\n${hunk.lines.join('\n')}\n`,
59  ).join('')
60
61// 引擎未返回 hunk、但提供了完整前后内容时,生成完整文件替换 diff。
62// 不依赖 Git,因此新文件、未暂存文件和非 Git 目录均可回看。
63const contentLines = (content: string) => {
64  if (content === '') return []
65  const lines = content.split('\n')
66  if (content.endsWith('\n')) lines.pop()
67  return lines
68}
69
70const fullDiff = (filePath: string, created: boolean, before: string, after: string) => {
71  const oldLines = contentLines(before)
72  const newLines = contentLines(after)
73  const removed = oldLines.map((line) => `-${line}`)
74  const added = newLines.map((line) => `+${line}`)
75  if (before !== '' && !before.endsWith('\n')) removed.push('\\ No newline at end of file')
76  if (after !== '' && !after.endsWith('\n')) added.push('\\ No newline at end of file')
77  if (oldLines.length === 0 && newLines.length === 0) return diffHeaders(filePath, created)
78  return formatPatch(filePath, created, [{
79    oldStart: oldLines.length === 0 ? 0 : 1,
80    oldLines: oldLines.length,
81    newStart: newLines.length === 0 ? 0 : 1,
82    newLines: newLines.length,
83    lines: [...removed, ...added],
84  }])
85}
86
87const collectDiffs = (tool: string, output: unknown): DiffEntry[] => {
88  if (!output || typeof output !== 'object') return []
89
90  if (tool === 'Write' || tool === 'Edit') {
91    const result = output as ToolResultOf<'Write'> | ToolResultOf<'Edit'>
92    // staged 表示等待机器所有者审阅,文件尚未写入。
93    if (result.staged || typeof result.filePath !== 'string') return []
94    const write = 'content' in result ? result : undefined
95    const created = write?.type === 'create'
96    const before = created ? '' : result.originalFile
97    const after = 'content' in result ? result.content : typeof before === 'string'
98      ? result.replaceAll
99        ? before.split(result.oldString).join(result.newString)
100        : before.replace(result.oldString, () => result.newString)
101      : null
102    const hunks = result.structuredPatch
103    if (!created && (!Array.isArray(hunks) || hunks.length === 0)
104      && typeof before === 'string' && before === after) return []
105    const diff = Array.isArray(hunks) && hunks.length > 0
106      ? formatPatch(result.filePath, created, hunks)
107      : typeof before === 'string' && typeof after === 'string'
108        ? fullDiff(result.filePath, created, before, after)
109        : '文件已修改,但工具未提供可用的 diff 或完整修改前内容。'
110    return [{ filePath: result.filePath, action: created ? 'create' : 'update', tool, diff }]
111  }
112
113  if (tool === 'Bash') {
114    // 仅使用引擎明确归属于本次命令的文件 diff,避免混入用户已有的工作区修改。
115    const result = output as ToolResultOf<'Bash'>
116    return (result.bashEditDiff?.files ?? [])
117      .filter((file) => !file.deleted && (file.created || file.hunks.length > 0))
118      .map((file) => ({
119        filePath: file.filePath,
120        action: file.created ? 'create' as const : 'update' as const,
121        tool,
122        diff: formatPatch(file.filePath, Boolean(file.created), file.hunks),
123      }))
124  }
125  return []
126}
127
128// 每次文件更新独立记录,最多保留最近 100 条,超出时删除最旧记录。
129const storeDiffs = async ($: EngineInterface, entries: DiffEntry[]) => {
130  if (entries.length === 0) return
131  try {
132    if ((parseMode((await read($, mode)) ?? '') ?? configured) !== 'hidden') return
133    const at = Date.now()
134    await update($, diffs, (list) => {
135      const firstId = list.length > 0 ? list[list.length - 1]!.id + 1 : 1
136      const next = [...list, ...entries.map((entry, index) => ({ ...entry, id: firstId + index, at }))]
137      return next.slice(-DIFF_LIMIT)
138    })
139  } catch {
140    // 归档失败不能把已经成功的文件更新报告成失败。
141    $.ui.log('show-mode:文件 diff 保存失败', { to: 'debug' })
142  }
143}
144
145const formatEntry = (entry: StoredDiff) =>
146  `── code #${entry.id} ── ${stamp(entry.at)} ── ${entry.action === 'create' ? '新增' : '修改'} ── ${entry.tool}\n${entry.filePath}\n${entry.diff}`
147
148type DiffStyle = 'plain' | 'record' | 'file' | 'hunk' | 'added' | 'removed' | 'context'
149
150const DIFF_STYLES: Record<DiffStyle, TextProps> = {
151  plain: {},
152  record: { color: 'cyan', bold: true },
153  file: { color: 'yellow' },
154  hunk: { color: 'cyan' },
155  added: { color: 'green' },
156  removed: { color: 'red' },
157  context: { dimColor: true },
158}
159
160// 仅解析回看输出的副本,保存的 diff 保持纯文本。
161// hunk 内的 +++ / --- 也可能是代码内容,必须按增删行处理。
162const diffRuns = (text: string) => {
163  const runs: { style: DiffStyle; text: string }[] = []
164  let inHunk = false
165  let filePathNext = false
166  for (const [index, line] of text.split(/\r?\n/).entries()) {
167    let style: DiffStyle = 'plain'
168    if (/^── code #\d+ ── /.test(line)) {
169      style = 'record'
170      inHunk = false
171      filePathNext = true
172    } else if (filePathNext) {
173      style = 'file'
174      filePathNext = false
175    } else if (/^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@/.test(line)) {
176      style = 'hunk'
177      inHunk = true
178    } else if (inHunk) {
179      style = line.startsWith('+') ? 'added'
180        : line.startsWith('-') ? 'removed' : 'context'
181    } else if (line.startsWith('--- ') || line.startsWith('+++ ')) {
182      style = 'file'
183    }
184
185    // 相邻同样式的行合并,避免大 diff 为每行创建一个渲染节点。
186    const content = (index === 0 ? '' : '\n') + line
187    const previous = runs[runs.length - 1]
188    if (previous?.style === style) previous.text += content
189    else runs.push({ style, text: content })
190  }
191  return runs
192}
193
194// 标题另计;正文最多五行,省略提示也放在第五行内。
195const limitThought = (text: string) => {
196  const lines = text.replace(/\r\n?|\u2028|\u2029/g, '\n')
197    .replace(/^\n+|\n+$/g, '').split('\n')
198  if (lines.length <= THOUGHT_LINE_LIMIT) return lines.join('\n')
199  const kept = lines.slice(0, THOUGHT_LINE_LIMIT)
200  kept[THOUGHT_LINE_LIMIT - 1] += ' …(后续省略)'
201  return kept.join('\n')
202}
203
204const showThought = async ($: EngineInterface, kind: LiveThought['kind'], text: string, agentId?: string) => {
205  if (text.trim() === '' || (parseMode((await read($, mode)) ?? '') ?? configured) !== 'hidden') return
206  const visible = limitThought(text)
207  await update($, liveThought, (previous) =>
208    previous?.kind === kind && previous.agentId === agentId && previous.text === visible
209      ? previous : { kind, text: visible, ...(agentId === undefined ? {} : { agentId }) })
210}
211
212// 尚未跟随工具调用的正文是最终回答候选,结束/切换模式时恢复其原生显示。
213const releasePending = async ($: EngineInterface, key?: string) => {
214  const ids = key === undefined
215    ? [...pending.values()].flatMap((rows) => [...rows.keys()])
216    : [...(pending.get(key)?.keys() ?? [])]
217  if (key === undefined) pending.clear()
218  else pending.delete(key)
219  if (ids.length === 0) return
220  await update($, processRows, (known) => {
221    const remaining = { ...known }
222    for (const id of ids) delete remaining[id]
223    return remaining
224  })
225}
226
227const THOUGHT_LABEL: Record<StoredThought['kind'], string> = {
228  thinking: '思考',
229  narration: '过程叙述',
230  tool: '工具调用',
231  skill: '技能调用',
232}
233
234// tool.call 的参数与 tool 同级;所有工具(包括只读工具和 MCP)都归入 think。
235const formatToolCall = (e: ToolCallInput) => {
236  const args = Object.entries(e)
237    .filter(([key]) => !['tool', 'tool_use_id', 'agentId', 'consent'].includes(key))
238    .map(([key, value]) => `${key}: ${typeof value === 'string' ? value : JSON.stringify(value)}`)
239  return `${e.tool}\n${args.join('\n')}`
240}
241
242// think 同样最多保存最近 100 条,工具/技能调用计入这一上限。
243const storeThoughts = async (
244  $: EngineInterface,
245  kind: StoredThought['kind'],
246  texts: string[],
247) => {
248  const entries = texts.filter((text) => text.trim() !== '').map(limitThought)
249  if (entries.length === 0) return
250  try {
251    if ((parseMode((await read($, mode)) ?? '') ?? configured) !== 'hidden') return
252    const at = Date.now()
253    await update($, thoughts, (list) => {
254      const firstId = list.length > 0 ? list[list.length - 1]!.id + 1 : 1
255      const next = [...list, ...entries.map((text, index) => ({
256        id: firstId + index, at, kind, text,
257      }))]
258      return next.slice(-THOUGHT_LIMIT)
259    })
260  } catch {
261    $.ui.log('show-mode:思考过程保存失败', { to: 'debug' })
262  }
263}
264
265const formatThought = (entry: StoredThought) =>
266  `── think #${entry.id} ── ${stamp(entry.at)} ── ${THOUGHT_LABEL[entry.kind]}\n${limitThought(entry.text)}`
267
268// /show-mode status 按需查看,避免在每个分片上弹 toast。
269const stats: {
270  starts: number
271  fires: number
272  chunks: number
273  completes: number
274  appends: number
275  tools: number
276  renders: number
277  kinds: Record<string, number>
278} = { starts: 0, fires: 0, chunks: 0, completes: 0, appends: 0, tools: 0, renders: 0, kinds: {} }
279
280const markProcess = async ($: EngineInterface, agentId?: string) => {
281  const key = loopKey(agentId)
282  const rows = pending.get(key)
283  if (!rows?.size) return
284
285  // 先取走这一批,避免并行工具重复归档同一段正文。
286  pending.delete(key)
287  if ((parseMode((await read($, mode)) ?? '') ?? configured) !== 'hidden') return
288  await update($, processRows, (known) => {
289    const next = { ...known }
290    for (const id of rows.keys()) next[id] = true
291    const ids = Object.keys(next)
292    for (const id of ids.slice(0, Math.max(0, ids.length - ROW_LIMIT))) delete next[id]
293    return next
294  })
295  await storeThoughts($, 'narration', [...rows.values()])
296}
297
298// 清单配置的默认模式,register 时赋值
299let configured: ShowMode = 'hidden'
300
301export const register: Register = (on, options) => {
302  configured = parseMode(String(options.mode ?? '')) ?? 'hidden'
303
304  // 会话开始:注册 /show-mode 命令
305  on('session.start', async ($, e, next) => {
306    await releasePending($)
307    await update($, liveThought, () => null)
308    await $.command.register({
309      name: 'show-mode',
310      description: '切换过程显示,回看或清空 hidden 模式保存的文件 diff 和思考过程',
311      argumentHint: 'off|hidden|status|view [code|think|all|clear]',
312    })
313
314    return next(e)
315  })
316
317  // /show-mode:
318  // - view:两类各最近 10 条;view code / view think:查看对应类型最近 10 条
319  // - view all:两类各最近 30 条;view clear:清空两类保存记录
320  // - 只有显式 off / hidden 才切换模式;无参数仅提示用法
321  on('command.run', { command: 'show-mode' }, async ($, e) => {
322    const arg = e.args.trim().toLowerCase()
323
324    // 回看不受当前显示模式限制,off 仍可以查看之前保存的记录。
325    if (arg === 'view' || arg.startsWith('view ')) {
326      const rest = arg.slice(4).trim()
327      if (rest === 'clear') {
328        await update($, diffs, () => [])
329        await update($, thoughts, () => [])
330        return { text: 'show-mode:已清空全部文件更新 diff 和 think 记录' }
331      }
332      if (!['', 'code', 'think', 'all'].includes(rest)) {
333        return { text: '用法:/show-mode view [code|think|all|clear];默认每类最近 10 条,all 每类最近 30 条,clear 清空全部记录' }
334      }
335      const limit = rest === 'all' ? VIEW_ALL_LIMIT : VIEW_LIMIT
336      // 先取最近记录再格式化,避免为未展示的历史 diff 创建大字符串。
337      const files = rest === 'think' ? [] : ((await read($, diffs)) ?? []).slice(-limit)
338      const thinking = rest === 'code' ? [] : ((await read($, thoughts)) ?? []).slice(-limit)
339      const records = [
340        ...files.map((entry) => ({ kind: 'code', at: entry.at, text: formatEntry(entry) })),
341        ...thinking.map((entry) => ({ kind: 'think', at: entry.at, text: formatThought(entry) })),
342      ].sort((a, b) => a.at - b.at)
343      if (records.length === 0) {
344        const label = rest === 'code' ? '文件更新 diff' : rest === 'think' ? '思考过程' : '文件更新或思考过程'
345        return { text: `show-mode:暂无保存的${label}` }
346      }
347      const counts = [
348        rest === 'think' ? '' : `文件更新 ${files.length} 条`,
349        rest === 'code' ? '' : `思考过程 ${thinking.length} 条`,
350      ].filter(Boolean).join(',')
351      // 纯文本输出也保留块间空行:think 前一行,code 前两行。
352      const content = records.map((entry) =>
353        `${entry.kind === 'code' ? '\n\n\n' : '\n\n'}${entry.text.replace(/\n+$/, '')}`,
354      ).join('')
355      return { text: `show-mode 最近的记录(${counts};每类最多 ${limit} 条):${content}` }
356    }
357
358    const current = parseMode((await read($, mode)) ?? '') ?? configured
359    const wanted = parseMode(arg)
360    if (arg !== 'status' && wanted === null) {
361      return { text: '用法:/show-mode off|hidden|status|view [code|think|all|clear];切换模式必须指定 off 或 hidden' }
362    }
363    const picked = arg === 'status'
364      ? current
365      : wanted!
366
367    if (arg !== 'status') {
368      if (picked !== current) {
369        await releasePending($)
370        await update($, liveThought, () => null)
371      }
372      await update($, mode, () => picked)
373      $.ui.toast(`show-mode: ${picked} (${MODE_LABEL[picked]})`)
374    }
375
376    const list = (await read($, diffs)) ?? []
377    const thinking = (await read($, thoughts)) ?? []
378    const rows = (await read($, processRows)) ?? {}
379    const kinds = Object.entries(stats.kinds)
380      .map(([k, n]) => `${k}=${n}`)
381      .join(' ')
382
383    return {
384      text: `show-mode ${arg === 'status' ? '当前为' : '已切换为'} ${picked} (${MODE_LABEL[picked]});已识别 ${Object.keys(rows).length} 条过程消息,已存文件 diff ${list.length} 条、思考过程 ${thinking.length} 条(/show-mode view 回看);仅 hidden 模式新增记录\n诊断:start=${stats.starts} / append=${stats.appends} / tool=${stats.tools} / AssistantMessage=${stats.renders} / complete=${stats.completes}\nturn.step=${stats.fires} / 分片=${stats.chunks} (${kinds || '无'})`,
385    }
386  })
387
388  // view / view code / view think / view all 共用无缩进的布局和块间空白。
389  // 用原生 Text 着色;命令输出的存档和复制内容不包含 ANSI 控制字符。
390  on('ui.render', { component: 'CommandOutput' }, async ($, e, next) => {
391    const arg = e.props.args.trim().toLowerCase()
392    const isView = arg === 'view'
393      || (arg.startsWith('view ') && ['code', 'think', 'all'].includes(arg.slice(4).trim()))
394    if (e.props.command !== 'show-mode' || e.props.isErrored
395      || !isView
396      || !/(?:^|\n)── (?:code|think) #\d+ ── /.test(e.props.text)) return next(e)
397
398    // 每条记录分别渲染,思考正文中的 + / - 不参与 diff 着色。
399    const sections = e.props.text.replace(/\r\n/g, '\n').split(
400      /(?=^── (?:code|think) #\d+ ── \d{2}:\d{2}:\d{2} ── )/m,
401    )
402    const { Box, Text } = $.ui.resolve(e)
403    return <Box flexDirection="column" marginLeft={0} paddingLeft={0}>
404      {sections.map((value, index) => {
405        // 分隔空行交给 Box 的 marginTop,避免 Markdown 吞掉它们。
406        const section = value.replace(/\n+$/, '')
407        if (/^── code #\d+ ── /.test(section)) {
408          return <Box key={String(index)} flexDirection="column" marginTop={2} paddingLeft={0}>
409            <Text wrap="wrap">{diffRuns(section).map((run, runIndex) =>
410              <Text key={String(runIndex)} {...DIFF_STYLES[run.style]}>{run.text}</Text>,
411            )}</Text>
412          </Box>
413        }
414        if (/^── think #\d+ ── /.test(section)) {
415          const end = section.indexOf('\n')
416          const title = end === -1 ? section : section.slice(0, end)
417          const body = end === -1 ? '' : limitThought(section.slice(end + 1))
418          return <Box key={String(index)} flexDirection="column" marginTop={1} paddingLeft={0}>
419            <Text color="magenta" bold wrap="wrap">{title}</Text>
420            {body !== '' && body.split('\n').map((line, lineIndex) =>
421              // 每行单独截断,确保长参数/正文不会自动折成超过五行。
422              <Text key={String(lineIndex)} wrap="truncate-end" dimColor>{line || ' '}</Text>,
423            )}
424          </Box>
425        }
426        return <Text key={String(index)} wrap="wrap" dimColor>{section}</Text>
427      })}
428    </Box>
429  })
430
431  // 新回合不继承上一轮的候选正文,避免把上一轮最终回答隐藏掉。
432  on('turn.start', async ($, e, next) => {
433    await releasePending($)
434    await update($, liveThought, () => null)
435    stats.starts += 1
436    return next(e)
437  })
438
439  // append 提供实际消息 UUID,对应 AssistantMessage 的 requestId。
440  // 正文和 tool_use 可能分开存储,因此还会在 tool.call 时归类。
441  on('session.append', async ($, e, next) => {
442    if (e.door !== 'response' || e.message.type !== 'assistant') return next(e)
443    stats.appends += 1
444    const key = loopKey(e.agentId)
445    const current = parseMode((await read($, mode)) ?? '') ?? configured
446    if (current !== 'hidden') {
447      await releasePending($, key)
448      return next(e)
449    }
450    const text = e.message.content
451      .filter((block) => block.type === 'text' && typeof block.text === 'string')
452      .map((block) => block.text as string)
453      .join('\n\n')
454    if (text.trim() !== '') {
455      const rows = pending.get(key) ?? new Map<string, string>()
456      rows.set(e.uuid, text)
457      pending.set(key, rows)
458      // append 落盘前就隐藏候选行,避免先显示一遍、工具开始后再消失。
459      await update($, processRows, (known) => {
460        const marked = { ...known, [e.uuid]: true }
461        for (const id of Object.keys(marked).slice(0, -ROW_LIMIT)) delete marked[id]
462        return marked
463      })
464      await showThought($, 'narration', text, e.agentId)
465    }
466    if (e.message.content.some((block) => block.type === 'tool_use')) {
467      await markProcess($, e.agentId)
468    }
469    return next(e)
470  })
471
472  on('tool.call', async ($, e, next) => {
473    stats.tools += 1
474    const current = parseMode((await read($, mode)) ?? '') ?? configured
475    await markProcess($, e.agentId)
476    if (current === 'hidden') {
477      try {
478        // 在执行前记录调用,保留嵌套技能/工具的发起顺序。
479        await storeThoughts($, e.tool === 'Skill' ? 'skill' : 'tool', [formatToolCall(e)])
480      } catch {
481        $.ui.log(`show-mode:无法记录 ${e.tool} 调用`, { to: 'debug' })
482      }
483    }
484    const result = await next(e)
485    if (current === 'hidden' && result.deny === undefined && !result.isError && !result.isReadOnly) {
486      try {
487        await storeDiffs($, collectDiffs(e.tool, result.result))
488      } catch {
489        $.ui.log(`show-mode:无法解析 ${e.tool} 返回的文件 diff`, { to: 'debug' })
490      }
491    }
492    return result
493  })
494
495  // 过程及候选正文在临时区域显示;回合结束后恢复最终回答的原生渲染。
496  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
497    stats.renders += 1
498    const current = parseMode((await read($, mode)) ?? '') ?? configured
499    if (current === 'off') return next(e)
500    const rows = await read($, processRows)
501    if (!rows[e.requestId]) return next(e)
502
503    const { Box } = $.ui.resolve(e)
504    return <Box display="none" />
505  })
506
507  // thinking 通过唯一的临时区域显示,下一块替换上一块,步骤结束时保留。
508  // text/tool/input/engine/stop 全部按原顺序透传;签名思考仍由引擎保存。
509  on('turn.step', async function* ($, e, next) {
510    stats.fires += 1
511    const blocks = new Map<number, string>()
512    try {
513      for await (const c of next(e)) {
514        stats.chunks += 1
515        stats.kinds[c.kind] = (stats.kinds[c.kind] ?? 0) + 1
516        if (c.kind === 'thinking') {
517          const current = parseMode((await read($, mode)) ?? '') ?? configured
518          if (current === 'hidden') {
519            const text = (blocks.get(c.index) ?? '') + c.text
520            blocks.set(c.index, text)
521            await showThought($, 'thinking', text, e.agentId)
522          } else {
523            yield c
524          }
525        } else {
526          yield c
527        }
528      }
529    } finally {
530      // 流中断时也保留已收到的思考;保存前再次检查模式。
531      await storeThoughts($, 'thinking', [...blocks.values()])
532    }
533  })
534
535  // 最后没有跟随工具调用的正文保留为回答;中断时也保留现有正文。
536  on('turn.complete', async ($, e, next) => {
537    stats.completes += 1
538    await releasePending($, loopKey(e.agentId))
539    // 子代理完成时只清理自己的预览,主回合结束则清理整个临时区域。
540    await update($, liveThought, (visible) =>
541      e.agentId === undefined || visible?.agentId === e.agentId ? null : visible)
542    return next(e)
543  })
544
545  on('classic.SessionStart', async ($, e, next) => {
546    await releasePending($)
547    await update($, liveThought, () => null)
548    return next(e)
549  })
550
551  // ToolGroup:批量汇总行(Thought for 13s, searched for 2 patterns...)
552  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
553    const current = parseMode((await read($, mode)) ?? '') ?? configured
554
555    if (current === 'off') {
556      return next(e)
557    }
558
559    // hidden:整行不画
560    const { Box } = $.ui.resolve(e)
561    return <Box display="none" />
562  })
563
564  // ToolUse:单个调用行(含 Skill 调用)
565  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
566    const current = parseMode((await read($, mode)) ?? '') ?? configured
567
568    if (current === 'off') {
569      return next(e)
570    }
571
572    // hidden:成功、失败和被中断的调用都不画。
573    const { Box } = $.ui.resolve(e)
574    return <Box display="none" />
575  })
576
577  // ToolResult:结果块
578  // hidden 下隐藏所有结果,包括错误输出。
579  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
580    const current = parseMode((await read($, mode)) ?? '') ?? configured
581
582    if (current === 'off') {
583      return next(e)
584    }
585
586    const { Box } = $.ui.resolve(e)
587    return <Box display="none" />
588  })
589
590  // ToolProgress:工具运行提示(ctrl+b to run in background)。
591  on('ui.render', { component: 'ToolProgress' }, async ($, e, next) => {
592    const current = parseMode((await read($, mode)) ?? '') ?? configured
593    if (current === 'off') return next(e)
594
595    const { Box } = $.ui.resolve(e)
596    return <Box display="none" />
597  })
598
599  // Spinner:回合进行中的状态行(Swirling… (46s · ↓ 1.1k tokens))
600  // 思考正文与原生状态行放在同一列,状态行始终在思考正文之后。
601  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
602    const current = parseMode((await read($, mode)) ?? '') ?? configured
603    const visible = await read($, liveThought)
604    const status = await next(e)
605    if (current !== 'hidden' || !visible) return status
606    const { Box, Text } = $.ui.resolve(e)
607    return <Box flexDirection="column" paddingLeft={0} marginTop={1}>
608      {visible.text.split('\n').map((line, index) =>
609        <Text key={String(index)} wrap="truncate-end" dimColor>{line || ' '}</Text>,
610      )}
611      {status}
612    </Box>
613  })
614}
615
types/index.d.ts 38 lines
1export type ShowMode = 'off' | 'hidden'
2
3// 保存已落地的文件新增/修改 diff,供 /show-mode view 回看。
4export interface StoredDiff {
5  id: number
6  at: number
7  filePath: string
8  action: 'create' | 'update'
9  tool: string
10  diff: string
11}
12
13// hidden 模式下保存的思考、过程叙述,以及工具/技能调用。
14export interface StoredThought {
15  id: number
16  at: number
17  kind: 'thinking' | 'narration' | 'tool' | 'skill'
18  text: string
19}
20
21export interface LiveThought {
22  agentId?: string
23  kind: 'thinking' | 'narration'
24  text: string
25}
26
27declare module 'claude-code' {
28  interface PluginState {
29    'show-mode': {
30      mode: ShowMode | null
31      diffs: StoredDiff[]
32      thoughts: StoredThought[]
33      processRows: Record<string, boolean>
34      liveThought: LiveThought | null
35    }
36  }
37}
38