终端里 Claude 的回复更顺手:文件路径单击打开(HTML、文档 md/pdf/txt/json/csv/Office、图片、音视频、文件夹;脚本只在文件夹里选中不运行);代码块画成卡片,一键复制,单条 shell 命令可 Insert 到输入框;文字里 `反引号` 写的命令也补一张卡片;悬停回复右上角 Copy…

English | 简体中文
Desktop-app comforts for the Claude Code terminal
An animated, moody pixel-crab usage panel · one-click file links · code cards you can copy or fill in
<img src="assets/usage-hud.gif" alt="usage-hud: a pixel-crab usage panel under the prompt" width="900">
Claude Code mods are plugins made of function hooks: one folder and a few dozen lines of TypeScript can change what Claude Code itself draws. This repository holds two mods. They don't depend on each other, so you can install just one:
| Mod | What it does |
|---|---|
| usage-hud | A usage panel under the prompt and a pixel crab above it. The crab acts out the tool Claude is using, walks with your subagents, and its mood follows how fast you burn your quota. The panel shows model and effort, project and branch, session time and cost, context / 5-hour / weekly usage and tokens, all at a glance |
| html-shelf | File paths in Claude's replies (HTML, PDF, images, video, folders …) open with one click. Code blocks, and commands written inline, are drawn as cards you can copy or drop into the prompt |
The UI text is in Chinese. The GIFs on this page show exactly what you get.
<img src="assets/quickstart.gif" alt="Three steps: add the marketplace, install the two mods, start claude" width="900">
1. Add the plugin marketplace (run in your terminal)
claude plugin marketplace add ronanworks/claude-code-mods
2. Install the two mods (or only the one you want)
claude plugin install usage-hud@claude-code-mods
claude plugin install html-shelf@claude-code-mods
3. Restart claude
Run /plugin: the dim line under the tabs reads 2 mods active with both names. From then on every new session, in the terminal and in the desktop app, loads them.
Clicking and hovering need Claude Code's fullscreen rendering. If it's off, run
/tui fullscreeninside Claude Code.
Clone the repository, then put the absolute paths of the two folders in the env block of ~/.claude/settings.json. Separate them with ; on Windows and : on macOS / Linux. Mods loaded this way reload in terminal sessions as soon as you save a file.
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/path/to/claude-code-mods/usage-hud:/path/to/claude-code-mods/html-shelf"
}
}
To try them once without changing any settings:
claude --plugin-dir ./claude-code-mods/usage-hud --plugin-dir ./claude-code-mods/html-shelf
Updating: run /plugin marketplace update claude-code-mods inside Claude Code, or turn on auto-update for this marketplace under /plugin → Marketplaces.
See what a mod does before you install it: a mod runs with your permissions. Clone the repository and run claude plugin validate ./usage-hud; the hooks: and calls: lines list the events it handles and what it calls.
In the terminal, usage-hud draws two things: a usage panel under the prompt, and a pixel crab that walks in a strip above it.
The panel is a 3 × 3 grid whose labels line up in columns:
| Column 1 | Column 2 | Column 3 | |
|---|---|---|---|
| Row 1 | Model + effort level | Project + git branch | Session time + cost |
| Row 2 | Context usage | 5-hour usage | Weekly usage |
| Row 3 | Status | Most-used tools | Total tokens + output (out) |
The 5-hour and weekly bars each carry a bright │ tick that marks how much of the window has passed, and a short countdown to the reset follows the bar, such as 1h54m. When the colored part runs past the tick, you are using quota faster than the clock.
The crab above the prompt (terminal only) lives in a strip right above the input box: one blank row that keeps it apart from the conversation, then three rows of crab. It follows what Claude is doing:
| Claude is | The crab |
|---|---|
| Thinking or replying | Walks sideways, eyes on where it's going; thinking dots rise while it thinks |
| Reading or searching | Stops and reads a page while a scan line moves |
| Writing or editing a file | Stops and taps with its right claw as the page fills with text |
| Running a command | Stops and types with both claws, terminal cursor blinking |
| On the web | Stops beside a spinning globe |
| Running subagents | Each running subagent adds a baby crab to the line; when it finishes, its crab waves and leaves. The status cell says how many ("+3 agents") |
| Done with a turn | Jumps with claws up, gold sparkles |
| Idle / idle for 5 minutes | Strolls a few steps now and then, blinks and looks around / sleeps, blowing bubbles |
| You type / you send | Stops and looks down at the input box / jumps |
| At ≥ 80% context | Turns red and sweats; at ≥ 95% flashes red |
The crab also has moods that follow your quota pace (pace = actual usage ÷ the usage expected for the time elapsed):
| Quota | The crab | The panel |
|---|---|---|
| Using less than the clock (pace < 0.8) | Wears sunglasses, strolls | Normal |
| Clearly ahead of pace, on track to run out before the reset | Sweats, walks faster | The percentage and the countdown turn red: 40m用完 (empty in 40m) |
| Runs out within 30 minutes, or ≥ 95% used | Panics with claws up and a "!", scurries | Same as above |
/tui fullscreen), point at the crab: it stops, waves, and holds a bubble with your usage and a tip until you move away. Point elsewhere on the strip and its eyes follow the pointer. No click needed; a click moves the keyboard focus to the strip, and Esc gives it back.[-] at the strip's right end: click it or press ctrl+x ctrl+a to fold the strip away. /hud crab off turns it off for good. In a short terminal the strip drops its blank row first, then shrinks to two rows, one row, or hides.More practical touches
· $0.42 · 2 files changed +5 -1 · 3 tool calls (terminal only)./compact./hud agents, or click "+N agents" in the status cell, opens a side pane. It shows how long each subagent has run, how long since it last did something, and its last tool. Anything silent for over 5 minutes is flagged red as possibly stuck.Clickable parts: model name → /model, effort level → /effort, project name → opens the project folder, context → /context, 5-hour / weekly → /usage, token → a breakdown, "+N agents" → the subagent board.
The panel shrinks with the window: 81 columns or more shows the full 3 × 3 grid, 51–80 columns (such as macOS's default 80) a 3 × 2 grid, and under 51 columns a single line. In the terminal the panel always stays under the prompt.
| Command | What it does |
|---|---|
/hud | Cycles full → compact (one line) → hidden |
/hud agents | Opens the subagent board; Esc closes it |
/hud crab, /hud crab on, /hud crab off | Toggles the crab above the prompt (on by default) |
In the desktop app, the panel becomes a dedicated SVG card (crab + dashboard) above the prompt, borderless, following the light or dark theme:
<img src="assets/client.gif" alt="usage-hud in the desktop app: an SVG card above the prompt" width="900">
/cost shows. Subscribers aren't charged this amount.<img src="assets/html-shelf.gif" alt="html-shelf: copy a code card with one click, open an HTML link in the browser" width="900">
code , or text`) open in their default app with one click. Only files that exist become links.Write(...) row gets an Open button./open: /open opens the most recent file, /open 3 the third most recent, and /open list lists the last 10 as clickable links.!. It puts the command into the prompt prefixed with !, and it runs on your machine only when you press Enter. A draft you're still typing is never overwritten. python -m unittest -v x.py ) get a card too, placed after that paragraph, list item or table. Only clear commands count: ones that start with !`, or a known program (python, powershell, git, npm, claude …) with arguments. The same command gets one card, at most 6 per reply.| Item | Notes |
|---|---|
| Claude Code | Terminal 2.1.287 or later, desktop app 2.1.286 or later (the official requirement for mods). Tested on 2.1.289 (Windows) and 2.1.290 (WSL) |
| OS | Windows: tested on Windows 11 + Windows Terminal. Linux: plugin tests and the open commands verified in WSL (Ubuntu 22.04). macOS: covered by mocked tests only; issues are welcome |
| Opening files | Windows: explorer.exe / cmd start; macOS: open; Linux: xdg-open, then gio open; in WSL, Windows' default apps first (wslview, else explorer.exe via wslpath) |
| Rendering | Clicking and hovering need fullscreen rendering (/tui fullscreen) |
| Dependencies | Node.js on your PATH (usage-hud counts tokens with a small script), git (for the branch); on Linux outside WSL, xdg-utils to open files |
CLAUDE_CONFIG_DIR, usage-hud looks for session transcripts there.Removing the marketplace also uninstalls the mods you installed from it:
claude plugin marketplace remove claude-code-mods
If you loaded the folders directly, remove their paths from CLAUDE_CODE_PLUGIN_DIRS and restart claude.
Check and test a mod:
claude plugin validate ./usage-hud
claude plugin test ./usage-hud
/reload-plugins there.version in plugin.json after each change: usage-hud reloads itself in the desktop app when the version changes, and marketplace users only receive a new version.│ ─ ━ ✓ and block characters. Glyphs such as • ⏱ ↻ ✦ have an ambiguous width in some terminals and overlap their neighbours.node tools/preview-client.mjs renders a preview of the desktop panel to tools/out/client.html without opening the app.tools/demo/ holds the scripts that render the GIFs on this page.usage-hud/
hooks/register.tsx terminal panel and events
hooks/desktop.ts desktop panel (SVG)
scripts/count-tokens.js counts a session's tokens, subagents included
html-shelf/
hooks/register.tsx links, code cards, /open
tools/ preview and GIF scripts
assets/ GIFs for this README
MIT © 2026 Ronan
hooks/register.tsx 1258 lines1import type { Register } from 'claude-code'
2
3// html-shelf: 让终端里 Claude 的回复更顺手
4// 1. 回复里的文件路径 (裸路径 / `代码` / [文字](路径) / ~/路径) 变成链接, 全屏终端里单击打开
5// (文件必须真实存在才变链接; 一条回复最多查 40 个路径):
6// 网页 html/htm → 浏览器; 文档 md/pdf/txt/json/csv/xlsx/docx/pptx 等、图片、音视频 → 默认程序;
7// `代码` 或 [文字](路径) 里真实存在的文件夹 → 文件管理器;
8// 脚本和可执行文件 (bat/cmd/ps1/sh/exe/py/vbs 等) 不运行, 只在文件夹里选中它
9// 2. 终端里每个写完的代码块 (```) 画成一张卡片: 标题栏 (语言名 + 填入 + 复制) 和代码区
10// 各用主题里的一种底色, 跟着深色/浅色主题走; 鼠标移上去标题栏变亮、按钮变橙;
11// 复制 = 代码原文进剪贴板 (末尾不带换行), 之后 1.8 秒显示绿色"已复制 ✓";
12// 填入 = 只有一条命令的 shell 代码块, 以 ! 开头填进输入框, 按回车才运行
13// 文字里 `反引号` 写的命令 (以 ! 开头, 或 python / git / npm / Get-ChildItem … 带参数) 也补一张同样的卡片,
14// 放在所在段落 / 列表项 / 表格的后面, 原文一字不改; 同一条命令只补一次, 代码块里写过的不补, 一条回复最多 6 张
15// 3. 终端里鼠标停在 Claude 的回复上, 右上角出现"复制全文" (复制这段回复的 markdown 原文)
16// 4. 终端里 Write / Edit 写完的文件, 工具行后面有"打开"
17// 5. /open 打开最近一次提到或写出的文件
18// /open 3 打开倒数第 3 个; /open list 列出最近 10 个, 单击打开; Claude 工作时也能用
19// 客户端 (desktop) 自带复制按钮和文件链接: 只保留原来的 HTML 链接, 其余功能只在终端
20
21const MAX_RECENT = 50
22const LIST_SIZE = 10
23const MAX_CHECKS = 40 // 一条回复最多查 40 个路径, 别拖慢渲染
24const MISS_MS = 3_000 // "不存在"只记 3 秒: 文件可能稍后才写出
25const MAX_PART = 9_000 // Markdown 一次最多画 10000 字, 超了就交给引擎原样画
26const FILE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit'])
27
28// 能点开的文件类型
29const HTML_EXT = ['html', 'htm']
30const DOC_EXT = ['md', 'markdown', 'pdf', 'txt', 'log', 'json', 'csv', 'tsv', 'xlsx', 'xls', 'docx', 'doc', 'pptx', 'ppt']
31const IMAGE_EXT = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg']
32const MEDIA_EXT = ['mp4', 'mov', 'mkv', 'webm', 'avi', 'mp3', 'wav']
33// 脚本和可执行文件: 单击只在文件夹里选中, 绝不直接运行 (Windows 上 .js / .vbs 双击会被系统执行)
34const SCRIPT_EXT = [
35 'bat', 'cmd', 'ps1', 'psm1', 'sh', 'bash', 'zsh', 'command', 'exe', 'msi',
36 'py', 'pyw', 'vbs', 'vbe', 'js', 'jse', 'wsf', 'hta', 'reg', 'lnk', 'jar', 'appimage',
37]
38const HTML = new Set(HTML_EXT)
39const SCRIPT = new Set(SCRIPT_EXT)
40const LINKABLE = new Set([...HTML_EXT, ...DOC_EXT, ...IMAGE_EXT, ...MEDIA_EXT, ...SCRIPT_EXT])
41
42const PATH_CHARS = String.raw`[^\s"'\x60()()<>\[\]{}|,,。:;、!?*]`
43type ExtRule = { bare: RegExp; span: RegExp; has: RegExp; set: Set<string> }
44function extRule(list: string[]): ExtRule {
45 const alt = [...list].sort((a, b) => b.length - a.length).join('|')
46 return {
47 bare: new RegExp(`(?:[A-Za-z]:[\\\\/])?${PATH_CHARS}+?\\.(?:${alt})(?![\\w.])`, 'gi'),
48 span: new RegExp(`\`([^\`\\n]+\\.(?:${alt}))\``, 'gi'),
49 has: new RegExp(`\\.(?:${alt})(?![\\w])`, 'i'),
50 set: new Set(list),
51 }
52}
53const WIDE = extRule([...LINKABLE]) // 终端: 所有支持的类型
54const NARROW = extRule(HTML_EXT) // 客户端: 维持原来只认 HTML
55const MD_LINK = /\[([^\]\n]*)\]\(([^)\s]+)\)/g
56const ANY_SPAN = /`([^`\n]+)`/g
57const DIR_HINT = /`[^`\n]*[\\/][^`\n]*`|\]\([^)\s]*[\\/][^)\s]*\)/
58const FENCE = /^( {0,12})(`{3,}|~{3,})(.*)$/
59const HAS_FENCE = /^ {0,12}(```|~~~)/m
60// 卡片颜色用主题键, 深色/浅色主题各自有值 (深色: 标题栏 55 灰, 悬停 70 灰, 代码区 38 灰)
61const CARD_HEAD = 'userMessageBackground'
62const CARD_HEAD_HOVER = 'userMessageBackgroundHover'
63const CARD_BODY = 'composerSidebarBackground'
64const PRESS_HOVER = { color: 'claude', bold: true, dimColor: false }
65const COPIED_MS = 1_800
66
67// 代码块语言 → 哪种 shell 的命令; shell / console 不指明, 算通用
68type ShellKind = 'bash' | 'powershell' | 'cmd' | 'any'
69const SHELL_LANGS: Record<string, ShellKind> = {
70 bash: 'bash', sh: 'bash', zsh: 'bash', shell: 'any', console: 'any',
71 powershell: 'powershell', ps1: 'powershell', pwsh: 'powershell', cmd: 'cmd', bat: 'cmd',
72}
73const SHELL_NAME: Record<ShellKind, string> = { bash: 'bash', powershell: 'PowerShell', cmd: 'cmd', any: 'shell' }
74// 没标语言的代码块: 只有一行、而且看起来像命令才当 shell 命令 (以 ! / $ / PS> 开头, 或第一个词是常见命令)
75const LOOKS_LIKE_CMD =
76 /^(?:!|\$\s|PS [^>]*>\s|(?:powershell|pwsh|cmd|claude|git|gh|npm|npx|pnpm|yarn|bun|node|deno|python3?|py|pip3?|uv|conda|cd|ls|dir|mkdir|winget|choco|scoop|brew|apt|sudo|wsl|ssh|scp|curl|wget|docker|code|start|explorer|open|xdg-open|ffmpeg|make|cargo|go|dotnet|java|(?:Get|Set|New|Start|Stop|Copy|Move|Invoke|Test)-\w+)(?:\s|$))/i
77// 行内命令 (`反引号` 里写的命令): 以 ! 开头, 或者以这些程序开头并且后面带参数
78const INLINE_PROGRAMS = [
79 'python', 'python3', 'py', 'pip', 'pip3', 'uv', 'conda', 'node', 'npm', 'npx', 'pnpm', 'yarn', 'git', 'gh', 'claude',
80 'powershell', 'pwsh', 'cmd', 'bash', 'sh', 'wsl', 'docker', 'cargo', 'go', 'dotnet', 'winget', 'scoop', 'choco',
81 'curl', 'wget', 'ssh', 'scp',
82].join('|')
83// PowerShell 的 动词-名词 (Get-ChildItem …)
84const PS_VERBS = [
85 'Get', 'Set', 'New', 'Start', 'Stop', 'Restart', 'Invoke', 'Remove', 'Copy', 'Move', 'Rename', 'Test', 'Add', 'Clear',
86 'Install', 'Uninstall', 'Update', 'Import', 'Export', 'Select', 'Where', 'ForEach', 'Out', 'Write', 'Enable', 'Disable',
87 'Expand', 'Compress', 'Resolve', 'Join', 'Split', 'Measure', 'ConvertTo', 'ConvertFrom', 'Register', 'Unregister', 'Wait',
88].join('|')
89// 程序名 + 空格 + 参数 (参数不以 + = | & , ; : 开头, 免得把 `cmd + K` 这类快捷键当命令)
90const INLINE_CMD = new RegExp(
91 `^(?:(?:${INLINE_PROGRAMS})(?:\\.exe)?|(?:${PS_VERBS})-[A-Za-z]\\w*)\\s+(?![+=|&,;:])\\S`,
92 'i',
93)
94const PS_CMDLET = new RegExp(`(?:^|[|;]\\s*)(?:${PS_VERBS})-[A-Za-z]`, 'i')
95// 粗筛: 有没有可能写了行内命令 (反引号后面紧跟 ! / 程序名 / 动词-名词)
96const INLINE_HINT = new RegExp(`\`\\s*(?:!|(?:${INLINE_PROGRAMS})(?:\\.exe)?\\s|(?:${PS_VERBS})-\\w)`, 'i')
97const MAX_INLINE = 6 // 一条回复最多补 6 张行内命令卡片
98const MAX_INLINE_LEN = 300
99// 按钮文字 (英文, 和 GitHub 上的代码块一样); 宽度用来给"Copy all"让位
100const LABEL = { copy: 'Copy', copied: 'Copied ✓', insert: 'Insert', copyAll: 'Copy all', open: 'Open' }
101// 显示宽度: 中日韩字符算 2 格, 其余 1 格
102const dw = (s: string) =>
103 [...s].reduce((n, ch) => n + (/[ᄀ-ᅟ⺀-가-힣豈-︰-﹏-⦆¢-₩]/.test(ch) ? 2 : 1), 0)
104
105// ---- 跨平台 (usage-hud 里有同样一份; 带 $ 的函数必须写在本文件里, 不能 import) ----
106type OS = 'windows' | 'mac' | 'linux'
107
108// 按工作目录缓存: 同一个目录只判断一次, 换了目录 (或测试里换了系统) 再判断
109let knownOS: { key: string; os: OS } | undefined
110
111// 判断顺序: 环境变量 OS=Windows_NT (Windows 自带) → 路径像 C:\ → 有 /System/Library/CoreServices 就是 macOS → 其余按 Linux
112async function detectOS($: any, dir: string): Promise<OS> {
113 if (knownOS && knownOS.key === dir) return knownOS.os
114 let found: OS = 'linux'
115 try {
116 if ((await $.env.get('OS')) === 'Windows_NT' || /^[A-Za-z]:[\\/]/.test(dir)) found = 'windows'
117 else if (await $.fs.exists('/System/Library/CoreServices')) found = 'mac'
118 } catch {}
119 knownOS = { key: dir, os: found }
120 return found
121}
122
123// 用户主目录: Windows 用 USERPROFILE, 其余用 HOME
124async function homeDir($: any, sys: OS): Promise<string> {
125 const h = (sys === 'windows' ? await $.env.get('USERPROFILE') : '') || (await $.env.get('HOME')) || ''
126 return h.replace(/[\\/]+$/, '')
127}
128
129// 跑一个命令, 退出码 0 算成功; 命令不存在会抛错, 也算失败
130async function ranOk($: any, argv: string[]): Promise<boolean> {
131 try {
132 const r = await $.process.run(argv, { timeoutMs: 10_000 })
133 return r.exitCode === 0
134 } catch {
135 return false
136 }
137}
138
139// WSL 里一般没有 Linux 的浏览器和文件管理器: 交给 Windows 那边的默认程序.
140// 先试 wslview (wslu 包), 再经 wslpath 把路径转成 Windows 写法交给 explorer.exe
141// (explorer.exe 成功也常返回 1, 所以 exit 0); 网址直接交给 explorer.exe
142function wslTries(target: string): string[][] {
143 const viaExplorer = /^[a-z][a-z0-9+.-]*:\/\//i.test(target)
144 ? ['sh', '-c', 'explorer.exe "$1"; exit 0', 'sh', target]
145 : ['sh', '-c', 'explorer.exe "$(wslpath -w "$1")"; exit 0', 'sh', target]
146 return [['wslview', target], viaExplorer]
147}
148
149// macOS / Linux: 用系统默认程序打开 (文件 → 默认应用, 文件夹 → 文件管理器, 网址 → 浏览器); 都不行就抛错
150async function openPosix($: any, target: string, sys: OS): Promise<void> {
151 const tries: string[][] = []
152 if (sys === 'mac') tries.push(['open', target])
153 else {
154 if (await $.env.get('WSL_DISTRO_NAME')) tries.push(...wslTries(target))
155 tries.push(['xdg-open', target], ['gio', 'open', target])
156 }
157 for (const argv of tries) if (await ranOk($, argv)) return
158 throw new Error('没能打开, 试过: ' + tries.map(a => (a[0] === 'sh' ? 'explorer.exe' : a[0])).join(' / '))
159}
160// ---- 跨平台 完 ----
161
162// 两个目录: 回复里的相对路径大多相对项目根目录写, 但 Claude 在终端里 cd 之后当前目录会变
163let root = '' // 项目根目录 ($.session.root): 会话开始的地方, 终端里 cd 不会改它
164let cwd = '' // 当前目录 ($.session.cwd): 跟着终端里的 cd 变
165let os: OS = 'windows' // useOS() 之后才准
166let home = '' // macOS / Linux 展开 ~/ 用
167let recent: string[] = [] // 绝对路径, 最新在前
168const existCache = new Map<string, boolean>()
169const missCache = new Map<string, number>() // 刚查过不存在的路径 → 查的时间
170const dirSet = new Set<string>() // 查到是文件夹的路径
171const seen = new Set<string>() // 已记进 /open 列表的 `${消息 id}|${路径}`: 重画同一条回复不打乱顺序
172const copied = new Set<string>() // 刚复制过的: `${消息 id}:${第几块}` / `${消息 id}:all`
173
174type Part =
175 | { kind: 'prose'; text: string }
176 | { kind: 'code'; lang: string; code: string; indent: number; fenced: string }
177type Found = { abs: string; isDir: boolean }
178// 一条回复共用的查找额度和结果 (同一路径只查一次)
179type Budget = { left: number; memo: Map<string, Found | null> }
180const newBudget = (): Budget => ({ left: MAX_CHECKS, memo: new Map() })
181
182const winPath = (p: string) => p.replace(/\//g, '\\')
183// 去掉末尾的 / 或 \ (盘符根 C:\ 和 / 保留)
184const trimSep = (p: string) => p.replace(/(?<=[^\\/:])[\\/]+$/, '')
185const baseName = (p: string) => trimSep(p).split(/[\\/]/).pop() ?? p
186const extOf = (p: string) => (/\.([A-Za-z0-9]+)$/.exec(trimSep(p))?.[1] ?? '').toLowerCase()
187// 去重和缓存用的键: Linux 文件名分大小写, Windows / macOS 默认不分
188const keyOf = (abs: string) => (os === 'linux' ? trimSep(abs) : trimSep(abs).toLowerCase())
189const isUrl = (s: string) => /^[a-z][a-z0-9+.-]*:\/\//i.test(s) && !/^file:/i.test(s)
190
191// 先弄清在哪个系统上: 路径怎么拼、用什么命令打开都看它
192async function useOS($: any) {
193 cwd = (await $.session.cwd()) || cwd
194 try {
195 root = (await $.session.root()) || root
196 } catch {}
197 os = await detectOS($, root || cwd)
198 home = os === 'windows' ? '' : await homeDir($, os)
199}
200
201// 相对路径按 base 拼成绝对路径 (默认项目根目录); 绝对路径、file:// 链接原样转成本系统写法
202function absolute(p: string, base = root || cwd): string {
203 let s = p.replace(/^file:\/\/(localhost)?/i, '') // file:///C:/x → /C:/x, file:///home/x → /home/x
204 try {
205 s = decodeURIComponent(s)
206 } catch {}
207 if (os === 'windows') {
208 s = winPath(s.replace(/^\/(?=[A-Za-z]:)/, ''))
209 if (/^[a-zA-Z]:[\\/]|^[\\/]{2}/.test(s)) return s
210 return winPath(base.replace(/[\\/]+$/, '') + '\\' + s.replace(/^\.[\\/]/, ''))
211 }
212 if (s.startsWith('~/') && home) s = home + s.slice(1)
213 if (s.startsWith('/')) return s
214 return base.replace(/\/+$/, '') + '/' + s.replace(/^\.\//, '')
215}
216
217// 一个路径可能指向的文件: 先按项目根目录, 再按当前目录 (Claude cd 进子目录后写的相对路径)
218function candidates(p: string): string[] {
219 const list = [absolute(p, root || cwd)]
220 if (root && cwd) {
221 const alt = absolute(p, cwd)
222 if (keyOf(alt) !== keyOf(list[0])) list.push(alt)
223 }
224 return list
225}
226
227function toHref(abs: string): string {
228 const path = os === 'windows' ? '/' + abs.replace(/\\/g, '/') : abs
229 return 'file://' + encodeURI(path).replace(/#/g, '%23').replace(/\?/g, '%3F').replace(/\(/g, '%28').replace(/\)/g, '%29')
230}
231
232function remember(abs: string) {
233 recent = [abs, ...recent.filter(p => keyOf(p) !== keyOf(abs))].slice(0, MAX_RECENT)
234}
235
236// 回复里提到的路径记进 /open 列表: 同一条回复里每个路径只记一次, 先提到的排在最前
237function noteFound(rid: string, found: Found[]) {
238 if (seen.size > 5_000) seen.clear()
239 for (const f of [...found].reverse()) {
240 const k = rid + '|' + keyOf(f.abs)
241 if (seen.has(k)) continue
242 seen.add(k)
243 remember(f.abs)
244 }
245}
246
247async function exists($: any, abs: string): Promise<boolean> {
248 const key = keyOf(abs)
249 if (existCache.get(key)) return true
250 const missed = missCache.get(key)
251 if (missed !== undefined && Date.now() - missed < MISS_MS) return false
252 let ok = false
253 try {
254 ok = await $.fs.exists(abs)
255 } catch {}
256 // "存在"一直记着; "不存在"只记几秒 (文件可能稍后才写出)
257 if (ok) existCache.set(key, true)
258 else missCache.set(key, Date.now())
259 return ok
260}
261
262async function isDirectory($: any, abs: string): Promise<boolean> {
263 const key = keyOf(abs)
264 if (dirSet.has(key)) return true
265 try {
266 const st = await $.fs.stat(abs)
267 if (st?.kind === 'dir') {
268 dirSet.add(key)
269 return true
270 }
271 } catch {}
272 return false
273}
274
275// 看着像文件夹路径: 有 / 或 \, 不是网址, 没有通配符; 带空格的只认绝对路径; 末段像文件名 (a.tsx) 的不查
276function dirLike(s: string): boolean {
277 if (s.length > 260 || isUrl(s) || /[*?<>|"]/.test(s) || !/[\\/]/.test(s)) return false
278 if (/\s/.test(s) && !/^(?:[A-Za-z]:[\\/]|[\\/]|~[\\/]|file:)/i.test(s)) return false
279 return !/\.[A-Za-z][A-Za-z0-9]{0,4}$/.test(trimSep(s))
280}
281
282// Windows 上从 Claude Code 隐藏启动的 explorer.exe 开出来的文件夹窗口是看不见的,
283// 所以文件夹和非 HTML 文件经 cmd 的 start 转一手 (和 usage-hud 同一做法, 已在本机验证);
284// 路径里有 cmd 会误解的字符时改用 PowerShell 的 Start-Process
285function utf16Base64(s: string): string {
286 const bytes: number[] = []
287 for (let i = 0; i < s.length; i++) {
288 const c = s.charCodeAt(i)
289 bytes.push(c & 255, c >> 8)
290 }
291 return (new Uint8Array(bytes) as any).toBase64()
292}
293const psQuote = (s: string) => "'" + s.replace(/'/g, "''") + "'"
294
295async function runPowerShell($: any, script: string) {
296 const r = await $.process.run(['powershell.exe', '-NoProfile', '-NonInteractive', '-EncodedCommand', utf16Base64(script)], {
297 timeoutMs: 20_000,
298 })
299 if (r && r.exitCode !== 0) throw new Error('PowerShell 退出码 ' + r.exitCode + ' ' + String(r.stderr ?? '').slice(0, 80))
300}
301
302async function startWindows($: any, abs: string) {
303 if (/^[^&^|<>()%!"]+$/.test(abs)) {
304 const r = await $.process.run(['cmd.exe', '/d', '/c', 'start', 'html shelf', abs], { timeoutMs: 10_000 })
305 if (r && r.exitCode !== 0) throw new Error('cmd start 退出码 ' + r.exitCode)
306 } else await runPowerShell($, 'Start-Process -FilePath ' + psQuote(abs))
307}
308
309// 在文件夹里选中它: Windows 用 PowerShell 的 Start-Process 拉起 explorer.exe /select,"路径"
310// (本机实测: 窗口可见且选中; 直接跑 explorer.exe /select 会开出看不见的窗口),
311// macOS 用 open -R, Linux 打开它所在的文件夹
312async function reveal($: any, abs: string) {
313 if (os === 'windows') {
314 await runPowerShell($, "Start-Process -FilePath 'explorer.exe' -ArgumentList " + psQuote('/select,"' + abs + '"'))
315 return
316 }
317 if (os === 'mac') {
318 if (await ranOk($, ['open', '-R', abs])) return
319 throw new Error('没能打开, 试过: open -R')
320 }
321 await openPosix($, abs.replace(/\/[^/]*$/, '') || '/', os)
322}
323
324// 单击链接 / 打开按钮 / /open 都走这里: 按类型决定怎么开
325async function act($: any, target: string) {
326 try {
327 await useOS($)
328 const abs = trimSep(target)
329 const name = baseName(abs)
330 const ext = extOf(abs)
331 if (SCRIPT.has(ext)) {
332 await reveal($, abs)
333 $.ui.toast('已在文件夹里选中: ' + name + '(脚本不会直接运行)')
334 return
335 }
336 const isDir = dirSet.has(keyOf(abs)) || (!LINKABLE.has(ext) && (await isDirectory($, abs)))
337 if (HTML.has(ext) && !isDir) {
338 // Windows: explorer.exe 用默认浏览器打开, 不经过 shell, 路径里有空格和 & 也安全
339 if (os === 'windows') await $.process.run(['explorer.exe', abs], { timeoutMs: 10_000 })
340 else await openPosix($, abs, os)
341 $.ui.toast('已用浏览器打开: ' + name)
342 return
343 }
344 if (os === 'windows') await startWindows($, abs)
345 else await openPosix($, abs, os)
346 $.ui.toast((isDir ? '已打开文件夹: ' : '已打开: ') + name)
347 } catch (err) {
348 $.ui.toast('打开失败: ' + String(err))
349 }
350}
351
352// 把一段 markdown 里的文件路径改写成 file:/// 链接; 代码块 (```) 里的不动.
353// wide = 终端 (所有类型 + 文件夹), 否则只认 HTML
354async function linkify(
355 $: any,
356 text: string,
357 wide: boolean,
358 budget: Budget = newBudget(),
359): Promise<{ text: string; hrefs: string[]; found: Found[] }> {
360 await useOS($)
361 const rule = wide ? WIDE : NARROW
362 const hrefs: string[] = []
363 const found: Found[] = []
364 const slots: string[] = []
365 const hold = (s: string) => `\u0000${slots.push(s) - 1}\u0000`
366
367 // 找到真实存在的文件 (或文件夹) 才返回; 额度用完就不再查
368 async function locate(raw: string, wantDir: boolean): Promise<Found | null> {
369 const memoKey = (wantDir ? 'd:' : 'f:') + raw
370 if (budget.memo.has(memoKey)) return budget.memo.get(memoKey) ?? null
371 let hit: Found | null = null
372 if (budget.left > 0) {
373 budget.left -= 1
374 for (const abs of candidates(raw)) {
375 if (!(await exists($, abs))) continue
376 if (wantDir && !(await isDirectory($, abs))) continue
377 hit = { abs: wantDir ? trimSep(abs) : abs, isDir: wantDir }
378 break
379 }
380 }
381 budget.memo.set(memoKey, hit)
382 return hit
383 }
384
385 async function link(raw: string, label: string, allowDir: boolean): Promise<string | undefined> {
386 if (isUrl(raw)) return undefined
387 const hasExt = rule.set.has(extOf(raw))
388 if (!hasExt && !(allowDir && wide && dirLike(raw))) return undefined
389 const hit = await locate(raw, !hasExt)
390 if (!hit) return undefined
391 const href = toHref(hit.abs)
392 hrefs.push(href)
393 found.push(hit)
394 return `[${label}](${href})`
395 }
396
397 // 每次复制一份正则: 循环里要等查文件, 几条回复同时在画时共用一个 lastIndex 会互相打乱
398 async function replaceAsync(s: string, shared: RegExp, fn: (m: RegExpExecArray) => Promise<string>) {
399 const re = new RegExp(shared.source, shared.flags)
400 let out = ''
401 let last = 0
402 for (let m = re.exec(s); m; m = re.exec(s)) {
403 out += s.slice(last, m.index) + (await fn(m))
404 last = m.index + m[0].length
405 }
406 return out + s.slice(last)
407 }
408
409 const parts = text.split(/(```[\s\S]*?```)/g)
410 for (let i = 0; i < parts.length; i += 2) {
411 let s = parts[i] ?? ''
412 // 已有的 markdown 链接: 只换目标 (网址原样留着, 也不再被当成裸路径)
413 s = await replaceAsync(s, MD_LINK, async m => hold((await link(m[2] ?? '', m[1] ?? '', true)) ?? m[0]))
414 // `路径` (文件或文件夹)
415 s = await replaceAsync(s, wide ? ANY_SPAN : rule.span, async m => {
416 const raw = m[1] ?? ''
417 return hold((await link(raw, '`' + raw + '`', true)) ?? m[0])
418 })
419 // 裸路径 (只认带后缀的文件)
420 s = await replaceAsync(s, rule.bare, async m => hold((await link(m[0], m[0], false)) ?? m[0]))
421 parts[i] = s.replace(/\u0000(\d+)\u0000/g, (_, n) => slots[Number(n)] ?? '')
422 }
423 return { text: parts.join(''), hrefs, found }
424}
425
426// 这段文字里可能有能点开的路径 (先粗筛, 免得每条回复都去查文件)
427function mayLink(text: string, wide: boolean): boolean {
428 return wide ? WIDE.has.test(text) || DIR_HINT.test(text) : NARROW.has.test(text)
429}
430
431// 头尾整行的空白去掉; 行内的缩进和空格不动
432function trimBlankLines(s: string): string {
433 return s.replace(/^(?:[ \t]*\n)+/, '').replace(/(?:\n[ \t]*)+$/, '')
434}
435
436function dedent(line: string, n: number): string {
437 let k = 0
438 while (k < n && line[k] === ' ') k++
439 return line.slice(k)
440}
441
442// 整段文字去掉所有非空行共有的缩进 (相对缩进和代码块原样保留)
443function dedentAll(text: string): string {
444 const lines = trimBlankLines(text.replace(/\r\n/g, '\n')).split('\n')
445 const widths = lines.filter(l => l.trim()).map(l => /^ */.exec(l)?.[0].length ?? 0)
446 const n = widths.length ? Math.min(...widths) : 0
447 return lines.map(l => dedent(l, n)).join('\n')
448}
449
450// 把回复切成 文字 / 代码块 两种段; 没闭合的代码块 (还在流式输出) 留在文字里
451function splitCode(text: string): Part[] {
452 const lines = text.split(/\r?\n/)
453 const parts: Part[] = []
454 let prose: string[] = []
455 const flush = () => {
456 const s = trimBlankLines(prose.join('\n'))
457 if (s.trim()) parts.push({ kind: 'prose', text: s })
458 prose = []
459 }
460 for (let i = 0; i < lines.length; i++) {
461 const m = FENCE.exec(lines[i] ?? '')
462 // ``` 后面的说明里再有反引号, 是行内代码, 不是代码块
463 if (!m || (m[2]?.[0] === '`' && (m[3] ?? '').includes('`'))) {
464 prose.push(lines[i] ?? '')
465 continue
466 }
467 const indent = (m[1] ?? '').length
468 const mark = m[2] ?? '```'
469 // 收尾的围栏: 同一种符号, 不短于开头
470 const close = new RegExp('^ *' + (mark[0] === '`' ? '`' : '~') + '{' + mark.length + ',}[ \\t]*$')
471 let j = i + 1
472 while (j < lines.length && !close.test(lines[j] ?? '')) j++
473 if (j >= lines.length) {
474 prose.push(...lines.slice(i))
475 break
476 }
477 flush()
478 // 列表里缩进的代码块: 按开头围栏的缩进去掉每行前面的空格
479 const body = lines.slice(i + 1, j).map(l => dedent(l, indent))
480 const info = (m[3] ?? '').trim()
481 parts.push({
482 kind: 'code',
483 lang: info.split(/\s+/)[0] ?? '',
484 code: trimBlankLines(body.join('\n')),
485 indent,
486 fenced: [mark + info, ...body, mark].join('\n'),
487 })
488 i = j
489 }
490 flush()
491 return parts
492}
493
494// shell 代码块里只有一条命令时取出它, 否则 undefined (不显示"填入"):
495// 行尾续行符 (bash \ / PowerShell ` / cmd ^) 拼成一行; 空行、整行注释不算;
496// 有提示符 ($ / PS C:\>) 的块只取带提示符的行 (其余是输出); here-doc 不算单行
497function oneCommand(code: string, kind: ShellKind): string | undefined {
498 const cont = kind === 'powershell' ? '`' : kind === 'cmd' ? '^' : '\\'
499 const logical: string[] = []
500 let buf = ''
501 for (const raw of code.split('\n')) {
502 const line = buf ? raw.trim() : raw.replace(/\s+$/, '')
503 if (line.endsWith(cont)) {
504 buf += line.slice(0, -1).replace(/\s+$/, '') + ' '
505 continue
506 }
507 logical.push(buf + line)
508 buf = ''
509 }
510 if (buf) return undefined // 最后一行还在续行: 命令没写完
511 const isComment = (l: string) => (kind === 'cmd' ? /^(?:@?rem\b|::)/i.test(l) : l.startsWith('#'))
512 let cmds = logical.map(l => l.trim()).filter(l => l && !isComment(l))
513 const prompt =
514 kind === 'any' ? /^(?:PS [^>]*>|[$%>])\s+/ : kind === 'powershell' ? /^PS [^>]*>\s+/ : kind === 'bash' ? /^[$%]\s+/ : undefined
515 if (prompt && cmds.some(l => prompt.test(l))) cmds = cmds.filter(l => prompt.test(l)).map(l => l.replace(prompt, ''))
516 if (cmds.length !== 1) return undefined
517 const c = (cmds[0] ?? '').trim()
518 if (!c || /<<-?\s*['"]?\w/.test(c)) return undefined
519 return c
520}
521
522// 代码块是哪种 shell: 标了 shell 类语言的按语言; 没标语言的一行命令算通用 shell; 标了别的语言 (python、json…) 不算
523function shellKindOf(p: { lang: string; code: string }): ShellKind | undefined {
524 const k = SHELL_LANGS[p.lang.toLowerCase()]
525 if (k) return k
526 if (p.lang) return undefined
527 const lines = p.code.split('\n').filter(l => l.trim())
528 return lines.length === 1 && LOOKS_LIKE_CMD.test((lines[0] ?? '').trim()) ? 'any' : undefined
529}
530
531// ---- 行内命令 (v0.7): 文字里 `反引号` 写的命令, 在所在的块后面补一张卡片 ----
532
533// `内容` 像不像要用户运行的命令: 以 ! 开头 (! 后有空格, 或带参数, 或是已知程序), 或已知程序 + 参数;
534// 单个词、路径、参数片段 (-ExecutionPolicy)、版本号 (`python 3.12`)、超过 300 字的不算
535function inlineCommand(raw: string): string | undefined {
536 const s = raw.trim()
537 if (!s || s.length > MAX_INLINE_LEN || /[\r\n]/.test(s)) return undefined
538 if (s.startsWith('!')) {
539 const rest = s.replace(/^!\s*/, '')
540 if (!/^[A-Za-z.~\\/]/.test(rest)) return undefined // `!=` `!!` `!0`
541 return /^!\s/.test(s) || /\s/.test(rest) || INLINE_CMD.test(rest + ' x') ? s : undefined
542 }
543 if (!INLINE_CMD.test(s)) return undefined
544 const args = s.replace(/^\S+\s+/, '')
545 if (/^v?\d+(?:\.\d+)*$/.test(args)) return undefined
546 return s
547}
548
549// 同一条命令的比较键: 去掉开头的 ! / $ / PS> 提示符, 空白合并
550function cmdKey(s: string): string {
551 return s
552 .trim()
553 .replace(/^(?:PS [^>]*>|[$%>])\s+/, '')
554 .replace(/^!\s*/, '')
555 .replace(/\s+/g, ' ')
556}
557
558// 行内命令卡片的语言: powershell / pwsh、动词-名词、带反斜杠的 Windows 路径 → powershell, 其余 bash
559function inlineLang(cmd: string): 'powershell' | 'bash' {
560 const c = cmd.replace(/^!\s*/, '')
561 if (/^(?:powershell|pwsh)(?:\.exe)?(?:\s|$)/i.test(c) || PS_CMDLET.test(c)) return 'powershell'
562 if (/(?:^|[\s"'=])(?:[A-Za-z]:\\|\.{1,2}\\|[\w.-]+\\[\w.-])/.test(c)) return 'powershell'
563 return 'bash'
564}
565
566// 一段文字里 CommonMark 的行内代码: 开头几个反引号, 就找后面同样个数的反引号收尾; 找不到 = 不是代码 (流式还没写完的也一样)
567function codeSpans(s: string): { start: number; end: number; body: string }[] {
568 const out: { start: number; end: number; body: string }[] = []
569 const runEnd = (k: number) => {
570 while (s[k] === '`') k++
571 return k
572 }
573 let i = 0
574 while (i < s.length) {
575 if (s[i] === '\\' && s[i + 1] === '`') {
576 i += 2
577 continue
578 }
579 if (s[i] !== '`') {
580 i++
581 continue
582 }
583 const open = runEnd(i)
584 const n = open - i
585 let k = open
586 let close = -1
587 while (k < s.length) {
588 if (s[k] !== '`') {
589 k++
590 continue
591 }
592 const e = runEnd(k)
593 if (e - k === n) {
594 close = k
595 break
596 }
597 k = e
598 }
599 if (close < 0) {
600 i = open
601 continue
602 }
603 out.push({ start: i, end: close + n, body: s.slice(open, close) })
604 i = close + n
605 }
606 return out
607}
608
609// 文字段里的"顶层块": 卡片只放在块的末尾 (段落、整个列表项含续行和子列表、整张表格);
610// 只在顶层切, 下一段 Markdown 就不会以缩进开头被当成代码; 没闭合的 ``` (流式) 到结尾整个算一块, 不扫;
611// 列表项里缩进的 ``` 还没闭合时, 整个列表项先不补卡片 (闭合后它成了代码块, 卡片再出现在列表项后面)
612type Unit = {
613 start: number
614 end: number // 不含
615 indent: number // 卡片的左缩进: 列表项 = 内容缩进, 其余 0
616 scan: boolean
617 table: boolean
618 fenceAt?: number // 没闭合的 ``` 在第几行
619 item?: { ordered: boolean; mark: string; num: number; expect: number }
620}
621const LIST_ITEM = /^( {0,3})([-*+]|\d{1,9}[.)])( +|$)/
622const isBlank = (l?: string) => !l || !l.trim()
623const indentOf = (l: string) => /^ */.exec(l)?.[0].length ?? 0
624const isFenceOpen = (l: string) => {
625 const m = FENCE.exec(l)
626 return !!m && !(m[2]?.[0] === '`' && (m[3] ?? '').includes('`'))
627}
628const isHeading = (l: string) => /^ {0,3}#{1,6}(?:\s|$)/.test(l)
629const isQuote = (l: string) => /^ {0,3}>/.test(l)
630const isRule = (l: string) => /^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/.test(l)
631const isTableDelim = (l?: string) => !!l && /\|/.test(l) && /^ {0,3}\|?\s*:?-+:?\s*(?:\|\s*:?-+:?\s*)*\|?\s*$/.test(l)
632// 能打断段落的行 (不认 --- / ===: 它们会把上一行变成标题, 切开就变样)
633const breaksPara = (l: string) => isFenceOpen(l) || isHeading(l) || isQuote(l) || /^ {0,3}(?:[-*+]|1[.)]) +\S/.test(l)
634
635function proseUnits(lines: string[]): Unit[] {
636 const units: Unit[] = []
637 let list: { ordered: boolean; mark: string; start: number; count: number } | undefined
638 let i = 0
639 while (i < lines.length) {
640 const l = lines[i] ?? ''
641 if (isBlank(l)) {
642 i++
643 continue
644 }
645 const base = { start: i, indent: 0, scan: true, table: false }
646 if (isFenceOpen(l)) {
647 units.push({ ...base, end: lines.length, scan: false, fenceAt: i })
648 break
649 }
650 const li = isRule(l) ? null : LIST_ITEM.exec(l)
651 if (li) {
652 const marker = li[2] ?? '-'
653 const spaces = (li[3] ?? '').length
654 const w = (li[1] ?? '').length + marker.length + (spaces >= 1 && spaces <= 4 ? spaces : 1)
655 let last = i
656 let fenceAt: number | undefined
657 let j = i + 1
658 while (j < lines.length) {
659 const s = lines[j] ?? ''
660 if (isBlank(s)) {
661 let k = j
662 while (k < lines.length && isBlank(lines[k])) k++
663 if (k < lines.length && indentOf(lines[k] ?? '') >= w) {
664 j = k
665 continue
666 }
667 break
668 }
669 if (isFenceOpen(s)) {
670 // 列表项里的 (缩进够) 没闭合代码块: 到结尾都属于这一项; 不够缩进的会结束列表, 另算一块
671 if (indentOf(s) >= w) {
672 fenceAt = j
673 last = lines.length - 1
674 }
675 break
676 }
677 if (indentOf(s) < w && (LIST_ITEM.test(s) || isHeading(s) || isQuote(s) || isRule(s))) break
678 last = j
679 j++
680 }
681 // 同一个列表里第几项: 有序列表显示的序号 = 第一项的数 + 第几项
682 const ordered = /\d/.test(marker)
683 const mark = ordered ? marker.slice(-1) : marker
684 const num = ordered ? parseInt(marker, 10) : 0
685 if (!list || list.ordered !== ordered || list.mark !== mark) list = { ordered, mark, start: num, count: 0 }
686 else list.count++
687 units.push({
688 ...base,
689 end: last + 1,
690 indent: w,
691 scan: fenceAt === undefined,
692 fenceAt,
693 item: { ordered, mark, num, expect: list.start + list.count },
694 })
695 i = last + 1
696 continue
697 }
698 list = undefined
699 if (indentOf(l) >= 4) {
700 // 缩进 4 格的代码: 不扫
701 let last = i
702 let j = i + 1
703 while (j < lines.length) {
704 const s = lines[j] ?? ''
705 if (isBlank(s)) {
706 let k = j
707 while (k < lines.length && isBlank(lines[k])) k++
708 if (k < lines.length && indentOf(lines[k] ?? '') >= 4) {
709 j = k
710 continue
711 }
712 break
713 }
714 if (indentOf(s) < 4) break
715 last = j
716 j++
717 }
718 units.push({ ...base, end: last + 1, scan: false })
719 i = last + 1
720 continue
721 }
722 if (isHeading(l) || isRule(l)) {
723 units.push({ ...base, end: i + 1 })
724 i++
725 continue
726 }
727 // 段落、表格、引用: 到空行为止 (表格从表头到最后一行; 引用的懒续行也算)
728 const table = /\|/.test(l) && isTableDelim(lines[i + 1])
729 const quote = isQuote(l)
730 let j = i + 1
731 while (j < lines.length) {
732 const s = lines[j] ?? ''
733 if (isBlank(s) || isFenceOpen(s) || isHeading(s)) break
734 if (!table && !quote && breaksPara(s)) break
735 j++
736 }
737 units.push({ ...base, end: j, table })
738 i = j
739 }
740 return units
741}
742
743type InlineCard = { cmd: string; key: string; lang: 'powershell' | 'bash'; kind: ShellKind }
744type ProsePlan = { lines: string[]; units: Unit[]; cards: Map<number, InlineCard[]> }
745
746// 一个块里的行内命令 (按出现顺序): 空行、列表标记、标题、引用处另起一段再配对反引号; 表格每行单独配对;
747// [文字](链接) 的文字里的不算
748function unitCommands(lines: string[], u: Unit): string[] {
749 const groups: string[][] = []
750 let cur: string[] = []
751 const flush = () => {
752 if (cur.length) groups.push(cur)
753 cur = []
754 }
755 for (let i = u.start; i < u.end; i++) {
756 const l = lines[i] ?? ''
757 if (isBlank(l) || u.table || /^\s*(?:[-*+]|\d{1,9}[.)])(?:\s|$)|^\s*#{1,6}\s|^\s*>/.test(l)) flush()
758 if (!isBlank(l)) cur.push(l)
759 if (u.table) flush()
760 }
761 flush()
762 const found: string[] = []
763 for (const g of groups) {
764 const text = g.join('\n')
765 const linkText: [number, number][] = []
766 for (const m of text.matchAll(new RegExp(MD_LINK.source, 'g'))) linkText.push([(m.index ?? 0) + 1, (m.index ?? 0) + 1 + (m[1] ?? '').length])
767 for (const sp of codeSpans(text)) {
768 if (linkText.some(([a, b]) => sp.start >= a && sp.end <= b)) continue
769 const body = u.table ? sp.body.replace(/\\\|/g, '|') : sp.body
770 const cmd = inlineCommand(body)
771 if (cmd) found.push(cmd)
772 }
773 }
774 return found
775}
776
777// 整条回复要补哪些行内命令卡片: 去掉重复的、去掉 fenced 代码块里已经写了的, 最多 MAX_INLINE 张
778function planInline(parts: Part[]): { plans: Map<number, ProsePlan>; total: number } {
779 const known = new Set<string>()
780 const addCode = (code: string, lang = '') => {
781 for (const line of code.split('\n')) if (line.trim()) known.add(cmdKey(line))
782 const one = oneCommand(code, shellKindOf({ lang, code }) ?? 'bash')
783 if (one) known.add(cmdKey(one))
784 }
785 const plans = new Map<number, ProsePlan>()
786 parts.forEach((p, i) => {
787 if (p.kind === 'code') {
788 addCode(p.code, p.lang)
789 return
790 }
791 const lines = p.text.split('\n')
792 const units = proseUnits(lines)
793 // 没闭合的 ``` (流式中) 里写的命令也算已经有了
794 for (const u of units) if (u.fenceAt !== undefined) addCode(lines.slice(u.fenceAt + 1, u.end).join('\n'))
795 plans.set(i, { lines, units, cards: new Map() })
796 })
797 const seen = new Set<string>()
798 let total = 0
799 for (const [, plan] of plans) {
800 plan.units.forEach((u, ui) => {
801 if (!u.scan) return
802 for (const cmd of unitCommands(plan.lines, u)) {
803 const k = cmdKey(cmd)
804 if (total >= MAX_INLINE || known.has(k) || seen.has(k)) continue
805 seen.add(k)
806 total++
807 const lang = inlineLang(cmd)
808 // 猜成 bash 的 (python / git / npm …) 两种 shell 都能跑: 填入时不提醒 shell 对不上
809 const card: InlineCard = { cmd, key: k, lang, kind: lang === 'powershell' ? 'powershell' : 'any' }
810 plan.cards.set(ui, [...(plan.cards.get(ui) ?? []), card])
811 }
812 })
813 }
814 return { plans, total }
815}
816
817// 文字段按要补卡片的块切成几段原文 (行范围的切片); 有序列表从中间切开时, 下一段第一项写回它本来显示的序号
818function proseChunks(plan: ProsePlan): { text: string; indent: number; cards: InlineCard[] }[] {
819 const out: { text: string; indent: number; cards: InlineCard[] }[] = []
820 let from = 0
821 const take = (end: number, indent: number, cards: InlineCard[]) => {
822 const lines = plan.lines.slice(from, end)
823 const first = plan.units.find(u => u.start >= from)
824 const it = first?.item
825 if (from > 0 && first && first.start < end && it?.ordered && it.num !== it.expect) {
826 const at = first.start - from
827 const fixed = (lines[at] ?? '').replace(/^( {0,3})\d{1,9}/, (_m, sp: string) => sp + it.expect)
828 // 序号位数变了会挪动续行的缩进, 这种少见情况保持原样
829 if (fixed.length === (lines[at] ?? '').length) lines[at] = fixed
830 }
831 const text = trimBlankLines(lines.join('\n'))
832 if (text.trim()) out.push({ text, indent, cards })
833 }
834 plan.units.forEach((u, ui) => {
835 const cards = plan.cards.get(ui)
836 if (!cards?.length) return
837 take(u.end, u.indent, cards)
838 from = u.end
839 })
840 if (from < plan.lines.length) take(plan.lines.length, 0, [])
841 return out
842}
843
844function preview(code: string): string {
845 const lines = code.split('\n')
846 const first = (lines[0] ?? '').trim()
847 const head = first.length > 36 ? first.slice(0, 36) + '...' : first
848 return lines.length > 1 ? `${head} 等 ${lines.length} 行` : head
849}
850
851function markCopied($: any, id: string, isOn: boolean) {
852 if (isOn) copied.add(id)
853 else copied.delete(id)
854 $.ui.invalidate('ui.render')
855}
856
857async function copyText($: any, id: string, text: string, surface: any, done: string) {
858 try {
859 const r = await $.ui.copy({ text, surface })
860 if (r?.isCopied) {
861 markCopied($, id, true)
862 $.ui.toast(done)
863 $.clock.after(COPIED_MS, () => markCopied($, id, false))
864 } else {
865 $.ui.toast('没复制上 (' + String(r?.reason ?? '原因不明') + '), 可以改用 /copy')
866 }
867 } catch (err) {
868 $.ui.toast('复制失败: ' + String(err))
869 }
870}
871
872// 这台机器上 ! 开头的输入用哪个 shell 跑: 环境变量 CLAUDE_CODE_USE_POWERSHELL_TOOL 优先,
873// 其次设置里的 defaultShell, 都没有时 Windows = PowerShell, macOS / Linux = bash
874async function bangShell($: any): Promise<'bash' | 'powershell'> {
875 await useOS($)
876 const flag = String((await $.env.get('CLAUDE_CODE_USE_POWERSHELL_TOOL')) ?? '').trim().toLowerCase()
877 if (['1', 'true', 'yes', 'on'].includes(flag)) return 'powershell'
878 if (['0', 'false', 'no', 'off'].includes(flag)) return 'bash'
879 try {
880 const s: any = await $.settings.read()
881 if (s?.defaultShell === 'bash' || s?.defaultShell === 'powershell') return s.defaultShell
882 } catch {}
883 return os === 'windows' ? 'powershell' : 'bash'
884}
885
886// 填入: 以 ! 开头放进输入框 (替换原有草稿), 不提交; 代码块语言和 ! 用的 shell 对不上时提醒
887async function fillPrompt($: any, cmd: string, kind: ShellKind) {
888 try {
889 // 输入框里有打了一半的字就不覆盖 (fill 默认 replace 会清掉草稿)
890 let draft = ''
891 try {
892 draft = String((await $.prompt.read())?.text ?? '')
893 } catch {}
894 if (draft.trim()) {
895 $.ui.toast(`输入框里有没发出的字, 没覆盖; 清空后再点 ${LABEL.insert}, 或者用 ${LABEL.copy}`)
896 return
897 }
898 // 命令本身已经以 ! 开头 (Claude 写给 shell 模式用的) 就不再多加一个
899 const r = await $.prompt.fill({ text: '!' + cmd.replace(/^!\s*/, '') })
900 if (!r?.isFilled) {
901 const why = r?.refusal === 'dialog' ? '有对话框占着键盘' : r?.refusal === 'no_composer' ? '这里没有输入框' : '输入框没接收'
902 $.ui.toast(`没填进去: ${why}, 可以改用 ${LABEL.copy}`)
903 return
904 }
905 const shell = await bangShell($)
906 const clash = kind !== 'any' && kind !== shell
907 $.ui.toast(
908 '已填入输入框,按回车运行' +
909 (clash ? ` · 注意: 这是 ${SHELL_NAME[kind]} 命令, 你的 ! 用 ${SHELL_NAME[shell]} 运行` : ''),
910 )
911 } catch (err) {
912 $.ui.toast('填入失败: ' + String(err))
913 }
914}
915
916// 一张卡片: 标题栏 (语言名 … Insert Copy) + 代码区 (上下各空一行, 不贴着标题栏);
917// key 让整张卡片成为悬停范围. keys = 卡片 / 填入 / 复制 三个 key, id = 记"已复制"用的
918type CardSpec = {
919 keys: { card: string; fill: string; copy: string }
920 id: string
921 lang: string
922 code: string
923 fill?: { cmd: string; kind: ShellKind }
924}
925function drawCard($: any, els: any, c: CardSpec, gap: number) {
926 const { Box, Text, Button, Code } = els
927 const actions: any[] = []
928 const fill = c.fill
929 if (fill)
930 actions.push(
931 <Button
932 key={c.keys.fill}
933 plain
934 dimColor
935 label={LABEL.insert}
936 hover={PRESS_HOVER}
937 onPress={() => fillPrompt($, fill.cmd, fill.kind)}
938 />,
939 )
940 if (copied.has(c.id)) actions.push(<Text color="success">{LABEL.copied}</Text>)
941 else
942 actions.push(
943 <Button
944 key={c.keys.copy}
945 plain
946 dimColor
947 label={LABEL.copy}
948 hover={PRESS_HOVER}
949 onPress={press => copyText($, c.id, c.code, press.surface, '已复制: ' + preview(c.code))}
950 />,
951 )
952 const head: any[] = []
953 if (c.lang) head.push(<Text color="inactive">{c.lang}</Text>)
954 head.push(
955 <Box flexDirection="row" gap={2}>
956 {actions}
957 </Box>,
958 )
959 return (
960 // 卡片一律和回复的文字左边对齐, 不跟着列表缩进 (缩进的卡片和别的卡片左边对不齐, 用户嫌乱)
961 <Box key={c.keys.card} flexDirection="column" marginTop={gap} backgroundColor={CARD_BODY}>
962 <Box
963 flexDirection="row"
964 justifyContent={c.lang ? 'space-between' : 'flex-end'}
965 paddingX={2}
966 backgroundColor={CARD_HEAD}
967 hover={{ backgroundColor: CARD_HEAD_HOVER }}
968 >
969 {head}
970 </Box>
971 <Box paddingX={2} paddingY={1}>
972 {c.lang ? <Code source={c.code} language={c.lang} /> : <Code source={c.code} />}
973 </Box>
974 </Box>
975 )
976}
977
978// 代码块 (```) 的卡片
979function codeCard($: any, els: any, p: Extract<Part, { kind: 'code' }>, n: number, rid: string, gap: number) {
980 const kind = shellKindOf(p)
981 const cmd = kind ? oneCommand(p.code, kind) : undefined
982 return drawCard(
983 $,
984 els,
985 {
986 keys: { card: `code-${n}`, fill: `fill-${n}`, copy: `copy-${n}` },
987 id: `${rid}:${n}`,
988 lang: p.lang.slice(0, 20),
989 code: p.code,
990 fill: kind && cmd ? { cmd, kind } : undefined,
991 },
992 gap,
993 )
994}
995
996// 行内命令的卡片: 复制 = 反引号里的原文; 填入 = 补一个 ! (原文已带 ! 不重复补)
997function inlineCard($: any, els: any, c: InlineCard, k: number, rid: string) {
998 return drawCard(
999 $,
1000 els,
1001 {
1002 keys: { card: `inline-${k}`, fill: `inline-fill-${k}`, copy: `inline-copy-${k}` },
1003 id: `${rid}:inline:${c.key}`,
1004 lang: c.lang,
1005 code: c.cmd,
1006 fill: { cmd: c.cmd, kind: c.kind },
1007 },
1008 1,
1009 )
1010}
1011
1012type Drawn = { tree: any; coverRight: number }
1013
1014// 一段文字画成 Markdown; 里面有真实存在的路径就换成可点击的链接
1015async function proseMarkdown($: any, els: any, text: string, key: string, rid: string, budget: Budget) {
1016 const { Markdown } = els
1017 if (mayLink(text, true)) {
1018 const linked = await linkify($, text, true, budget)
1019 if (linked.hrefs.length) {
1020 noteFound(rid, linked.found)
1021 return (
1022 <Markdown key={key} text={linked.text} pressableLinks={linked.hrefs} onLinkPress={link => act($, absolute(link.href))} />
1023 )
1024 }
1025 }
1026 return <Markdown text={text} />
1027}
1028
1029// 终端里带代码块或行内命令的回复: 自己分段画, 文字段照常, 代码块画成卡片,
1030// 行内命令在所在块的后面补一张卡片 (原文一字不改);
1031// 返回 undefined = 不归这里画 (没有写完的代码块也没有行内命令, 或某段太长)
1032async function drawWithCopy($: any, e: any, props: { text: string; isFirstOfReply: boolean }, budget: Budget) {
1033 const parts = splitCode(props.text)
1034 const inline = planInline(parts)
1035 if (!parts.some(p => p.kind === 'code' && p.code.trim()) && inline.total === 0) return undefined
1036 if (parts.some(p => (p.kind === 'prose' ? p.text : p.fenced).length > MAX_PART)) return undefined
1037
1038 const els = $.ui.resolve(e)
1039 const { Box, Text, Markdown } = els
1040 const rid = String(e.requestId ?? '')
1041 const rows: any[] = []
1042 let n = 0
1043 let k = 0
1044 let coverRight = 0
1045 for (let i = 0; i < parts.length; i++) {
1046 const p = parts[i]
1047 if (!p) continue
1048 const gap = rows.length === 0 ? 0 : 1
1049 if (p.kind === 'prose') {
1050 const plan = inline.plans.get(i)
1051 const chunks = plan && plan.cards.size ? proseChunks(plan) : [{ text: p.text, indent: 0, cards: [] as InlineCard[] }]
1052 for (let j = 0; j < chunks.length; j++) {
1053 const c = chunks[j]
1054 if (!c) continue
1055 const key = j === 0 ? `html-links-${rid}-${i}` : `html-links-${rid}-${i}-${j}`
1056 rows.push(<Box marginTop={rows.length === 0 ? 0 : 1}>{await proseMarkdown($, els, c.text, key, rid, budget)}</Box>)
1057 for (const cmd of c.cards) rows.push(inlineCard($, els, cmd, ++k, rid))
1058 }
1059 continue
1060 }
1061 if (!p.code.trim()) {
1062 rows.push(
1063 <Box marginTop={gap} marginLeft={p.indent}>
1064 <Markdown text={p.fenced} />
1065 </Box>,
1066 )
1067 continue
1068 }
1069 n += 1
1070 // 回复第一行就是卡片标题栏时, "Copy all" 往左让开卡片自己的按钮
1071 // (右内边距 2 + Copy/Copied ✓ 中较宽的 + Insert 和间距 2 + 再空 1 格)
1072 if (i === 0) {
1073 const kind = shellKindOf(p)
1074 const insertW = kind && oneCommand(p.code, kind) ? dw(LABEL.insert) + 2 : 0
1075 coverRight = 2 + Math.max(dw(LABEL.copy), dw(LABEL.copied)) + insertW + 1
1076 }
1077 rows.push(codeCard($, els, p, n, rid, gap))
1078 }
1079 const tree = (
1080 <Box flexDirection="row">
1081 <Text>{props.isFirstOfReply ? '● ' : ' '}</Text>
1082 <Box flexDirection="column" flexGrow={1}>
1083 {rows}
1084 </Box>
1085 </Box>
1086 )
1087 return { tree, coverRight } as Drawn
1088}
1089
1090// 没有代码块 (或交给引擎画代码块) 的回复: 有能点开的路径就换成可点击的 Markdown
1091async function drawLinked($: any, e: any, props: { text: string; isFirstOfReply: boolean }, wide: boolean, budget?: Budget) {
1092 if (props.text.length > MAX_PART || !mayLink(props.text, wide)) return undefined
1093 const { text, hrefs, found } = await linkify($, props.text, wide, budget)
1094 if (hrefs.length === 0) return undefined
1095 noteFound(String(e.requestId ?? ''), found)
1096
1097 const { Box, Text, Markdown } = $.ui.resolve(e)
1098 return (
1099 <Box flexDirection="row">
1100 <Text>{props.isFirstOfReply ? '● ' : ' '}</Text>
1101 <Box flexDirection="column" flexGrow={1}>
1102 <Markdown
1103 key={'html-links-' + (e.requestId ?? '')}
1104 text={text}
1105 pressableLinks={hrefs}
1106 onLinkPress={link => act($, absolute(link.href))}
1107 />
1108 </Box>
1109 </Box>
1110 )
1111}
1112
1113// 整条回复包一层 (带 key = 悬停范围), 右上角叠一个平时隐藏的"复制全文"; 不挤动原来的排版.
1114// 小底块用代码区的深灰 (盖在卡片标题栏上也看得清), 指到它上面变成标题栏的灰
1115function withCopyAll($: any, e: any, tree: any, text: string, coverRight: number) {
1116 const { Box, Text, Button } = $.ui.resolve(e)
1117 const id = `${String(e.requestId ?? '')}:all`
1118 const isCopied = copied.has(id)
1119 const body = dedentAll(text)
1120 // 刚复制过: 不等悬停, 直接显示"已复制 ✓" (引擎不许给本来就显示的 Box 再加 hover 显示)
1121 const shown = isCopied ? { display: 'flex' as const } : { display: 'none' as const, hover: { display: 'flex' as const } }
1122 return (
1123 <Box key="reply" flexDirection="column">
1124 {tree}
1125 <Box position="absolute" top={0} right={coverRight} {...shown}>
1126 <Box key="copy-all-chip" paddingX={1} backgroundColor={CARD_BODY} hover={{ backgroundColor: CARD_HEAD }}>
1127 {isCopied ? (
1128 <Text color="success">{LABEL.copied}</Text>
1129 ) : (
1130 <Button
1131 key="copy-all"
1132 plain
1133 dimColor
1134 label={LABEL.copyAll}
1135 hover={PRESS_HOVER}
1136 onPress={press => copyText($, id, body, press.surface, '已复制全文 (' + body.split('\n').length + ' 行)')}
1137 />
1138 )}
1139 </Box>
1140 </Box>
1141 </Box>
1142 )
1143}
1144
1145// /open list 的文字: 带序号的链接, 后面跟所在目录 (项目里的写相对路径)
1146function listText(): string {
1147 const esc = (s: string) => s.replace(/([\\`*_[\]<>])/g, '\\$1')
1148 const rows = recent.slice(0, LIST_SIZE).map((abs, i) => {
1149 const dir = abs.slice(0, abs.length - baseName(abs).length).replace(/[\\/]+$/, '')
1150 const rootKey = root ? keyOf(root) : ''
1151 let where = dir
1152 if (rootKey && keyOf(dir) === rootKey) where = ''
1153 else if (rootKey && keyOf(dir).startsWith(rootKey + (os === 'windows' ? '\\' : '/'))) where = dir.slice(trimSep(root).length + 1)
1154 return `${i + 1}. [${esc(baseName(abs))}](${toHref(abs)})` + (where ? ` · \`${where.replace(/`/g, "'")}\`` : '')
1155 })
1156 return '最近的文件(单击打开,也可以 /open 序号):\n\n' + rows.join('\n')
1157}
1158
1159export const register: Register = on => {
1160 on('session.start', async ($, e, next) => {
1161 await useOS($)
1162 try {
1163 await $.command.register({
1164 name: 'open',
1165 description: '打开最近提到或写出的文件(/open 2 = 倒数第 2 个,/open list = 列出最近 10 个)',
1166 argumentHint: '[N | list]',
1167 immediate: true,
1168 })
1169 } catch (err) {
1170 $.ui.log('html-shelf: /open 注册失败 ' + String(err))
1171 }
1172 return next(e)
1173 })
1174
1175 on('command.run', { command: 'open' }, async ($, e) => {
1176 await useOS($)
1177 const arg = String(e.args || '').trim().toLowerCase()
1178 if (arg === 'list' || arg === 'ls' || arg === 'l') {
1179 if (!recent.length) return { text: '还没有可打开的文件。' }
1180 return { text: listText() }
1181 }
1182 const n = parseInt(arg || '1', 10) || 1
1183 const abs = recent[n - 1]
1184 if (!abs) return { text: recent.length ? `没有倒数第 ${n} 个(最近只有 ${recent.length} 个,/open list 看列表)。` : '还没有可打开的文件。' }
1185 await act($, abs)
1186 return {}
1187 })
1188
1189 // /open list 的输出行: 终端里链接单击就打开
1190 on('ui.render', { component: 'CommandOutput', props: { command: 'open' } }, async ($, e, next) => {
1191 if (e.surface !== 'terminal' || e.props.isErrored) return next(e)
1192 const hrefs = [...e.props.text.matchAll(/\]\((file:\/\/[^)\s]+)\)/g)].map(m => m[1] ?? '')
1193 if (!hrefs.length || e.props.text.length > MAX_PART) return next(e)
1194 await useOS($)
1195 const { Box, Markdown } = $.ui.resolve(e)
1196 return (
1197 <Box paddingLeft={2}>
1198 <Markdown key="open-list" text={e.props.text} pressableLinks={hrefs} onLinkPress={link => act($, absolute(link.href))} />
1199 </Box>
1200 )