SLOPSHOPPER

html-shelf

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

newrowsguardcommandtoastprocess
★ 6v0.7.1MITupdated 2026-10-07ronanworks/claude-code-mods/html-shelf
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · html-shelf
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ⟨Claude Code's own drawing⟩ ✻ Worked for 42s · done 4:20 PM › /open ⎿ html-shelf: 还没有可打开的文件。 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Claude's reply
⟨Claude Code's own drawing⟩
README

claude-code-mods

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

License: MIT Claude Code Platform mods

<img src="assets/usage-hud.gif" alt="usage-hud: a pixel-crab usage panel under the prompt" width="900">

What is this

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:

ModWhat it does
usage-hudA 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-shelfFile 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.

Quick start

<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 fullscreen inside 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.

usage-hud: an animated usage panel

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 1Column 2Column 3
Row 1Model + effort levelProject + git branchSession time + cost
Row 2Context usage5-hour usageWeekly usage
Row 3StatusMost-used toolsTotal 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 isThe crab
Thinking or replyingWalks sideways, eyes on where it's going; thinking dots rise while it thinks
Reading or searchingStops and reads a page while a scan line moves
Writing or editing a fileStops and taps with its right claw as the page fills with text
Running a commandStops and types with both claws, terminal cursor blinking
On the webStops beside a spinning globe
Running subagentsEach 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 turnJumps with claws up, gold sparkles
Idle / idle for 5 minutesStrolls a few steps now and then, blinks and looks around / sleeps, blowing bubbles
You type / you sendStops and looks down at the input box / jumps
At ≥ 80% contextTurns 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):

QuotaThe crabThe panel
Using less than the clock (pace < 0.8)Wears sunglasses, strollsNormal
Clearly ahead of pace, on track to run out before the resetSweats, walks fasterThe percentage and the countdown turn red: 40m用完 (empty in 40m)
Runs out within 30 minutes, or ≥ 95% usedPanics with claws up and a "!", scurriesSame as above
  • Small particles keep it lively: dust behind its feet, a drop of sweat when it hurries, gold sparkles when a turn finishes, bubbles while it sleeps.
  • Speech bubbles appear beside the crab for a few seconds: a finished turn (「搞定 3m12s」, done), a finished compaction, a quota reset, a red pace warning (「慢点!40m用完」, slow down, empty in 40m), and a permission prompt waiting for you (「等你点头」).
  • In fullscreen mode (/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.
  • The row between the strip and the input box belongs to Claude Code itself (notices such as "copied 4 chars to clipboard" appear there), so the strip can't sit any lower.
  • The engine draws [-] 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

  • Turn receipt: the line that ends each turn gets a tail such as · $0.42 · 2 files changed +5 -1 · 3 tool calls (terminal only).
  • One-click compact: at 75% context a "compact" button appears in the context cell and runs /compact.
  • Quota-reset reminder: after the 5-hour or weekly quota passed 30% and then resets, a toast says it's back.
  • Subagent board: /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.

CommandWhat it does
/hudCycles full → compact (one line) → hidden
/hud agentsOpens the subagent board; Esc closes it
/hud crab, /hud crab on, /hud crab offToggles 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" is the API list-price equivalent, the same figure /cost shows. Subscribers aren't charged this amount.
  • Tokens come from the session transcript, subagents included. Cache reads count too, so the number is usually large.

html-shelf: clickable links and code cards

<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">

  • File paths become links: paths in a reply (bare paths, ` code , or text`) open in their default app with one click. Only files that exist become links.
  • Supported: HTML, Markdown, PDF, images (png / jpg / gif / svg …), audio and video (mp4 / mov / mp3 …), Office files (docx / xlsx / pptx), csv, txt.
  • Folders open in your file manager.
  • Scripts and executables (bat, ps1, sh, py, exe …) are never run; a click only selects them in their folder.
  • "Open" on tool rows: when Claude writes a file, its 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.
  • Code cards: each code block is drawn as a card, with the language on the left of the title bar and buttons on the right.
  • "Copy": copies the exact text with no trailing newline, so pasting into a shell doesn't run it.
  • "Insert": single-line shell commands get this extra button, including an untagged one-line command such as one that starts with !. 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.
  • Commands written inline in the text (inside backticks, such as ` 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.
  • Hover the card and the title bar lights up while the buttons turn orange; after a click you see "Copied ✓".
  • Card colors come from Claude Code's current theme, so they suit both dark and light themes.
  • Copy the whole reply: hover a reply and "Copy all" appears at its top right. It copies that block's text with common indentation removed.
  • Code cards, the buttons and non-HTML links appear in the terminal only; the desktop app already has its own.

Requirements

ItemNotes
Claude CodeTerminal 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)
OSWindows: 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 filesWindows: explorer.exe / cmd start; macOS: open; Linux: xdg-open, then gio open; in WSL, Windows' default apps first (wslview, else explorer.exe via wslpath)
RenderingClicking and hovering need fullscreen rendering (/tui fullscreen)
DependenciesNode.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
  • In the macOS desktop app, Node.js installed with Homebrew or nvm may not be on the app's PATH. The token cell then falls back to counting from this launch only; everything else works.
  • If you set CLAUDE_CONFIG_DIR, usage-hud looks for session transcripts there.

Uninstall

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.

Development

Check and test a mod:

claude plugin validate ./usage-hud
claude plugin test ./usage-hud
  • Terminal sessions watch the mod folders and reload on save. Desktop app sessions don't; run /reload-plugins there.
  • Bump 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.
  • In the terminal, use only ASCII, CJK characters, │ ─ ━ ✓ 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

License

MIT © 2026 Ronan

Source 1 files
hooks/register.tsx 1258 lines
1import 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    )