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

个人 Claude Code 插件(mods)集合。每个子目录都是一个独立的 Claude Code 插件,通过 hooks 实现界面定制与行为增强,在本会话中热加载,无需重启。
| 插件 | 版本 | 说明 |
|---|---|---|
| show-mode | 0.3.0 | hidden 模式仅实时展示当前思考,隐藏工具调用与过程痕迹,保存文件 diff 与思考过程供回看 |
| shell-mode | 0.3.0 | 用 /shell-mode 切换 Bash 控制台,显示输出颜色,退出后将命令和结果保留在 CLI 中 |
将本仓库克隆到 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 后安装。
show-mode 在插件清单(show-mode/.claude-plugin/plugin.json)中声明了一个用户配置项:
| 配置项 | 类型 | 可选值 | 默认值 | 说明 |
|---|---|---|---|---|
mode | string | off / hidden | hidden | off=正常显示且不新增记录;hidden=仅展示当前思考,并保存文件 diff 与思考过程 |
运行时模式优先于清单配置:通过 /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 模式保存的记录。view all 可查看每类最近 30 条。在项目目录启动 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 配置hooks/register.tsx 615 lines1import { 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}
615types/index.d.ts 38 lines1export 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