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

个人 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.ts 363 lines1import 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}
363hooks/output.ts 161 lines1import 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