SLOPSHOPPER

shell-mode

用 /shell-mode 切换 Bash 模式,连续执行命令并显示颜色,退出后将输出保留在 CLI 中

newpanerowscommandtoastprocess
v0.3.0no licenseupdated 2026-10-03h1048576/claude-mods/shell-mode
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · shell-mode
│ ┃ shell-console ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ $ │ shell-mode │ │ ⏺ Read(src/auth.ts) │ 无法进入 Shell Mode:未找到 Git Bash,请安装 Git for │ │ ⎿ Read 6 lines │ Windows,或设置 CLAUDE_CODE_GIT_BASH_PATH │ │ ⏺ 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 │ │ › /shell-mode │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · shell-console
$
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.ts 363 lines
1import type { EngineInterface, Register } from 'claude-code'
2import { lineLength, OUTPUT_LIMIT, OUTPUT_LINE_LIMIT, parseOutput, renderOutput, serializeOutput } from './output'
3import type { OutputLine } from './output'
4
5// 只在当前会话内保持模式和目录,重载后回到正常对话。
6let active = false
7let running = false
8let cwd = ''
9let bash = ''
10let draft = ''
11let history: OutputLine[] = []
12let generation = 0
13let columns = 80
14let skipCloseArchive = false
15let pendingArchive = ''
16
17const PANE_ID = 'shell-console'
18const INPUT_KEY = 'shell-command'
19const OUTPUT_COMMAND = 'shell-mode-output'
20const CWD_MARKER = '\0shell-mode-cwd\0'
21const ARCHIVE_REFERENCE = /\[shell-mode-output:([\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12})\]/i
22
23const archivePath = async ($: EngineInterface, id: string) => {
24  const localAppData = await $.env.get('LOCALAPPDATA')
25  if (localAppData) return `${localAppData.replace(/\\/g, '/')}/Claude/shell-mode/history/${id}.txt`
26  const userProfile = await $.env.get('USERPROFILE')
27  if (userProfile) return `${userProfile.replace(/\\/g, '/')}/.claude/shell-mode/history/${id}.txt`
28  throw new Error('无法确定 Shell 历史记录的本地保存目录')
29}
30
31// command.run 返回的文字会被 Claude 读取,因此只返回引用,实际输出单独存文件。
32// 文件名只使用插件生成的 UUID,不使用命令、路径或命令输出作为文件名。
33const saveArchive = async ($: EngineInterface, text: string) => {
34  if (text === '') return ''
35  const id = crypto.randomUUID()
36  await $.fs.write(await archivePath($, id), text)
37  return `[shell-mode-output:${id}]`
38}
39
40const readArchive = async ($: EngineInterface, reference: string) => {
41  const id = ARCHIVE_REFERENCE.exec(reference)?.[1]
42  if (id === undefined) return undefined
43  return $.fs.read(await archivePath($, id))
44}
45
46// 控制台用 Text 保留换行,不经过附带插件名前缀的 ui.log。
47// 同时限制单次输出和整个控制台的历史,避免连续执行后无限增长。
48const trimHistory = () => {
49  let length = history.reduce((total, line) => total + lineLength(line), 0) + Math.max(0, history.length - 1)
50  let runs = history.reduce((total, line) => total + line.length, 0)
51  while (history.length > OUTPUT_LINE_LIMIT || length > OUTPUT_LIMIT || runs > 4_000) {
52    const oldest = history.shift()
53    if (oldest !== undefined) {
54      length -= lineLength(oldest) + (history.length > 0 ? 1 : 0)
55      runs -= oldest.length
56    }
57  }
58}
59
60const appendOutput = (text: string) => {
61  const output = parseOutput(text)
62  history.push(...output.lines)
63  trimHistory()
64  return output.truncated
65}
66
67// 命令通过独立 argv 传入,交给 Bash 解释;不在包装脚本中拼接命令。
68// 登录配置先由 Bash 加载,再开启非交互 alias 展开并恢复本次工作目录。
69// EXIT 时从 stderr 回传实际目录,支持 cd、带空格路径及复合命令。
70const BASH_SCRIPT = String.raw`
71builtin shopt -s expand_aliases
72builtin cd -- "$2" || exit
73builtin trap '__shell_mode_exit=$?; builtin printf "\0shell-mode-cwd\0%s\0" "$(builtin pwd -W)" >&2; exit "$__shell_mode_exit"' EXIT
74__shell_mode_columns="$3"
75if [[ "$4" == 1 ]]; then
76  # 仅对直接显示的命令模拟终端格式;管道、重定向等保持原行为。
77  # 保留用户 alias 和已有同名函数,显式 -1、-l、--color=never 等参数优先。
78  if ! builtin declare -F ls >/dev/null; then
79    function ls {
80      local __shell_mode_arg
81      for __shell_mode_arg in "$@"; do
82        builtin shift
83        [[ "$__shell_mode_arg" == --color=auto ]] && __shell_mode_arg=--color=always
84        builtin set -- "$@" "$__shell_mode_arg"
85      done
86      builtin command ls --color=always -C --tabsize=0 --width="$__shell_mode_columns" "$@"
87    }
88  fi
89  if ! builtin declare -F git >/dev/null; then
90    function git { builtin command git -c color.ui=always "$@"; }
91  fi
92fi
93builtin eval "$1"
94`
95
96const findBash = async ($: EngineInterface) => {
97  const override = await $.env.get('CLAUDE_CODE_GIT_BASH_PATH')
98  if (override) {
99    if (await $.fs.exists(override)) return override
100    throw new Error(`CLAUDE_CODE_GIT_BASH_PATH 指向的 Bash 不存在:${override}`)
101  }
102
103  const programFiles = await $.env.get('ProgramFiles')
104  const programW6432 = await $.env.get('ProgramW6432')
105  const localAppData = await $.env.get('LOCALAPPDATA')
106  const userProfile = await $.env.get('USERPROFILE')
107  const candidates = [
108    programW6432 && `${programW6432}/Git/bin/bash.exe`,
109    programFiles && `${programFiles}/Git/bin/bash.exe`,
110    localAppData && `${localAppData}/Programs/Git/bin/bash.exe`,
111    userProfile && `${userProfile}/scoop/apps/git/current/bin/bash.exe`,
112    'C:/Program Files/Git/bin/bash.exe',
113  ]
114  for (const candidate of new Set(candidates)) {
115    if (candidate && await $.fs.exists(candidate)) return candidate
116  }
117  throw new Error('未找到 Git Bash,请安装 Git for Windows,或设置 CLAUDE_CODE_GIT_BASH_PATH')
118}
119
120const resetShell = () => {
121  active = false
122  running = false
123  draft = ''
124  history = []
125  generation += 1
126}
127
128const exitShell = async ($: EngineInterface, archive = true) => {
129  skipCloseArchive = !archive
130  try {
131    await $.ui.close({ id: PANE_ID })
132    resetShell()
133    $.ui.invalidate('ui.render')
134  } finally {
135    skipCloseArchive = false
136  }
137}
138
139const executeCommand = async ($: EngineInterface, command: string) => {
140  const currentGeneration = generation
141  running = true
142  draft = ''
143  appendOutput(`$ ${command}`)
144  $.ui.invalidate('ui.render')
145  try {
146    await $.ui.scroll({ in: PANE_ID, to: 'end' })
147    if (!active || generation !== currentGeneration) return
148    // 不能给管道、重定向或复合命令注入颜色与列格式,以免改变其数据。
149    const terminalOutput = !/[|&;<>()\r\n`$\\]/.test(command)
150    const result = await $.process.run(
151      [bash, '--login', '-c', BASH_SCRIPT, 'shell-mode', command, cwd, String(columns), terminalOutput ? '1' : '0'],
152      {
153        cwd,
154        timeoutMs: 600_000,
155        env: {
156          COLUMNS: String(columns),
157          ...(terminalOutput ? { TERM: 'xterm-256color', CLICOLOR: '1', CLICOLOR_FORCE: '1', FORCE_COLOR: '1' } : {}),
158        },
159      },
160    )
161    // 退出或重新进入后,不把旧命令的输出和目录带到新控制台。
162    if (!active || generation !== currentGeneration) return
163    let stderr = result.stderr
164    const marker = stderr.lastIndexOf(CWD_MARKER)
165    if (marker !== -1 && stderr.endsWith('\0')) {
166      const directory = stderr.slice(marker + CWD_MARKER.length, -1)
167      if (directory !== '') cwd = directory
168      stderr = stderr.slice(0, marker)
169    }
170    const output = [result.stdout, stderr]
171      .filter(Boolean)
172      .map(text => text.replace(/(?:\r?\n)+$/, ''))
173      .join('\n')
174    const truncated = output !== '' && appendOutput(output)
175    if (truncated || result.isStdoutTruncated || result.isStderrTruncated) {
176      appendOutput('命令输出过长,已截断显示。')
177    }
178  } catch (error) {
179    if (active && generation === currentGeneration) {
180      appendOutput(`Shell 执行失败:${error instanceof Error ? error.message : String(error)}`)
181    }
182  } finally {
183    if (active && generation === currentGeneration) {
184      running = false
185      history.push([])
186      trimHistory()
187      $.ui.invalidate('ui.render')
188      await $.ui.scroll({ in: PANE_ID, to: 'end' })
189    }
190  }
191}
192
193export const register: Register = (on) => {
194  on('session.start', async ($, e, next) => {
195    resetShell()
196    await exitShell($, false)
197    cwd = e.cwd
198    bash = ''
199    await $.command.register({
200      name: 'shell-mode',
201      description: '切换 Bash 模式:首次输入进入,再次输入退出;也可输入 exit 退出',
202      argumentHint: '[on|off]',
203    })
204    await $.command.register({
205      name: OUTPUT_COMMAND,
206      description: '保存 Shell 控制台输出',
207      immediate: true,
208    })
209    return next(e)
210  })
211
212  on('command.run', { command: 'shell-mode' }, async ($, e) => {
213    const arg = e.args.trim().toLowerCase()
214    if (arg === 'off' || (arg === '' && active)) {
215      try {
216        const text = await saveArchive($, serializeOutput(history))
217        // 原生 slash 命令直接返回引用,避免在 command.run 中等待另一个命令。
218        await exitShell($, false)
219        return text === '' ? {} : { text }
220      } catch (error) {
221        $.ui.toast(`无法保存 Shell 输出,控制台继续保留:${error instanceof Error ? error.message : String(error)}`)
222        return {}
223      }
224    }
225    if (arg !== '' && arg !== 'on') {
226      $.ui.toast('用法:/shell-mode 切换 Bash 模式;on 明确开启,off 或 exit 退出。')
227      return {}
228    }
229    try {
230      if (!active) {
231        bash = await findBash($)
232        cwd = await $.session.cwd()
233        columns = Math.max(20, e.presentation.columns - 4)
234        active = true
235      }
236      $.ui.invalidate('ui.render')
237      const opened = await $.ui.open({
238        id: PANE_ID,
239        title: 'Shell Mode',
240        focus: true,
241        closeOnEscape: true,
242        rows: 18,
243      })
244      if (!opened.isPlaced) throw new Error(opened.reason)
245      await $.ui.focus({ requestId: PANE_ID, key: INPUT_KEY })
246      await $.ui.scroll({ in: PANE_ID, to: 'end' })
247    } catch (error) {
248      await exitShell($, false)
249      $.ui.toast(`无法进入 Shell Mode:${error instanceof Error ? error.message : String(error)}`)
250    }
251    return {}
252  })
253
254  on('command.describe', { command: OUTPUT_COMMAND }, async ($, e, next) => ({
255    ...await next(e),
256    isHidden: true,
257  }))
258
259  on('command.run', { command: OUTPUT_COMMAND }, () => {
260    const text = pendingArchive
261    pendingArchive = ''
262    return text === '' ? {} : { text }
263  })
264
265  // CLI 记录只保留引用,从本地文件恢复内容及颜色,重绘/重载不依赖临时历史。
266  on('ui.render', { component: 'CommandOutput' }, async ($, e, next) => {
267    if (![OUTPUT_COMMAND, 'shell-mode'].includes(e.props.command) || e.props.isErrored) return next(e)
268    try {
269      const text = await readArchive($, e.props.text)
270      if (text === undefined) return next(e)
271      const lines = parseOutput(text).lines
272      lines.push([])
273      return renderOutput($.ui.resolve(e), lines)
274    } catch {
275      return $.ui.resolve(e).Text({ children: 'Shell 历史记录无法读取,请检查本地存档是否仍然存在。', dimColor: true })
276    }
277  })
278
279  on('ui.render', { component: 'UserMessage' }, ($, e, next) => {
280    if (e.props.origin.kind === 'plugin' && e.props.origin.name === 'shell-mode'
281      && /(?:^|\n)\/shell-mode-output(?:\s|$)/.test(e.props.text)) {
282      return $.ui.resolve(e).Box({ children: [] })
283    }
284    return next(e)
285  })
286
287  // 命令从独立 Input 提交,不触发 prompt.submit,也不产生 Prompt dropped 通知。
288  on('ui.input', { plugin: 'shell-mode', element: INPUT_KEY, requestId: PANE_ID }, async ($, e) => {
289    if (!active) return { element: e.element, value: '' }
290    if (e.kind === 'change') {
291      draft = e.value
292      $.ui.invalidate('ui.render')
293      return { element: e.element, value: draft }
294    }
295    const command = e.value.trim()
296    if (command === 'exit' || command === '/shell-mode' || command === '/shell-mode off') {
297      await exitShell($)
298      return { element: e.element, value: '' }
299    }
300    if (command === '' || command === '/shell-mode on') {
301      draft = ''
302      $.ui.invalidate('ui.render')
303      return { element: e.element, value: '' }
304    }
305    if (running) return { element: e.element, value: draft }
306    await executeCommand($, command)
307    return { element: e.element, value: draft }
308  })
309
310  on('ui.render', { component: 'Pane', requestId: PANE_ID }, ($, e) => {
311    if (e.surface === 'mobile') {
312      const { Text } = $.ui.resolve(e)
313      return Text({ children: '请在 Claude Code 终端中使用 Shell Mode。' })
314    }
315    const { Box, Text, Input } = $.ui.resolve(e)
316    columns = Math.max(20, e.props.bodyColumns)
317    return Box({
318      flexDirection: 'column',
319      children: [
320        renderOutput({ Box, Text }, history),
321        Box({
322          flexDirection: 'row',
323          children: [
324            Text({ children: '$ ' }),
325            Input({
326              key: INPUT_KEY,
327              value: draft,
328              autoFocus: true,
329              submitLabel: '',
330              onSubmit: () => {},
331            }),
332          ],
333        }),
334      ],
335    })
336  })
337
338  on('ui.close', { id: PANE_ID }, async ($, e, next) => {
339    const text = active && !skipCloseArchive ? serializeOutput(history) : ''
340    // 在卸载输入控件前保存,避免退出时的输入事件被卸载取消。
341    if (text !== '') {
342      try {
343        pendingArchive = await saveArchive($, text)
344        await $.command.run({ command: OUTPUT_COMMAND })
345      } catch (error) {
346        $.ui.toast(`无法保存 Shell 输出,控制台继续保留:${error instanceof Error ? error.message : String(error)}`)
347        if (e.origin.kind !== 'unload') return { deny: '无法保存 Shell 输出,请稍后再退出控制台。' }
348      } finally {
349        pendingArchive = ''
350      }
351    }
352    const result = await next(e)
353    resetShell()
354    $.ui.invalidate('ui.render')
355    return result
356  })
357
358  on('session.end', async ($, e, next) => {
359    await exitShell($, false)
360    return next(e)
361  })
362}
363
hooks/output.ts 161 lines
1import type { Elements, TextProps } from 'claude-code'
2
3export type OutputRun = { text: string; style: TextProps }
4export type OutputLine = OutputRun[]
5
6export const OUTPUT_LIMIT = 50_000
7export const OUTPUT_LINE_LIMIT = 200
8const RUN_LIMIT = 4_000
9const ANSI_COLORS = [
10  'black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white',
11  'gray', 'redBright', 'greenBright', 'yellowBright', 'blueBright', 'magentaBright', 'cyanBright', 'whiteBright',
12]
13
14const paletteColor = (value: number): string | undefined => {
15  if (!Number.isInteger(value) || value < 0 || value > 255) return undefined
16  if (value < 16) return ANSI_COLORS[value]
17  if (value >= 232) {
18    const channel = (8 + (value - 232) * 10).toString(16).padStart(2, '0')
19    return `#${channel}${channel}${channel}`
20  }
21  const index = value - 16
22  const levels = [0, 95, 135, 175, 215, 255]
23  return '#' + [Math.floor(index / 36), Math.floor(index / 6) % 6, index % 6]
24    .map(channel => levels[channel]!.toString(16).padStart(2, '0')).join('')
25}
26
27// 只采用程序明确给出的 SGR 颜色与样式,不根据文件名或输出文字猜测。
28const applyStyle = (style: TextProps, sequence: string) => {
29  const values = (sequence === '' ? ['0'] : sequence.split(';')).map(Number)
30  for (let index = 0; index < values.length; index += 1) {
31    const value = values[index]!
32    if (value === 0) {
33      for (const key of Object.keys(style)) delete style[key as keyof TextProps]
34    } else if (value === 1) style.bold = true
35    else if (value === 2) style.dimColor = true
36    else if (value === 3) style.italic = true
37    else if (value === 4) style.underline = true
38    else if (value === 7) style.inverse = true
39    else if (value === 9) style.strikethrough = true
40    else if (value === 22) { delete style.bold; delete style.dimColor }
41    else if (value === 23) delete style.italic
42    else if (value === 24) delete style.underline
43    else if (value === 27) delete style.inverse
44    else if (value === 29) delete style.strikethrough
45    else if (value === 39) delete style.color
46    else if (value === 49) delete style.backgroundColor
47    else if (value >= 30 && value <= 37) style.color = ANSI_COLORS[value - 30]
48    else if (value >= 90 && value <= 97) style.color = ANSI_COLORS[value - 90 + 8]
49    else if (value >= 40 && value <= 47) style.backgroundColor = ANSI_COLORS[value - 40]
50    else if (value >= 100 && value <= 107) style.backgroundColor = ANSI_COLORS[value - 100 + 8]
51    else if (value === 38 || value === 48) {
52      let color: string | undefined
53      if (values[index + 1] === 5 && values[index + 2] !== undefined) {
54        color = paletteColor(values[index + 2]!)
55        index += 2
56      } else if (values[index + 1] === 2 && values.length >= index + 5) {
57        const channels = values.slice(index + 2, index + 5)
58        if (channels.every(channel => Number.isInteger(channel) && channel >= 0 && channel <= 255)) {
59          color = '#' + channels.map(channel => channel.toString(16).padStart(2, '0')).join('')
60        }
61        index += 4
62      }
63      if (color !== undefined) style[value === 38 ? 'color' : 'backgroundColor'] = color
64    }
65  }
66}
67
68export const parseOutput = (text: string) => {
69  // 先限制原始数据,再移除光标移动、OSC 等控制序列;颜色在 Text 上渲染。
70  const rawLimit = OUTPUT_LIMIT * 8
71  const source = text.slice(0, rawLimit)
72    .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
73    .replace(/\x1b(?:\[[0-?]*[ -/]*[@-~]|[ -/]*[@-~])/g,
74      sequence => /^\x1b\[[\d;]*m$/.test(sequence) ? sequence : '')
75    .replace(/\x1b(?!\[[\d;]*m)/g, '')
76    .replace(/\r\n?|\u2028|\u2029/g, '\n')
77    .replace(/\t/g, '    ')
78    .replace(/[\x00-\x09\x0b-\x1a\x1c-\x1f\x7f]/g, '')
79  const lines: OutputLine[] = [[]]
80  const style: TextProps = {}
81  let length = 0
82  let runs = 0
83  let truncated = text.length > rawLimit
84
85  const append = (value: string) => {
86    const parts = value.split('\n')
87    for (const [index, part] of parts.entries()) {
88      if (index > 0) {
89        if (lines.length >= OUTPUT_LINE_LIMIT || length >= OUTPUT_LIMIT) {
90          truncated = true
91          return false
92        }
93        lines.push([])
94        length += 1
95      }
96      const remaining = OUTPUT_LIMIT - length
97      let piece = part.slice(0, remaining)
98      // 不在 UTF-16 代理对中间截断。
99      if (piece.length < part.length && /[\uD800-\uDBFF]$/.test(piece)) piece = piece.slice(0, -1)
100      if (piece !== '') {
101        const line = lines[lines.length - 1]!
102        const nextStyle = runs < RUN_LIMIT ? { ...style } : {}
103        const previous = line[line.length - 1]
104        if (previous && JSON.stringify(previous.style) === JSON.stringify(nextStyle)) previous.text += piece
105        else { line.push({ text: piece, style: nextStyle }); runs += 1 }
106        length += piece.length
107      }
108      if (piece.length < part.length) { truncated = true; return false }
109    }
110    return true
111  }
112
113  let offset = 0
114  let stopped = false
115  for (const match of source.matchAll(/\x1b\[([\d;]*)m/g)) {
116    if (!append(source.slice(offset, match.index))) { stopped = true; break }
117    applyStyle(style, match[1]!)
118    offset = match.index + match[0].length
119  }
120  if (!stopped) append(source.slice(offset))
121  while (lines.length > 0 && lines[lines.length - 1]!.length === 0) lines.pop()
122  return { lines, truncated }
123}
124
125export const lineLength = (line: OutputLine) => line.reduce((total, run) => total + run.text.length, 0)
126
127const colorCodes = (color: string | undefined, background: boolean) => {
128  if (color === undefined) return []
129  const index = ANSI_COLORS.indexOf(color)
130  if (index !== -1) return [(index < 8 ? (background ? 40 : 30) : (background ? 100 : 90)) + index % 8]
131  if (/^#[\da-f]{6}$/i.test(color)) {
132    return [background ? 48 : 38, 2, ...[1, 3, 5].map(offset => parseInt(color.slice(offset, offset + 2), 16))]
133  }
134  return []
135}
136
137// 本地存档带颜色,后续重绘/插件重载无需依赖内存中的历史。
138export const serializeOutput = (lines: OutputLine[]) => {
139  return lines.map(line => line.map(run => {
140    const codes = [
141      ...(run.style.bold ? [1] : []), ...(run.style.dimColor ? [2] : []),
142      ...(run.style.italic ? [3] : []), ...(run.style.underline ? [4] : []),
143      ...(run.style.inverse ? [7] : []), ...(run.style.strikethrough ? [9] : []),
144      ...colorCodes(run.style.color, false), ...colorCodes(run.style.backgroundColor, true),
145    ]
146    return codes.length === 0 ? run.text : `\x1b[${codes.join(';')}m${run.text}\x1b[0m`
147  }).join('')).join('\n')
148}
149
150export const renderOutput = (elements: Pick<Elements['terminal'], 'Box' | 'Text'>, lines: OutputLine[]) => {
151  const { Box, Text } = elements
152  return Box({
153    flexDirection: 'column',
154    children: lines.map(line => Text({
155      wrap: 'wrap',
156      children: line.length === 0 ? [' '] : line.flatMap(run =>
157        (run.text.match(/.{1,4000}/gu) ?? []).map(piece => Text({ ...run.style, children: piece }))),
158    })),
159  })
160}
161