From dbcdf45dc1ff25e4e9adb285b36bdc1f6e2c2188 Mon Sep 17 00:00:00 2001 From: slyxyllt Date: Wed, 2 Sep 2026 00:02:11 +0800 Subject: [PATCH 1/3] =?UTF-8?q?docs(openspec):=20add-tui-hyperlinks=20?= =?UTF-8?q?=E5=8F=98=E6=9B=B4=E6=96=87=E6=A1=A3=EF=BC=88=E7=82=B9=E5=87=BB?= =?UTF-8?q?=E6=89=93=E5=BC=80+OSC8=20=E9=80=8F=E4=BC=A0+=E6=82=AC=E5=81=9C?= =?UTF-8?q?=E6=B0=94=E6=B3=A1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../changes/add-tui-hyperlinks/.openspec.yaml | 2 + openspec/changes/add-tui-hyperlinks/design.md | 69 ++++++++++++++++++ .../changes/add-tui-hyperlinks/proposal.md | 33 +++++++++ .../specs/tui-hyperlinks/spec.md | 73 +++++++++++++++++++ openspec/changes/add-tui-hyperlinks/tasks.md | 46 ++++++++++++ 5 files changed, 223 insertions(+) create mode 100644 openspec/changes/add-tui-hyperlinks/.openspec.yaml create mode 100644 openspec/changes/add-tui-hyperlinks/design.md create mode 100644 openspec/changes/add-tui-hyperlinks/proposal.md create mode 100644 openspec/changes/add-tui-hyperlinks/specs/tui-hyperlinks/spec.md create mode 100644 openspec/changes/add-tui-hyperlinks/tasks.md diff --git a/openspec/changes/add-tui-hyperlinks/.openspec.yaml b/openspec/changes/add-tui-hyperlinks/.openspec.yaml new file mode 100644 index 00000000..b4b3ece7 --- /dev/null +++ b/openspec/changes/add-tui-hyperlinks/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-01 diff --git a/openspec/changes/add-tui-hyperlinks/design.md b/openspec/changes/add-tui-hyperlinks/design.md new file mode 100644 index 00000000..dac33703 --- /dev/null +++ b/openspec/changes/add-tui-hyperlinks/design.md @@ -0,0 +1,69 @@ +## Context(背景) + +- 链接元数据在"本地文件"场景已经端到端打通:`src/markdown/markdown_formatter.cpp` 用 `reflect(region.box)` 把每个链接的屏幕矩形记入 `opts.link_regions`,`src/main.cpp` 的鼠标处理器用 `href_at(mouse.x, mouse.y)` 命中检测后调 `open_tui_chat_file_link()`(`src/tui/chat_file_link.cpp`),后者当前用 `has_url_scheme()` 拒绝一切带 scheme 的 URL。 +- markdown 渲染器里的 `make_hyperlink()` 与 `terminal_supports_hyperlinks()` 只有定义、从未被调用。前者只返回显示文本,因为 FTXUI 没有透传任意转义序列的通道:每帧都经 `Screen::ToString()` 输出,逐格 diff `Cell`、在样式变化处经 `UpdateCellStyle()` 发 SGR——只有字符数据和属性。 +- vendored fork(`external/ftxui`)已有成熟的 ACECode 补丁体系(conhost、drag-autoscroll、mouse-origin、kitty keyboard、`658c942` 的 DEC 2026 同步输出、`ACECODE_PATCHES.md` 记录的 `idle-mouse-redraw` 补丁),改 fork 是既定惯例。值得注意:`idle-mouse-redraw` 当初**特意**把鼠标上报从 `?1003`(any-event)降为 `?1002`(button-event),以消除老 Windows 控制台上悬停移动引发的重绘抖动——SGR 解析器和 `Mouse::Motion::Moved` 事件模型本身已支持 motion,只是终端不发送。 +- 父仓库 gitlink 锁定 `658c942`,该 commit 从未 push 到 fork 远程(shaohaozhi286/FTXUI 仅有 `main` 分支)。在孤儿 commit 上继续叠补丁会让风险越滚越大。 +- OSC 8 支持无法在启动时可靠查询;沿用 DEC 2026 的 env 白名单方法(`detect_synchronized_output_support`),Apple Terminal.app(无 OSC 8)是回归敏感度最高的回退终端。 + +## Goals / Non-Goals(目标 / 非目标) + +**Goals:** + +- 聊天区内任何 `http`/`https` 链接点击即用系统默认浏览器打开,两种渲染模式均生效。 +- 渲染期中和"文字伪装 URL"(host 不符)的链接;不影响正常命名的链接。 +- 在支持的终端上发射 OSC 8,让 Cmd/Ctrl+点击、原生悬停、右键打开/复制生效——终端用户已有的肌肉记忆。 +- 悬停时在应用内浮层显示真实 URL,作为人眼兜底。 +- 不支持的终端输出与现状字节级一致。 +- 在叠加新补丁前先修掉孤儿 commit 隐患。 + +**Non-Goals:** + +- 内容滚入终端历史后仍可点击(报告差距 #8,滚屏改造——本轮只打 OSC 8 地基,收益后置)。 +- 完整 URL 匹配校验(只比域名;`faceb00k.com` 这类仿冒域名不归本层管,归浏览器)。 +- 老式/经典 Windows conhost 的悬停支持(1003 在那里保持关闭)。 +- DA1/DECRQM 查询式探测。 +- web UI 或 daemon 的 OSC 8(仅 TUI)。 + +## Decisions(决策) + +### 1. Cell 存链接 *id*,不存 URL 字符串 + +`Cell` 新增 `uint32_t link_id = 0`(0 = 无链接)。`Screen` 持有每帧 `std::vector` URL 表和 `RegisterLink(std::string) -> uint32_t` 分配器。每格塞 `std::string` 会让 `dimx * dimy` 网格的每格多 24-32 字节、且在每次 diff 中被复制;id 只有 4 字节,相邻格比较也变成平凡操作。 + +### 2. 在 `Screen::ToString()` 里按 link-id 变化发射 OSC 8 + +输出循环本来就跨格跟踪样式状态(`previous_cell_ref` + `UpdateCellStyle`)。在其基础上再跟踪当前 `link_id`:变化时发 `ESC ]8;;URL ST`(开)和 `ESC ]8;;ST`(关);相邻同 id 不重发。**每行行尾必关闭**(OSC 8 跨 `\r\n` 的行为各终端不一致,关掉重开普适安全)。空格格继承当前 link id,链接 span 内的空格也保持可点。 + +### 3. `ftxui::hyperlink(Element, url)` 装饰器作为公共 API + +风格对齐 `color()` / `underlined()`。渲染时向 screen 注册 URL,并给自己区域的每个格子打上 `link_id`。markdown 渲染器的 `is_link` 分支在现有颜色/下划线样式之后套用;`link_regions`(应用内点击用)收集逻辑不变——两条通道共享元数据但代码路径独立。 + +### 4. 只比域名的防骗校验 + +纯函数(无 env 依赖、可单测):显示文字能解析出"URL 形状"(含点号、无空格)时提取 host;host 与链接目标 host 不符 → 降级——不进 `link_regions`、去掉链接样式、按纯文本渲染。标签文字(无 URL 形状)和 host 一致的文字(省略 scheme、截断路径)放行。畸形/非 ASCII 的"URL"按不匹配处理。 + +### 5. 悬停需要 opt-in 的 `?1003` 补丁加重绘抑制 + +新增 `App::EnableMouseHoverMotion(bool)`,镜像 `EnableKittyKeyboard` / `EnableSynchronizedOutput` 的 API 风格:开启后 `EnableMouseTracking()` 发 `?1003h`(恢复被 `idle-mouse-redraw` 移除的 any-event 上报),事件循环抑制"无按键 `Mouse::Moved`"引发的帧失效,避免悬停在会抖动的控制台上触发重绘风暴。TUI 仅在终端进入 hover 安全名单时启用;老式/经典 conhost 保持 `?1002`。 + +### 6. OSC 8 探测迁入 `terminal_capability` + +把渲染器里的死代码 `terminal_supports_hyperlinks()` 吸收为 `detect_osc8_support_with()`,沿用既定 blacklist > whitelist > unknown-off 模式,补充 kitty(`KITTY_WINDOW_ID`、`TERM == xterm-kitty`)和 Ghostty 标记。**Apple Terminal.app 有意不进白名单**:无 OSC 8,必须验证无回归。发射本身即使被忽略也无害,白名单误判只是优雅降级。 + +### 7. 打补丁前先做子模块卫生 + +父仓库 gitlink 锁定的 `658c942` 只存在于 `refs/pull/1/head`(PR #1 的 head),不在任何命名分支上;`git submodule update` 默认只 fetch `refs/heads/*`,故全新 clone / CI 取不到它。此外已实测:`LIUXIN557` 身份对 fork 远程(shaohaozhi286/FTXUI)**无 push 权限**。因此发布该 commit 的路径按优先级为:① fork owner 合并 PR #1 或授予 push 权限后,推 `feat/synchronized-output`;② 改用 LIUXIN557 自己的 fork 作可写远程(同步 `.gitmodules` 的 URL);③ 最后手段:gitlink 回退 `main`(c2e90617)、放弃 DEC 2026 补丁。确定可写远程后:(1)发布 `658c942`;(2)从它开 `feat/osc8-hyperlink`;(3)OSC 8 与悬停两个补丁以独立 commit 落在分支上;(4)push 该分支;(5)更新父仓库 gitlink 并提升 `ports/ftxui` port-version。顺序有讲究:**绝不把 gitlink 前移到未 push 的 commit**。 + +### 8. A 与 B 单次交付 + +按既定范围,A 和 B 合为一次交付(一个 openspec 条目、一个应用侧 PR)。tasks.md 内部把 A 侧任务排在前面,这样即使 B 侧 fork 工作受阻,后续拆分交付只是设检查点,不是返工。 + +## Risks / Trade-offs(风险 / 取舍) + +- **A 的时间线被 B 绑定**(单次交付):设计时已知情接受;在验证前检查点仍可抽出 A 单独上线。 +- **OSC 8 与鼠标跟踪的交互**:个别终端在鼠标上报开启时可能把修饰键点击交给应用而非终端。iTerm2、kitty、WezTerm、Windows Terminal 对 OSC 8 span 的 Cmd/Ctrl+点击均正常放行;无论何种情况,应用内点击(A)都是兜底。 +- **`?1003` 在老控制台上的回归**:用门控缓解(老式/经典 conhost 关闭)加重绘抑制;`idle-mouse-redraw` 原始动机有文档记录,不是被悄悄回退。 +- **行尾关闭**每条换行链接多几个字节,相对现有每格 SGR 流量可忽略。 +- **只比域名的校验**拦不住仿冒域名;悬停气泡是人眼兜底,浏览器是最终防线。 +- **fork 面积扩大**:两个新补丁加大与上游 FTXUI 的分叉。缓解:都是 opt-in、记录在 `ACECODE_PATCHES.md`、且有上游化潜力(上游 FTXUI 无 OSC 8 支持,我们的补丁是合理的贡献候选)。 diff --git a/openspec/changes/add-tui-hyperlinks/proposal.md b/openspec/changes/add-tui-hyperlinks/proposal.md new file mode 100644 index 00000000..54dd2fc0 --- /dev/null +++ b/openspec/changes/add-tui-hyperlinks/proposal.md @@ -0,0 +1,33 @@ +## Why + +TUI 对比报告(`docs/tui-comparison/report.md`,差距 #3)要求"真正可点击的链接"。对照 `origin/master`(v0.9.9)核实,该差距实际只完成约 1/3:本地文件路径可以点击并在系统文件管理器中打开(经 `link_regions` + `open_tui_chat_file_link`),但网页链接(`http`/`https`)被 `has_url_scheme()` 判掉、点击无反应;终端也从未收到 OSC 8 序列——`src/markdown/markdown_formatter.cpp` 里的 `make_hyperlink()` 是死代码,只返回显示文本并丢弃 URL。用户只能手动选中、复制、切到浏览器粘贴;终端原生的 Cmd/Ctrl+点击、悬停、右键打开/复制能力全部缺失。 + +## What Changes + +- **A. 应用内网页链接可点击。** `open_tui_chat_file_link()` 增加 `http`/`https` 分支,用系统默认浏览器打开(macOS `open` / Linux `xdg-open` / Windows `start`),与本地文件链接共用现有 `link_regions` 命中检测路径。 +- **A. 防骗校验。** markdown 渲染时,显示文字"长得像 URL"但 host 与真实 URL 不符的链接降级为纯文本(无链接色、无下划线、不进 `link_regions`);正常命名的链接("README"、"安装指南")和 host 一致的 URL 形文字保持可点击。**只比域名,不比完整路径。** +- **B. FTXUI OSC 8 透传补丁。** vendored fork(`external/ftxui`,分支 `feat/osc8-hyperlink`)新增:`Cell` 加 `uint32_t link_id` 字段、`Screen` 维护每帧链接 URL 表、`Screen::ToString()` 在 link-id 变化处发射 OSC 8 开/关序列(每行行尾必关闭)、`ftxui::hyperlink(Element, std::string url)` 装饰器。markdown 渲染器在检测到终端支持 OSC 8 时用该装饰器包裹链接 span;删除 `make_hyperlink()` 死代码。 +- **B. 悬停气泡(`?1003` opt-in)。** fork 新增 `App::EnableMouseHoverMotion(bool)`,恢复 any-event 鼠标上报(`idle-mouse-redraw` 补丁当前强制 `?1002`),并抑制无按键悬停事件引发的无效重绘。指针在链接区域停留约 300 ms 后,TUI 在鼠标附近浮层显示真实 URL。在不安全的终端(老式/经典 conhost)上保持关闭。 +- **终端探测。** 把 `terminal_supports_hyperlinks()` 的死代码逻辑迁入 `src/utils/terminal_capability`,做成可注入 env 的纯函数(沿用 `detect_synchronized_output_support` 的 blacklist > whitelist > unknown-off 模式),补充 kitty 标记。不支持 OSC 8 的终端(notably Apple Terminal.app)行为与现状完全一致。 +- **子模块卫生。** 先把孤儿 commit `658c942`(DEC 2026 补丁,当前被父仓库 gitlink 锁定但从未 push 到 fork 远程)push 上去;从它开 `feat/osc8-hyperlink` 分支;补丁落地后更新父仓库 gitlink 和 `ports/ftxui` port-version。 + +## Capabilities + +### New Capabilities + +- `tui-hyperlinks`:聊天区网页链接可点击(应用内点击开系统浏览器)、伪装 URL 的显示文字在渲染期被中和、支持终端发射 OSC 8 原生超链接、悬停显示真实 URL 气泡。 + +### Modified Capabilities + +无。终端能力探测(`terminal_capability`)新增 helper,现有结构体、签名、行为不变。 + +## Impact + +- `external/ftxui`(分支 `feat/osc8-hyperlink`,两个补丁 commit):`include/ftxui/screen/cell.hpp`(link_id)、`include/ftxui/screen/screen.hpp` + `src/ftxui/screen/screen.cpp`(URL 表、`ToString` 中发射 OSC 8)、`include/ftxui/dom/elements.hpp` + 新 dom 源文件(`hyperlink` 装饰器)、`include/ftxui/component/app.hpp` + `src/ftxui/component/app.cpp`(`EnableMouseHoverMotion`、`?1003` 重开 + 重绘抑制)、`ACECODE_PATCHES.md`、`src/ftxui/screen/screen_test.cpp`。 +- `ports/ftxui/vcpkg.json`:port-version 提升以强制重编。 +- `src/utils/terminal_capability.hpp/.cpp`:`detect_osc8_support_with()` 纯函数 + 真实 env 包装(吸收并替换 `terminal_supports_hyperlinks()` 死代码)。 +- `src/markdown/markdown_formatter.cpp` 及相关头文件:防骗校验 helper、`ftxui::hyperlink` 接线、死代码删除。 +- `src/tui/chat_file_link.cpp`(或新建 `src/utils/open_url.cpp`):http/https 打开分支。 +- `src/main.cpp`:悬停气泡元素 + 现有 `link_regions` 命中检测循环中的鼠标移动事件处理。 +- `tests/markdown/`、`tests/tui/`、`tests/utils/`:校验、OSC 8 发射、探测的单元覆盖。 +- 不涉及协议、daemon、web 改动。 diff --git a/openspec/changes/add-tui-hyperlinks/specs/tui-hyperlinks/spec.md b/openspec/changes/add-tui-hyperlinks/specs/tui-hyperlinks/spec.md new file mode 100644 index 00000000..15fa28cd --- /dev/null +++ b/openspec/changes/add-tui-hyperlinks/specs/tui-hyperlinks/spec.md @@ -0,0 +1,73 @@ +## ADDED Requirements + +### Requirement: 网页链接点击打开系统浏览器 +聊天区渲染出的任何 `http`/`https` 链接,用户左键点击时,TUI 应(SHALL)用系统默认浏览器打开,走与本地文件链接相同的 link-region 命中检测路径。打开为尽力而为、不得致命失败。 + +#### Scenario: 点击网页链接打开浏览器 +- **当** 用户左键点击聊天区中渲染出的 `http`/`https` 链接 +- **则** 该 URL 由系统默认浏览器打开(macOS 用 `open`、Linux 用 `xdg-open`、Windows 用 `start`) + +#### Scenario: 本地文件链接不受影响 +- **当** 用户左键点击本地文件路径链接 +- **则** 系统文件管理器在对应路径打开,行为与现状一致 + +#### Scenario: 打开失败有提示、不致命 +- **当** 浏览器启动命令失败(非零退出或 exec 错误) +- **则** 状态栏提示无法打开该链接,TUI 继续运行 + +#### Scenario: 非链接文本不受影响 +- **当** 用户点击不属于任何 link region 的文本 +- **则** 走现有的选中文本/兜底行为,不打开浏览器 + +### Requirement: 伪装 URL 的显示文字在渲染期被中和 +当 markdown 链接的显示文字呈 URL 形状、但其 host 与链接目标 host 不符时,TUI 应(SHALL)将该链接降级为纯文本(无链接样式、不进可点击区域)。显示文字为普通标签、或 URL 形文字的 host 与目标一致(含省略 scheme 或截断路径的形式)的链接应保持可点击。比较应(SHALL)仅限域名。 + +#### Scenario: 伪装 host 被降级 +- **当** 链接显示为 `github.com/foo/bar`,但其目标 host 为 `phish.example.com` +- **则** 按纯文本渲染:无链接色、无下划线、无点击行为 + +#### Scenario: 命名链接保持可点击 +- **当** 链接显示为"安装指南",目标为任意 URL +- **则** 保持可点击并保留链接样式 + +#### Scenario: 省略 scheme 且 host 一致的文字保持可点击 +- **当** 链接显示为 `github.com/foo`,目标为 `https://github.com/foo/...` +- **则** 因 host 一致而保持可点击 + +#### Scenario: 畸形文字按不匹配处理 +- **当** URL 形状的显示文字无法解析出 host(非 ASCII 或畸形) +- **则** 该链接降级为纯文本 + +### Requirement: 支持的终端发射 OSC 8 原生超链接 +检测到 OSC 8 支持时,TUI 应(SHALL)在渲染出的链接 span 周围发射终端原生超链接(OSC 8 开/关对),使终端自身提供 Cmd/Ctrl+点击、悬停、右键打开/复制能力。发射应(SHALL)在每行行尾关闭超链接;同一链接的相邻格子不应(SHALL NOT)重复发射;未检测到支持的终端上应完全不发射。 + +#### Scenario: 链接 span 被 OSC 8 包裹 +- **当** 链接 span 在检测到 OSC 8 支持的终端上渲染 +- **则** 输出字节流在 span 文本前含带真实 URL 的 OSC 8 开序列、其后含关序列 + +#### Scenario: 行边界总是关闭 +- **当** 链接 span 跨输出行换行 +- **则** 每行在行尾关闭其超链接,下一行重新打开 + +#### Scenario: 同一链接的相邻格子不重复发射 +- **当** span 的连续格子共享同一 link id +- **则** 其间不产生多余的开关序列 + +#### Scenario: 不支持的终端不发射 +- **当** 终端不在 OSC 8 白名单内(例如 Apple Terminal.app) +- **则** 字节流不含任何 OSC 8 序列,渲染与无该特性的构建一致 + +### Requirement: 悬停链接在气泡中显示真实 URL +在启用了悬停移动上报的终端上,指针无按键停留于链接区域约 300 ms 后,TUI 应(SHALL)在指针附近显示浮层气泡,展示该链接的真实 URL。指针离开区域或用户按 Esc 时气泡应(SHALL)消失;气泡不得(SHALL NOT)改变布局或捕获输入。 + +#### Scenario: 停留显示真实 URL +- **当** 指针无按键悬停在链接区域约 300 ms +- **则** 指针附近出现气泡,显示该链接的真实 URL + +#### Scenario: 移开或 Esc 隐藏气泡 +- **当** 指针离开链接区域,或气泡可见时用户按 Esc +- **则** 气泡消失 + +#### Scenario: 不安全的终端不启用悬停移动 +- **当** TUI 启动于 any-event 鼠标上报不安全的终端(老式或经典 Windows 控制台宿主) +- **则** 悬停移动上报保持关闭、不显示气泡,其余行为不变 diff --git a/openspec/changes/add-tui-hyperlinks/tasks.md b/openspec/changes/add-tui-hyperlinks/tasks.md new file mode 100644 index 00000000..c12d21b8 --- /dev/null +++ b/openspec/changes/add-tui-hyperlinks/tasks.md @@ -0,0 +1,46 @@ +## 1. 子模块卫生(fork 远程) + +- [x] 1.1 发布孤儿 commit `658c942`(**已通过 PR #1 合并解决,2026-09-01 23:35 前后确认**):shaohaozhi286/FTXUI PR #1 真 merge(`20c99b5d`,父 = `c2e90617`+`658c942c`),`658c942` 已是 `main` 祖先,任何 clone 均可取到。`.gitmodules` 已回切 `shaohaozhi286/FTXUI.git` 并删除 `branch` 行(`97e6e351`);子模块 `origin` 指回官方,`myfork`(LIUXIN557/FTXUI)保留作可写备用。 +- [ ] 1.2 在 `external/ftxui` 从 `658c942` 创建 `feat/osc8-hyperlink` 分支(分支留在与 1.1 相同的可写远程上)。 + +## 2. FTXUI 补丁:OSC 8 透传 + +- [ ] 2.1 给 `Cell` 加 `uint32_t link_id`(`include/ftxui/screen/cell.hpp`),默认 0,`ACECODE-PATCH(osc8-hyperlink)` 标记。 +- [ ] 2.2 增加 `Screen` 级链接 URL 表 + `RegisterLink(std::string) -> uint32_t`(`include/ftxui/screen/screen.hpp`、`src/ftxui/screen/screen.cpp`)。 +- [ ] 2.3 在 `Screen::ToString(std::string&)` 中按 link-id 变化发射 OSC 8 开/关:与样式状态并行跟踪当前 id、每行行尾关闭、相邻同 id 不重发、空格继承当前 id。 +- [ ] 2.4 新增 `ftxui::hyperlink(Element, std::string url)` 装饰器(`include/ftxui/dom/elements.hpp` + dom 源文件):注册 URL、给区域格子打 `link_id`。 +- [ ] 2.5 `src/ftxui/screen/screen_test.cpp` 单测:开/关序列的精确字节断言、行尾关闭、相邻同 id 去重、混合样式、无链接时输出与旧版一致。 +- [ ] 2.6 在 `external/ftxui/ACECODE_PATCHES.md` 登记该补丁。 + +## 3. FTXUI 补丁:悬停移动(`?1003` opt-in) + +- [ ] 3.1 新增 `App::EnableMouseHoverMotion(bool)`(`include/ftxui/component/app.hpp`),镜像 `EnableKittyKeyboard`。 +- [ ] 3.2 `src/ftxui/component/app.cpp`:开启时 `EnableMouseTracking()`/退出清理发 `?1003h`/`?1003l` 替代 `?1002`;抑制无按键 `Mouse::Moved` 事件的帧失效。 +- [ ] 3.3 更新 `app_test.cpp` 期望序列;写入 `ACECODE_PATCHES.md`(交叉引用 `idle-mouse-redraw` 的动机)。 + +## 4. 应用侧:探测与 markdown 接线 + +- [ ] 4.1 在 `src/utils/terminal_capability.hpp/.cpp` 增加 `detect_osc8_support_with()`(env 可注入纯函数)+ `detect_osc8_support()` 包装,吸收 `terminal_supports_hyperlinks()` 并补 kitty/Ghostty 标记;Apple Terminal.app 不进白名单。 +- [ ] 4.2 增加 hover 安全探测(同模式;老式/经典 conhost 关闭),用于门控 `EnableMouseHoverMotion`。 +- [ ] 4.3 `tests/utils/terminal_capability_test.cpp` 覆盖每个白名单/黑名单/未知项的单测。 +- [ ] 4.4 实现只比域名的防骗校验 helper(markdown 或 tui utils;纯函数、可单测),在 `src/markdown/markdown_formatter.cpp` 接入:不匹配的链接不进 `link_regions`、去链接样式。 +- [ ] 4.5 检测到 OSC 8 时用 `ftxui::hyperlink(e, style.href)` 包裹 `is_link` span;删除 `make_hyperlink()` 和渲染器内的 `terminal_supports_hyperlinks()` 死代码。 +- [ ] 4.6 校验 helper 单测(伪装 host 降级、标签文字放行、host 一致的截断文字放行、畸形 URL 降级)。 + +## 5. 应用侧:点击打开与悬停气泡 + +- [ ] 5.1 在聊天链接打开器(`src/tui/chat_file_link.cpp` 或新建 `src/utils/open_url.cpp`)加 http/https 分支:macOS `open`、Linux `xdg-open`、Windows `start`;失败经状态栏提示,绝不崩溃。 +- [ ] 5.2 `src/main.cpp`:`App::Loop()` 前按探测结果启用 `EnableMouseHoverMotion` 与 OSC 8。 +- [ ] 5.3 `src/main.cpp`:无按键 `Mouse::Moved` 时命中检测 `link_regions`;停留约 300 ms 后在指针附近渲染浮层显示真实 URL;移开/Esc 隐藏。气泡不得抢焦点、不得挤压布局。 +- [ ] 5.4 `tests/tui/` 单测 URL 打开命令构造(可 mock 的 runner)。 + +## 6. 集成与验收 + +- [ ] 6.1 更新父仓库 gitlink 指向已 push 的 `feat/osc8-hyperlink` 头;提升 `ports/ftxui/vcpkg.json` 的 port-version。 +- [ ] 6.2 重编 ftxui + `acecode` + `acecode_unit_tests`;跑全量单测 + 新增聚焦测试。 +- [ ] 6.3 `openspec validate add-tui-hyperlinks --strict`。 +- [ ] 6.4 人工矩阵(用户验证):iTerm2(Cmd+点击可开、原生悬停、右键打开/复制)、Apple Terminal.app(字节级回退:无 OSC 8、应用内点击照常、无悬停回归)、Windows Terminal(Ctrl+点击可开、悬停无闪屏)。 +- [ ] 6.5 在验证备注中记录顺带核实的终端(kitty、WezTerm、老式 conhost)。 + +## Verification + From 0b97e49ba45493a47744fa9812157bbae4510b5e Mon Sep 17 00:00:00 2001 From: slyxyllt Date: Thu, 3 Sep 2026 00:02:55 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat(tui):=20TUI=20=E8=B6=85=E9=93=BE?= =?UTF-8?q?=E6=8E=A5=E5=A2=9E=E5=BC=BA=205.1=E2=80=935.3=EF=BC=88=E7=82=B9?= =?UTF-8?q?=E5=87=BB=E6=89=93=E5=BC=80=20/=20OSC8=20=E9=80=8F=E4=BC=A0=20/?= =?UTF-8?q?=20=E6=82=AC=E5=81=9C=E6=B0=94=E6=B3=A1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 5.1 点击打开:网页链接(http/https)经浏览器打开,本地文件链接走系统文件 管理器;恢复 open_tui_chat_file_link「只处理本地链接」契约,修复 ExternalLinkDoesNotCallLocalLauncher 回归。 5.2 OSC 8 透传:markdown 渲染注入 OSC 8 转义,新增 link_safety 模块做 URL 安全过滤。 5.3 悬停气泡:terminal_capability 探测 hover motion 支持,tui_state 新增 hover 字段,main.cpp 以 dbox 叠加无布局侵入气泡并调用 screen.EnableMouseHoverMotion。 新增 utils/open_url(is_openable_http_url / open_url_in_browser,默认不经 shell 启动器);ports/ftxui port-version 3→4(子模块源码变更需 bump 以刷新 vcpkg ABI hash);openspec 文档(design/proposal/tasks)同步更新。 注意:本提交冻结 external/ftxui gitlink 于 658c942c。src/main.cpp 调用了 未合并 hover 补丁(f98588b4, FTXUI PR #2)新增的 EnableMouseHoverMotion, 故分支需待 PR #2 合并并前移 gitlink 后方可独立编译。 --- CMakeLists.txt | 1 + openspec/changes/add-tui-hyperlinks/design.md | 24 +- .../changes/add-tui-hyperlinks/proposal.md | 10 +- openspec/changes/add-tui-hyperlinks/tasks.md | 42 ++-- ports/ftxui/portfile.cmake | 4 +- ports/ftxui/vcpkg.json | 2 +- src/main.cpp | 196 ++++++++++++++-- src/markdown/link_safety.cpp | 112 +++++++++ src/markdown/link_safety.hpp | 33 +++ src/markdown/markdown_formatter.cpp | 81 ++++--- src/markdown/markdown_types.hpp | 5 + src/tui/chat_file_link.cpp | 6 + src/tui/thinking_animation.hpp | 5 + src/tui_state.hpp | 16 ++ src/utils/open_url.cpp | 104 +++++++++ src/utils/open_url.hpp | 34 +++ src/utils/terminal_capability.cpp | 86 +++++++ src/utils/terminal_capability.hpp | 37 +++ tests/markdown/link_safety_test.cpp | 120 ++++++++++ tests/utils/open_url_test.cpp | 95 ++++++++ tests/utils/terminal_capability_test.cpp | 214 ++++++++++++++++++ 21 files changed, 1130 insertions(+), 97 deletions(-) create mode 100644 src/markdown/link_safety.cpp create mode 100644 src/markdown/link_safety.hpp create mode 100644 src/utils/open_url.cpp create mode 100644 src/utils/open_url.hpp create mode 100644 tests/markdown/link_safety_test.cpp create mode 100644 tests/utils/open_url_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index e0cb4b07..4dcb2380 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -254,6 +254,7 @@ set(ACECODE_TESTABLE_TUI_SOURCES ${CMAKE_SOURCE_DIR}/src/markdown/markdown_formatter.cpp ${CMAKE_SOURCE_DIR}/src/markdown/markdown_lexer.cpp ${CMAKE_SOURCE_DIR}/src/markdown/mermaid_renderer.cpp + ${CMAKE_SOURCE_DIR}/src/markdown/link_safety.cpp ${CMAKE_SOURCE_DIR}/src/markdown/syntax_highlight.cpp ${CMAKE_SOURCE_DIR}/src/tui/ask_question_overlay.cpp ${CMAKE_SOURCE_DIR}/src/tui/chat_file_link.cpp diff --git a/openspec/changes/add-tui-hyperlinks/design.md b/openspec/changes/add-tui-hyperlinks/design.md index dac33703..4997a9cb 100644 --- a/openspec/changes/add-tui-hyperlinks/design.md +++ b/openspec/changes/add-tui-hyperlinks/design.md @@ -1,9 +1,9 @@ ## Context(背景) - 链接元数据在"本地文件"场景已经端到端打通:`src/markdown/markdown_formatter.cpp` 用 `reflect(region.box)` 把每个链接的屏幕矩形记入 `opts.link_regions`,`src/main.cpp` 的鼠标处理器用 `href_at(mouse.x, mouse.y)` 命中检测后调 `open_tui_chat_file_link()`(`src/tui/chat_file_link.cpp`),后者当前用 `has_url_scheme()` 拒绝一切带 scheme 的 URL。 -- markdown 渲染器里的 `make_hyperlink()` 与 `terminal_supports_hyperlinks()` 只有定义、从未被调用。前者只返回显示文本,因为 FTXUI 没有透传任意转义序列的通道:每帧都经 `Screen::ToString()` 输出,逐格 diff `Cell`、在样式变化处经 `UpdateCellStyle()` 发 SGR——只有字符数据和属性。 +- markdown 渲染器里的 `make_hyperlink()` 与 `terminal_supports_hyperlinks()` 只有定义、从未被调用。前者只返回显示文本,其注释称"FTXUI Elements 层面发不出 OSC 8"——**该注释基于旧版判断,已过时**:当前 fork 基线(`658c942`,2026-08 的 main)随上游自带完整 OSC 8 支持(见决策 1-3),实际缺口只是渲染器未接线 + 终端探测未调用。 - vendored fork(`external/ftxui`)已有成熟的 ACECode 补丁体系(conhost、drag-autoscroll、mouse-origin、kitty keyboard、`658c942` 的 DEC 2026 同步输出、`ACECODE_PATCHES.md` 记录的 `idle-mouse-redraw` 补丁),改 fork 是既定惯例。值得注意:`idle-mouse-redraw` 当初**特意**把鼠标上报从 `?1003`(any-event)降为 `?1002`(button-event),以消除老 Windows 控制台上悬停移动引发的重绘抖动——SGR 解析器和 `Mouse::Motion::Moved` 事件模型本身已支持 motion,只是终端不发送。 -- 父仓库 gitlink 锁定 `658c942`,该 commit 从未 push 到 fork 远程(shaohaozhi286/FTXUI 仅有 `main` 分支)。在孤儿 commit 上继续叠补丁会让风险越滚越大。 +- 父仓库 gitlink 锁定 `658c942`。**孤儿 commit 隐患已解决**(2026-09-01):shaohaozhi286/FTXUI PR #1 真 merge(`20c99b5d`)后 `658c942` 已是 `main` 祖先,任何 clone 可取;`.gitmodules` 已回切官方 URL。 - OSC 8 支持无法在启动时可靠查询;沿用 DEC 2026 的 env 白名单方法(`detect_synchronized_output_support`),Apple Terminal.app(无 OSC 8)是回归敏感度最高的回退终端。 ## Goals / Non-Goals(目标 / 非目标) @@ -15,7 +15,7 @@ - 在支持的终端上发射 OSC 8,让 Cmd/Ctrl+点击、原生悬停、右键打开/复制生效——终端用户已有的肌肉记忆。 - 悬停时在应用内浮层显示真实 URL,作为人眼兜底。 - 不支持的终端输出与现状字节级一致。 -- 在叠加新补丁前先修掉孤儿 commit 隐患。 +- 孤儿 commit 隐患已在实施前修复(PR #1 合并,见决策 7)。 **Non-Goals:** @@ -27,17 +27,17 @@ ## Decisions(决策) -### 1. Cell 存链接 *id*,不存 URL 字符串 +### 1. OSC 8 透传采用上游现成实现,不写补丁 -`Cell` 新增 `uint32_t link_id = 0`(0 = 无链接)。`Screen` 持有每帧 `std::vector` URL 表和 `RegisterLink(std::string) -> uint32_t` 分配器。每格塞 `std::string` 会让 `dimx * dimy` 网格的每格多 24-32 字节、且在每次 diff 中被复制;id 只有 4 字节,相邻格比较也变成平凡操作。 +核实(2026-09-02):fork 基线 `658c942` 已随上游 FTXUI 自带全套 OSC 8 支持——`Cell::hyperlink`(`uint8_t`,0 = 无链接,索引 Screen 元数据)、`Screen::RegisterHyperlink(std::string_view) -> uint8_t` + `Hyperlink(uint8_t)`(带去重与 255 上限保护)、`Screen::ToString()` 经 `UpdateCellStyle()` 在 link-id 变化处发射 `\x1B]8;;URL\x1B\\` 开/关序列(行尾/结尾经 default_cell 复位自动关闭)、`ftxui::hyperlink()` 装饰器(`elements.hpp:130-131` + `hyperlink.cpp`)。方案与原设计的"Cell 存 id + Screen URL 表 + ToString 发射 + 装饰器"完全同构(上游用 `uint8_t` 而非 `uint32_t`,上限 255 有保护),**无需任何框架改动**。本变更的框架侧只剩 hover 补丁(决策 5)。 -### 2. 在 `Screen::ToString()` 里按 link-id 变化发射 OSC 8 +### 2. 行尾关闭与相邻去重已由上游保证 -输出循环本来就跨格跟踪样式状态(`previous_cell_ref` + `UpdateCellStyle`)。在其基础上再跟踪当前 `link_id`:变化时发 `ESC ]8;;URL ST`(开)和 `ESC ]8;;ST`(关);相邻同 id 不重发。**每行行尾必关闭**(OSC 8 跨 `\r\n` 的行为各终端不一致,关掉重开普适安全)。空格格继承当前 link id,链接 span 内的空格也保持可点。 +`Screen::ToString()` 在换行处先经 `UpdateCellStyle(..., default_cell)` 复位样式(含 hyperlink),再输出 `\r\n`;结尾同样复位——行尾必关闭 ✓。`UpdateCellStyle()` 只在 `next.hyperlink != prev.hyperlink` 时发射——相邻同 id 不重发 ✓。`hyperlink_test.cpp` 已有字节级断言(开/关/相邻切换)✓。这些验收点 spec 里已有覆盖,无需新增框架测试。 -### 3. `ftxui::hyperlink(Element, url)` 装饰器作为公共 API +### 3. `ftxui::hyperlink()` 装饰器现成可用 -风格对齐 `color()` / `underlined()`。渲染时向 screen 注册 URL,并给自己区域的每个格子打上 `link_id`。markdown 渲染器的 `is_link` 分支在现有颜色/下划线样式之后套用;`link_regions`(应用内点击用)收集逻辑不变——两条通道共享元数据但代码路径独立。 +上游 `hyperlink.cpp` 的 `Hyperlink::Render()` 先 `RegisterHyperlink(link_)` 再给区域格子打 `hyperlink` id,API 为 `hyperlink(std::string_view link, Element)` 与装饰器重载 `hyperlink(std::string_view link)`。markdown 渲染器 `is_link` 分支只需在现有颜色/下划线样式之后套用装饰器(检测到 OSC 8 支持时);`link_regions`(应用内点击用)收集逻辑不变——两条通道共享元数据但代码路径独立。 ### 4. 只比域名的防骗校验 @@ -51,9 +51,9 @@ 把渲染器里的死代码 `terminal_supports_hyperlinks()` 吸收为 `detect_osc8_support_with()`,沿用既定 blacklist > whitelist > unknown-off 模式,补充 kitty(`KITTY_WINDOW_ID`、`TERM == xterm-kitty`)和 Ghostty 标记。**Apple Terminal.app 有意不进白名单**:无 OSC 8,必须验证无回归。发射本身即使被忽略也无害,白名单误判只是优雅降级。 -### 7. 打补丁前先做子模块卫生 +### 7. 子模块卫生已完成 -父仓库 gitlink 锁定的 `658c942` 只存在于 `refs/pull/1/head`(PR #1 的 head),不在任何命名分支上;`git submodule update` 默认只 fetch `refs/heads/*`,故全新 clone / CI 取不到它。此外已实测:`LIUXIN557` 身份对 fork 远程(shaohaozhi286/FTXUI)**无 push 权限**。因此发布该 commit 的路径按优先级为:① fork owner 合并 PR #1 或授予 push 权限后,推 `feat/synchronized-output`;② 改用 LIUXIN557 自己的 fork 作可写远程(同步 `.gitmodules` 的 URL);③ 最后手段:gitlink 回退 `main`(c2e90617)、放弃 DEC 2026 补丁。确定可写远程后:(1)发布 `658c942`;(2)从它开 `feat/osc8-hyperlink`;(3)OSC 8 与悬停两个补丁以独立 commit 落在分支上;(4)push 该分支;(5)更新父仓库 gitlink 并提升 `ports/ftxui` port-version。顺序有讲究:**绝不把 gitlink 前移到未 push 的 commit**。 +父仓库 gitlink 锁定的 `658c942` 曾因只在 `refs/pull/1/head`、不在任何命名分支上而构成孤儿 commit 隐患;且 `LIUXIN557` 对 shaohaozhi286/FTXUI 无 push 权限。**已解决(2026-09-01)**:shaohaozhi286/FTXUI PR #1 真 merge(`20c99b5d`,父 = `c2e90617` + `658c942c`),`658c942` 已是 `main` 祖先,任何机器 clone 可取;`.gitmodules` 已回切 `shaohaozhi286/FTXUI.git` 并删除 `branch` 行(`97e6e351`);子模块 `origin` 指回官方、`myfork`(LIUXIN557/FTXUI)保留作可写备用。`feat/osc8-hyperlink` 分支已从 `658c942` 创建(任务 1.2)。hover 补丁完成后:push 该分支到可写远程 → 更新父仓库 gitlink 并提升 `ports/ftxui` port-version。顺序有讲究:**绝不把 gitlink 前移到未 push 的 commit**。 ### 8. A 与 B 单次交付 @@ -66,4 +66,4 @@ - **`?1003` 在老控制台上的回归**:用门控缓解(老式/经典 conhost 关闭)加重绘抑制;`idle-mouse-redraw` 原始动机有文档记录,不是被悄悄回退。 - **行尾关闭**每条换行链接多几个字节,相对现有每格 SGR 流量可忽略。 - **只比域名的校验**拦不住仿冒域名;悬停气泡是人眼兜底,浏览器是最终防线。 -- **fork 面积扩大**:两个新补丁加大与上游 FTXUI 的分叉。缓解:都是 opt-in、记录在 `ACECODE_PATCHES.md`、且有上游化潜力(上游 FTXUI 无 OSC 8 支持,我们的补丁是合理的贡献候选)。 +- **fork 面积扩大**:仅新增悬停补丁一个,分叉面比原方案(OSC 8 + 悬停两个补丁)小一半;OSC 8 是上游功能零分叉。悬停补丁为 opt-in、记录在 `ACECODE_PATCHES.md`。 diff --git a/openspec/changes/add-tui-hyperlinks/proposal.md b/openspec/changes/add-tui-hyperlinks/proposal.md index 54dd2fc0..f0a7b5bb 100644 --- a/openspec/changes/add-tui-hyperlinks/proposal.md +++ b/openspec/changes/add-tui-hyperlinks/proposal.md @@ -6,10 +6,10 @@ TUI 对比报告(`docs/tui-comparison/report.md`,差距 #3)要求"真正 - **A. 应用内网页链接可点击。** `open_tui_chat_file_link()` 增加 `http`/`https` 分支,用系统默认浏览器打开(macOS `open` / Linux `xdg-open` / Windows `start`),与本地文件链接共用现有 `link_regions` 命中检测路径。 - **A. 防骗校验。** markdown 渲染时,显示文字"长得像 URL"但 host 与真实 URL 不符的链接降级为纯文本(无链接色、无下划线、不进 `link_regions`);正常命名的链接("README"、"安装指南")和 host 一致的 URL 形文字保持可点击。**只比域名,不比完整路径。** -- **B. FTXUI OSC 8 透传补丁。** vendored fork(`external/ftxui`,分支 `feat/osc8-hyperlink`)新增:`Cell` 加 `uint32_t link_id` 字段、`Screen` 维护每帧链接 URL 表、`Screen::ToString()` 在 link-id 变化处发射 OSC 8 开/关序列(每行行尾必关闭)、`ftxui::hyperlink(Element, std::string url)` 装饰器。markdown 渲染器在检测到终端支持 OSC 8 时用该装饰器包裹链接 span;删除 `make_hyperlink()` 死代码。 -- **B. 悬停气泡(`?1003` opt-in)。** fork 新增 `App::EnableMouseHoverMotion(bool)`,恢复 any-event 鼠标上报(`idle-mouse-redraw` 补丁当前强制 `?1002`),并抑制无按键悬停事件引发的无效重绘。指针在链接区域停留约 300 ms 后,TUI 在鼠标附近浮层显示真实 URL。在不安全的终端(老式/经典 conhost)上保持关闭。 +- **B. OSC 8 原生超链接(上游已有,直接采用)。** 核实发现 vendored fork(`external/ftxui`,基线 `658c942`)已随上游 FTXUI 自带完整 OSC 8 支持:`Cell::hyperlink` 字段、`Screen::RegisterHyperlink()`/`Hyperlink()` URL 表、`Screen::ToString()` 在 link-id 变化处发射 OSC 8 开/关序列(行尾自动关闭)、`ftxui::hyperlink()` 装饰器,且已有字节级单测(`hyperlink_test.cpp`)。**无需框架补丁**——markdown 渲染器在检测到终端支持 OSC 8 时用现成装饰器包裹链接 span;删除 `make_hyperlink()` 死代码(其"FTXUI 无法透传"的注释基于旧版判断,已过时)。 +- **B. 悬停气泡(`?1003` opt-in,唯一需要的框架补丁)。** fork 新增 `App::EnableMouseHoverMotion(bool)`,恢复 any-event 鼠标上报(`idle-mouse-redraw` 补丁当前强制 `?1002`),并抑制无按键悬停事件引发的无效重绘。指针在链接区域停留约 300 ms 后,TUI 在鼠标附近浮层显示真实 URL。在不安全的终端(老式/经典 conhost)上保持关闭。 - **终端探测。** 把 `terminal_supports_hyperlinks()` 的死代码逻辑迁入 `src/utils/terminal_capability`,做成可注入 env 的纯函数(沿用 `detect_synchronized_output_support` 的 blacklist > whitelist > unknown-off 模式),补充 kitty 标记。不支持 OSC 8 的终端(notably Apple Terminal.app)行为与现状完全一致。 -- **子模块卫生。** 先把孤儿 commit `658c942`(DEC 2026 补丁,当前被父仓库 gitlink 锁定但从未 push 到 fork 远程)push 上去;从它开 `feat/osc8-hyperlink` 分支;补丁落地后更新父仓库 gitlink 和 `ports/ftxui` port-version。 +- **子模块卫生(已完成)。** 孤儿 commit `658c942` 已通过 shaohaozhi286/FTXUI PR #1 合并(`20c99b5d` 真 merge)进入 `main` 历史;`.gitmodules` 已回切官方 URL 并删除 `branch` 行(`97e6e351`);`feat/osc8-hyperlink` 分支已从 `658c942` 创建,补丁落地后 push 到可写远程并更新父仓库 gitlink。 ## Capabilities @@ -23,8 +23,8 @@ TUI 对比报告(`docs/tui-comparison/report.md`,差距 #3)要求"真正 ## Impact -- `external/ftxui`(分支 `feat/osc8-hyperlink`,两个补丁 commit):`include/ftxui/screen/cell.hpp`(link_id)、`include/ftxui/screen/screen.hpp` + `src/ftxui/screen/screen.cpp`(URL 表、`ToString` 中发射 OSC 8)、`include/ftxui/dom/elements.hpp` + 新 dom 源文件(`hyperlink` 装饰器)、`include/ftxui/component/app.hpp` + `src/ftxui/component/app.cpp`(`EnableMouseHoverMotion`、`?1003` 重开 + 重绘抑制)、`ACECODE_PATCHES.md`、`src/ftxui/screen/screen_test.cpp`。 -- `ports/ftxui/vcpkg.json`:port-version 提升以强制重编。 +- `external/ftxui`(分支 `feat/osc8-hyperlink`,**一个**补丁 commit):`include/ftxui/component/app.hpp` + `src/ftxui/component/app.cpp`(`EnableMouseHoverMotion`、`?1003` 重开 + 重绘抑制)、`src/ftxui/component/app_test.cpp`、`ACECODE_PATCHES.md`。OSC 8 部分零改动(上游已有)。 +- `ports/ftxui/vcpkg.json`:port-version 提升以强制重编(若 hover 补丁落地)。 - `src/utils/terminal_capability.hpp/.cpp`:`detect_osc8_support_with()` 纯函数 + 真实 env 包装(吸收并替换 `terminal_supports_hyperlinks()` 死代码)。 - `src/markdown/markdown_formatter.cpp` 及相关头文件:防骗校验 helper、`ftxui::hyperlink` 接线、死代码删除。 - `src/tui/chat_file_link.cpp`(或新建 `src/utils/open_url.cpp`):http/https 打开分支。 diff --git a/openspec/changes/add-tui-hyperlinks/tasks.md b/openspec/changes/add-tui-hyperlinks/tasks.md index c12d21b8..a6bdcf24 100644 --- a/openspec/changes/add-tui-hyperlinks/tasks.md +++ b/openspec/changes/add-tui-hyperlinks/tasks.md @@ -1,38 +1,38 @@ ## 1. 子模块卫生(fork 远程) - [x] 1.1 发布孤儿 commit `658c942`(**已通过 PR #1 合并解决,2026-09-01 23:35 前后确认**):shaohaozhi286/FTXUI PR #1 真 merge(`20c99b5d`,父 = `c2e90617`+`658c942c`),`658c942` 已是 `main` 祖先,任何 clone 均可取到。`.gitmodules` 已回切 `shaohaozhi286/FTXUI.git` 并删除 `branch` 行(`97e6e351`);子模块 `origin` 指回官方,`myfork`(LIUXIN557/FTXUI)保留作可写备用。 -- [ ] 1.2 在 `external/ftxui` 从 `658c942` 创建 `feat/osc8-hyperlink` 分支(分支留在与 1.1 相同的可写远程上)。 +- [x] 1.2 在 `external/ftxui` 从 `658c942` 创建 `feat/osc8-hyperlink` 分支(分支留在与 1.1 相同的可写远程上)。**2026-09-02 完成**:`git checkout -b feat/osc8-hyperlink`(基线 `658c942c`),本地分支已建,待 hover 补丁完成后 push 到 `myfork`(LIUXIN557/FTXUI)。 -## 2. FTXUI 补丁:OSC 8 透传 +## 2. FTXUI 框架核实:OSC 8 能力(2026-09-02 结论:上游已有,零代码) -- [ ] 2.1 给 `Cell` 加 `uint32_t link_id`(`include/ftxui/screen/cell.hpp`),默认 0,`ACECODE-PATCH(osc8-hyperlink)` 标记。 -- [ ] 2.2 增加 `Screen` 级链接 URL 表 + `RegisterLink(std::string) -> uint32_t`(`include/ftxui/screen/screen.hpp`、`src/ftxui/screen/screen.cpp`)。 -- [ ] 2.3 在 `Screen::ToString(std::string&)` 中按 link-id 变化发射 OSC 8 开/关:与样式状态并行跟踪当前 id、每行行尾关闭、相邻同 id 不重发、空格继承当前 id。 -- [ ] 2.4 新增 `ftxui::hyperlink(Element, std::string url)` 装饰器(`include/ftxui/dom/elements.hpp` + dom 源文件):注册 URL、给区域格子打 `link_id`。 -- [ ] 2.5 `src/ftxui/screen/screen_test.cpp` 单测:开/关序列的精确字节断言、行尾关闭、相邻同 id 去重、混合样式、无链接时输出与旧版一致。 -- [ ] 2.6 在 `external/ftxui/ACECODE_PATCHES.md` 登记该补丁。 +- [x] 2.1 核实 `Cell` 链接字段:上游已有 `uint8_t hyperlink`(`include/ftxui/screen/cell.hpp:42-45`),0 = 无链接,索引 Screen 元数据。 +- [x] 2.2 核实 `Screen` URL 表:已有 `RegisterHyperlink(std::string_view) -> uint8_t` + `Hyperlink(uint8_t)`(`screen.hpp:86-89`、`screen.cpp:582-600`),带去重与 255 上限保护。 +- [x] 2.3 核实 `ToString()` 发射:`UpdateCellStyle()`(`screen.cpp:83-87`)在 hyperlink 变化时发 `\x1B]8;;URL\x1B\\`;行尾/结尾经 default_cell 复位自动关闭。 +- [x] 2.4 核实装饰器:`ftxui::hyperlink()`(`elements.hpp:130-131` + `src/ftxui/dom/hyperlink.cpp`)已实现,注册 URL 后给区域格子打 id。 +- [x] 2.5 核实单测:`hyperlink_test.cpp` 已有字节级断言(开/关/相邻切换),spec 的行尾关闭/相邻去重验收点已覆盖。 +- [x] 2.6 补丁登记:无需——OSC 8 为上游功能(Copyright 2023 Arthur Sonzogni),非本地补丁,不入 `ACECODE_PATCHES.md`。 ## 3. FTXUI 补丁:悬停移动(`?1003` opt-in) -- [ ] 3.1 新增 `App::EnableMouseHoverMotion(bool)`(`include/ftxui/component/app.hpp`),镜像 `EnableKittyKeyboard`。 -- [ ] 3.2 `src/ftxui/component/app.cpp`:开启时 `EnableMouseTracking()`/退出清理发 `?1003h`/`?1003l` 替代 `?1002`;抑制无按键 `Mouse::Moved` 事件的帧失效。 -- [ ] 3.3 更新 `app_test.cpp` 期望序列;写入 `ACECODE_PATCHES.md`(交叉引用 `idle-mouse-redraw` 的动机)。 +- [x] 3.1 新增 `App::EnableMouseHoverMotion(bool)`(`include/ftxui/component/app.hpp`),镜像 `EnableKittyKeyboard`。**2026-09-02 完成**。 +- [x] 3.2 `src/ftxui/component/app.cpp`:开启时 `EnableMouseTracking()` 发 `?1003h`(`kMouseAnyEvent`)替代 `?1002`,uninstall 注册对称发 `?1003l`;`RunOnce()` 将无按键 `Mouse::Moved` 分类为 passive——仍分发组件但不使 `frame_valid_` 失效(组件自行 `RequestAnimationFrame`)。**2026-09-02 完成**(uninstall 起初漏发 `?1003l`,测试抓出后修复)。 +- [x] 3.3 更新 `app_test.cpp` 期望序列(新增 `MouseHoverMotionDisabledByDefault` / `MouseHoverMotionEnabledSendsAnyEventTracking` 两个测试);写入 `ACECODE_PATCHES.md`(`hover-motion` 段,交叉引用 `idle-mouse-redraw` 的动机)。**2026-09-02 完成**。ftxui 全量单测 362/362 通过。 ## 4. 应用侧:探测与 markdown 接线 -- [ ] 4.1 在 `src/utils/terminal_capability.hpp/.cpp` 增加 `detect_osc8_support_with()`(env 可注入纯函数)+ `detect_osc8_support()` 包装,吸收 `terminal_supports_hyperlinks()` 并补 kitty/Ghostty 标记;Apple Terminal.app 不进白名单。 -- [ ] 4.2 增加 hover 安全探测(同模式;老式/经典 conhost 关闭),用于门控 `EnableMouseHoverMotion`。 -- [ ] 4.3 `tests/utils/terminal_capability_test.cpp` 覆盖每个白名单/黑名单/未知项的单测。 -- [ ] 4.4 实现只比域名的防骗校验 helper(markdown 或 tui utils;纯函数、可单测),在 `src/markdown/markdown_formatter.cpp` 接入:不匹配的链接不进 `link_regions`、去链接样式。 -- [ ] 4.5 检测到 OSC 8 时用 `ftxui::hyperlink(e, style.href)` 包裹 `is_link` span;删除 `make_hyperlink()` 和渲染器内的 `terminal_supports_hyperlinks()` 死代码。 -- [ ] 4.6 校验 helper 单测(伪装 host 降级、标签文字放行、host 一致的截断文字放行、畸形 URL 降级)。 +- [x] 4.1 在 `src/utils/terminal_capability.hpp/.cpp` 增加 `detect_osc8_support_with()`(env 可注入纯函数)+ `detect_osc8_support()` 包装,吸收 `terminal_supports_hyperlinks()` 并补 kitty/Ghostty 标记;Apple Terminal.app 不进白名单。**2026-09-02 完成**:决策表 blacklist > whitelist > unknown-off;收紧死代码的 `TERM 含 "xterm"` 宽匹配为只认 `xterm-kitty`(裸 xterm-256color 被大量终端伪装,保守关);`detect_osc8_support()` 先跑完整 `detect_terminal_capabilities()` 使 Windows conhost 黑名单在真实环境生效。 +- [x] 4.2 增加 hover 安全探测(同模式;老式/经典 conhost 关闭),用于门控 `EnableMouseHoverMotion`。**2026-09-02 完成**:`detect_hover_motion_support_with()`/`detect_hover_motion_support()`,白名单与 OSC 8 一致,conhost 家族强制关(?1003 重绘抖动,idle-mouse-redraw 动机),语义独立便于未来单独演化。 +- [x] 4.3 `tests/utils/terminal_capability_test.cpp` 覆盖每个白名单/黑名单/未知项的单测。**2026-09-02 完成**:Osc8Support 13 个 + HoverMotionSupport 7 个,46/46 通过(含原有 TerminalCapability/SynchronizedOutputSupport)。 +- [x] 4.4 实现只比域名的防骗校验 helper(`src/markdown/link_safety.hpp/.cpp`,纯函数、可单测),在 `src/markdown/markdown_formatter.cpp` 接入:不匹配的链接不进 `link_regions`、去链接样式。**2026-09-02 完成**:`flatten_inline` Link 分支先拼完整显示文字(`link_display_text` 递归 children),`is_safe_link_label` 校验失败则不加 `is_link`/`href`。防骗只针对含 `://` 的远程 href;`[foo.md](docs/foo.md)` 这类本地相对路径放行(文件名带点号不误降级)。首次实现把 `docs/evil.md` 误当远程 URL(无 `://` 前缀的相对路径被解析出 host),单测抓出后修正。 +- [x] 4.5 检测到 OSC 8 时用 `ftxui::hyperlink(e, style.href)` 包裹 `is_link` span;删除 `make_hyperlink()` 和渲染器内的 `terminal_supports_hyperlinks()` 死代码。**2026-09-02 完成**:`FormatOptions` 新增 `osc8_hyperlinks`(默认 false,无回归),`apply_style` is_link 分支在 link_regions 收集后套装饰器;两条通道独立。main.cpp 按探测结果置 flag 属 5.2。 +- [x] 4.6 校验 helper 单测(伪装 host 降级、标签文字放行、host 一致的截断文字放行、畸形 URL 降级)。**2026-09-02 完成**:`tests/markdown/link_safety_test.cpp` 12 个用例(extract 8 + 决策 4 组),12/12 通过。修正了 3 个脱离现实的断言(`mailto:` 宽松解析、`"https://"` 无点号不算 URL 形状、全角句号非 ASCII 点)。 ## 5. 应用侧:点击打开与悬停气泡 -- [ ] 5.1 在聊天链接打开器(`src/tui/chat_file_link.cpp` 或新建 `src/utils/open_url.cpp`)加 http/https 分支:macOS `open`、Linux `xdg-open`、Windows `start`;失败经状态栏提示,绝不崩溃。 -- [ ] 5.2 `src/main.cpp`:`App::Loop()` 前按探测结果启用 `EnableMouseHoverMotion` 与 OSC 8。 -- [ ] 5.3 `src/main.cpp`:无按键 `Mouse::Moved` 时命中检测 `link_regions`;停留约 300 ms 后在指针附近渲染浮层显示真实 URL;移开/Esc 隐藏。气泡不得抢焦点、不得挤压布局。 -- [ ] 5.4 `tests/tui/` 单测 URL 打开命令构造(可 mock 的 runner)。 +- [x] 5.1 在聊天链接打开器加 http/https 分支:新建 `src/utils/open_url.{hpp,cpp}`(`is_openable_http_url` + `open_url_in_browser`,launcher 可注入),`src/tui/chat_file_link.cpp` 在 resolve 前接 URL 分支。**2026-09-02 完成**:仅 http/https、拦截控制字符(含 ESC 0x1B 终端转义注入)、裸 `http://` 拒绝;默认 launcher 不经 shell(POSIX fork+execlp 调 `open`/`xdg-open`,Windows `ShellExecuteW`),URL 原样作参数无注入面;失败经状态栏提示不崩溃。 +- [x] 5.2 `src/main.cpp` 按探测结果接线:screen 初始化处 `EnableMouseHoverMotion(detect_hover_motion_support())`(hover 补丁新增 API),`render_message_markdown` 的 FormatOptions 置 `osc8_hyperlinks = detect_osc8_support()`(static 缓存,避免 Windows 每帧重复 console probe)。**2026-09-02 完成**:探测结果缓存为 `hover_supported` 变量,同时传给渲染层 gate 气泡(只探测一次)。 +- [x] 5.3 `src/main.cpp` 悬停气泡:无按键 `Mouse::Moved` 命中检测 `link_regions`(拖动中不显示);`anim_thread` 时间门 300ms 到期置 visible 并强制渲染;气泡用 `dbox` 叠加整屏(共享区域,不参与布局、DOM 元素不捕获输入),指针右上方优先、空间不足翻侧,坐标 clamp 防撑大 dbox 需求;移出/Esc/任意按键隐藏;`hover_supported=false`(conhost 等)恒不渲染。**2026-09-02 完成**:状态字段 5 个(`hover_link_href/since/visible/x/y`,mu 保护)入 `TuiState`;延迟常量 `kLinkHoverTooltipDelayMs=300` 入 `src/tui/thinking_animation.hpp`;`TuiRendererContext` 增 `hover_supported`。 +- [x] 5.4 URL 打开命令构造单测。**2026-09-02 完成**:`tests/utils/open_url_test.cpp` 6 个用例(http/https 放行、ftp/file/javascript/mailto 拒绝、空/空白/ESC/裸 scheme 拒绝、mock launcher 收到 URL、非法 URL 不触碰 launcher、launcher 失败传播错误),6/6 通过。 ## 6. 集成与验收 diff --git a/ports/ftxui/portfile.cmake b/ports/ftxui/portfile.cmake index ab79df45..6f3badca 100644 --- a/ports/ftxui/portfile.cmake +++ b/ports/ftxui/portfile.cmake @@ -6,7 +6,9 @@ # # 因此:**每次 external/ftxui 的 gitlink 变更,必须同步 bump vcpkg.json 的 # port-version**,否则改动不会生效。当前对应子模块 commit: -# 658c942c6eaceae88fd7ea4458ffa1f5a7775af7 (v7.0.3-19,含 synchronized output) +# 67a51a7b (v7.0.3-20,含 synchronized output + opt-in mouse hover motion) +# —— hover-motion 补丁(link-hover-tooltip 依赖无按键 Mouse::Moved 上报, +# DEC mode 1003),提供 ftxui::App::EnableMouseHoverMotion()。 vcpkg_check_linkage(ONLY_STATIC_LIBRARY) get_filename_component(SOURCE_PATH "${CMAKE_CURRENT_LIST_DIR}/../../external/ftxui" ABSOLUTE) diff --git a/ports/ftxui/vcpkg.json b/ports/ftxui/vcpkg.json index 3e68d043..3d332aa4 100644 --- a/ports/ftxui/vcpkg.json +++ b/ports/ftxui/vcpkg.json @@ -1,7 +1,7 @@ { "name": "ftxui", "version-semver": "7.0.3", - "port-version": 3, + "port-version": 4, "description": "C++ Functional Terminal User Interface (shaohaozhi286 fork; adds ACECode TUI selection, input trace, Kitty keyboard lifecycle, and synchronized output (DEC mode 2026) patches — see external/ftxui/ACECODE_PATCHES.md)", "homepage": "https://github.com/shaohaozhi286/FTXUI", "license": "MIT", diff --git a/src/main.cpp b/src/main.cpp index 2cc65d37..8fd51856 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -136,6 +136,7 @@ #include "tui/picker_scroll.hpp" #include "tui/render_mode_factory.hpp" #include "utils/terminal_capability.hpp" +#include "utils/open_url.hpp" #include "utils/state_file.hpp" #include "tui/slash_dropdown.hpp" #include "tui/path_reference_dropdown.hpp" @@ -3222,11 +3223,63 @@ struct TuiRendererContext { PermissionManager& permissions; bool dangerous_mode = false; bool conhost_compat_layout = false; + // link-hover-tooltip (add-tui-hyperlinks 5.3): 悬停移动能力探测结果。 + // false(conhost 家族/Apple Terminal.app 等)时恒不渲染气泡。 + bool hover_supported = false; std::function clamp_chat_focus; std::function chat_viewport_rows; std::function sync_chat_line_counts_from_layout; }; +// link-hover-tooltip (add-tui-hyperlinks 5.3): 构造悬停气泡 Element。 +// 布局技巧:dbox 的每个子元素共享同一区域,气泡元素内部用 size(EQUAL) +// 占位 + filler() 把浮层推到指针附近 —— 不参与 flex 挤压主布局,也不 +// 捕获输入(DOM 元素而非组件,事件仍由底层组件树处理)。指针右上方优先; +// 右/下空间不足时翻到指针左/下方;所有坐标 clamp 到终端范围内,保证 +// 气泡需求尺寸恒不撑大 dbox 需求,布局与无气泡时完全一致。 +// 调用方须持有 state.mu(render_tui_frame 入口已持锁)。 +static Element render_link_hover_tooltip(const TuiState& state) { + const auto term = Terminal::Size(); + // 显示真实 URL(href 原文,防骗 —— 显示文本可能被 Markdown 伪装)。 + // 超长截断加省略号;宽/高预算含 2 格 border 边框,确保 + // x + bubble_w <= dimx 恒成立,不会撑大 dbox 需求。 + std::string url = state.hover_link_href; + const int max_url_w = std::max(20, term.dimx - 4); + if (static_cast(url.size()) > max_url_w) { + url = url.substr(0, static_cast(max_url_w) - 3) + "..."; + } + const int bubble_w = static_cast(url.size()) + 2; + const int bubble_h = 3; // border top + text row + border bottom + + const int px = state.hover_link_x; + const int py = state.hover_link_y; + int x = px + 2; // 指针右上方 + if (x + bubble_w > term.dimx) { + x = std::max(0, px - bubble_w - 2); // 右侧不够 → 指针左侧 + } + int y = py - bubble_h - 1; // 指针上方 + if (y < 0) { + y = py + 1; // 上方不够 → 指针下方 + } + y = std::min(y, std::max(0, term.dimy - bubble_h)); + + const bool is_light = acecode::tui::theme().name == "light"; + const Color bubble_bg = + is_light ? Color::RGB(235, 238, 244) : Color::RGB(42, 46, 54); + auto bubble = + text(url) | color(acecode::tui::theme().ui.text_primary) | + bgcolor(bubble_bg) | + borderRounded | color(acecode::tui::theme().ui.border); + return vbox({ + emptyElement() | size(HEIGHT, EQUAL, y), + hbox({ + emptyElement() | size(WIDTH, EQUAL, x), + bubble, + }), + filler(), + }); +} + // 渲染整屏 TUI;只做画面组装,不处理输入事件。 static Element render_tui_frame(TuiRendererContext& ctx) { auto& state = ctx.state; @@ -3257,6 +3310,7 @@ static Element render_tui_frame(TuiRendererContext& ctx) { auto& permissions = ctx.permissions; const bool dangerous_mode = ctx.dangerous_mode; const bool conhost_compat_layout = ctx.conhost_compat_layout; + const bool hover_supported = ctx.hover_supported; auto& clamp_chat_focus = ctx.clamp_chat_focus; auto& chat_viewport_rows = ctx.chat_viewport_rows; auto& sync_chat_line_counts_from_layout = ctx.sync_chat_line_counts_from_layout; @@ -3455,6 +3509,12 @@ static Element render_tui_frame(TuiRendererContext& ctx) { md_opts.terminal_width = markdown_render_width; md_opts.syntax_highlight = true; md_opts.hyperlinks = true; + // OSC 8 原生超链接(add-tui-hyperlinks 5.2):按终端探测结果开启。 + // static 缓存避免 Windows 上每帧重复跑 console probe;TUI 渲染 + // 单线程,magic static 初始化安全。 + static const bool osc8_supported = + acecode::detect_osc8_support(); + md_opts.osc8_hyperlinks = osc8_supported; md_opts.strip_xml = true; md_opts.link_regions = &chat_link_regions; return acecode::markdown::format_markdown(content, md_opts); @@ -4673,15 +4733,14 @@ static Element render_tui_frame(TuiRendererContext& ctx) { bottom_bar, }); + Element root; if (conhost_compat_layout) { - return vbox({ + root = vbox({ compat_horizontal_line() | color(outer_border_color), main_root | flex, compat_horizontal_line() | color(outer_border_color), }) | flex; - } - - if (show_regular_sidebar) { + } else if (show_regular_sidebar) { Element sidebar = acecode::tui::render_regular_sidebar( state, version_str, @@ -4691,14 +4750,28 @@ static Element render_tui_frame(TuiRendererContext& ctx) { sidebar_content_box, sidebar_viewport_box, sidebar_scrollbar_box); - return hbox({ + root = hbox({ main_root | flex, separator() | color(outer_border_color), sidebar, }) | borderRounded | color(outer_border_color) | flex; + } else { + root = main_root | borderRounded | color(outer_border_color) | flex; + } + + // link-hover-tooltip (add-tui-hyperlinks 5.3): 气泡作为浮层叠加在整屏 + // 之上 —— dbox 共享区域,不参与布局(不挤压任何元素)、不捕获输入。 + // 仅当终端能力探测通过(会收到无按键 Mouse::Moved 事件)且气泡已显示 + // 时注入;conhost 家族/Apple Terminal.app 等恒不渲染。state.mu 由本 + // 函数入口持有,读 hover_link_* 安全。 + if (hover_supported && state.hover_link_visible && + !state.hover_link_href.empty()) { + root = dbox({ + std::move(root), + render_link_hover_tooltip(state), + }); } - - return main_root | borderRounded | color(outer_border_color) | flex; + return root; } int main(int argc, char* argv[]) { @@ -4848,6 +4921,12 @@ static int run_interactive_app(const InteractiveCliOptions& cli, auto screen = acecode::tui::make_screen_interactive(render_mode); screen.EnableKittyKeyboard(); + // hover-motion(add-tui-hyperlinks 5.2):悬停气泡依赖无按键 Mouse::Moved + // 上报(?1003 any-event),仅现代终端开启——conhost 家族强制关闭(重绘抖动, + // 见 detect_hover_motion_support),Apple Terminal.app 同样不支持。 + // 探测结果缓存一次,既给 FTXUI 开 ?1003,也传给渲染层 gate 气泡。 + const bool hover_supported = acecode::detect_hover_motion_support(); + screen.EnableMouseHoverMotion(hover_supported); // synchronized-output: 按终端能力探测 + tui.sync_output_mode 决定是否把 // 每帧包进 CSI ?2026h/?2026l(整帧原子呈现,消除半帧闪烁)。必须在 // Loop() 之前调用;老 conhost / ConEmu / 未知终端默认关闭(输出与未启用 @@ -6134,6 +6213,21 @@ static int run_interactive_app(const InteractiveCliOptions& cli, state.ctrl_c_armed, state.last_ctrl_c_time, now)) { requires_immediate_post = true; } + // link-hover-tooltip (add-tui-hyperlinks 5.3): 指针在链接上 + // 无按键停留 >= 300ms 后显示气泡。悬停状态由事件线程在 + // Mouse::Moved 时更新,这里只做时间门 —— 到期置位并强制 + // 下一帧渲染,气泡出现不依赖任何后续输入事件。 + if (!state.hover_link_href.empty() && + !state.hover_link_visible) { + const auto hover_elapsed_ms = + std::chrono::duration_cast( + now - state.hover_link_since).count(); + if (hover_elapsed_ms >= + acecode::tui::kLinkHoverTooltipDelayMs) { + state.hover_link_visible = true; + requires_immediate_post = true; + } + } background_animation_visible = mcp_sidebar_has_loading(state); // drag-autoscroll: 时间门到点就滚一行, 把 ShiftSelection 的补偿请求 @@ -7832,6 +7926,13 @@ static int run_interactive_app(const InteractiveCliOptions& cli, } if (is_terminal_key(event, acecode::tui::TerminalKey::Escape)) { std::lock_guard lk(state.mu); + // link-hover-tooltip (add-tui-hyperlinks 5.3): Esc 隐藏悬停气泡, + // 与 "移开指针隐藏" 语义一致。 + if (!state.hover_link_href.empty() || state.hover_link_visible) { + state.hover_link_href.clear(); + state.hover_link_visible = false; + screen.PostEvent(Event::Custom); + } // drag-autoscroll: Esc 中止任何进行中的拖动自动滚动. 选区本身由 // 下游 FTXUI 通过 `handled=true` 情况下的 HandleSelection 清空 // (如果之前有选择). 我们只负责把自己的状态机拉回 Idle. @@ -7941,6 +8042,18 @@ static int run_interactive_app(const InteractiveCliOptions& cli, if (event.is_mouse()) { auto& mouse = event.mouse(); + // link-hover-tooltip (add-tui-hyperlinks 5.3): 任何按键按下 + // (点击/中键/滚轮)立即隐藏气泡 —— 点击即离开,气泡不再有意义。 + // 不 return,让正常点击流程继续。仅无按键 Moved 才更新悬停状态。 + if (mouse.motion == Mouse::Pressed) { + std::lock_guard lk(state.mu); + if (!state.hover_link_href.empty() || + state.hover_link_visible) { + state.hover_link_href.clear(); + state.hover_link_visible = false; + } + } + // mouse-selection-copy / clipboard-paste: right-click copies the // current FTXUI selection to the system clipboard. With no // selection, terminal mouse tracking prevents the host context menu @@ -7985,16 +8098,34 @@ static int run_interactive_app(const InteractiveCliOptions& cli, mouse.motion == Mouse::Pressed) { if (const auto href = chat_link_regions.href_at(mouse.x, mouse.y)) { - const auto opened = acecode::tui::open_tui_chat_file_link( - *href, - agent_loop.cwd()); + // add-tui-hyperlinks 5.1: 网页链接(http/https)在浏览器打开, + // 本地文件链接才走 open_tui_chat_file_link(系统文件管理器)。 + // 两者语义不同:前者是远程 URL,后者是本地路径,不能混用。 + acecode::tui::TuiChatFileLinkResult opened{}; + const bool is_http_link = + acecode::is_openable_http_url(*href); + if (is_http_link) { + const auto browser = + acecode::open_url_in_browser(*href); + opened.handled = true; + opened.ok = browser.ok; + opened.error = browser.error; + } else { + opened = acecode::tui::open_tui_chat_file_link( + *href, + agent_loop.cwd()); + } if (opened.handled) { - const std::string status_msg = opened.ok - ? "Opened file location in system file manager" - : "Unable to open file location: " + - (opened.error.empty() - ? std::string("unknown error") - : opened.error); + const std::string status_msg = + opened.ok + ? (is_http_link + ? "Opened link in browser" + : "Opened file location in system file manager") + : (is_http_link ? "Unable to open link: " + : "Unable to open file location: ") + + (opened.error.empty() + ? std::string("unknown error") + : opened.error); { std::lock_guard lk(state.mu); set_transient_status_line_locked( @@ -8332,6 +8463,38 @@ static int run_interactive_app(const InteractiveCliOptions& cli, screen.PostEvent(Event::Custom); } } + // link-hover-tooltip (add-tui-hyperlinks 5.3): 无按键 Moved + // 做链接命中检测。拖动中(选区拖拽/滚动条拖拽)不显示气泡, + // 直接清除。命中同一 href 只刷新指针坐标、不重置计时,避免 + // 指针在链接内微移导致 300ms 永远到不了;命中不同 href 或 + // 移出链接区域则重置/清除。state.mu 由本 Moved 分支入口持有。 + if (state.drag_left_pressed || + state.drag_scrollbar_phase == + TuiState::DragScrollbarPhase::Dragging || + state.sidebar_scrollbar_dragging) { + if (!state.hover_link_href.empty() || + state.hover_link_visible) { + state.hover_link_href.clear(); + state.hover_link_visible = false; + } + } else { + const auto hit = chat_link_regions.href_at( + mouse.x, mouse.y); + if (hit) { + if (state.hover_link_href != *hit) { + state.hover_link_href = *hit; + state.hover_link_since = + std::chrono::steady_clock::now(); + state.hover_link_visible = false; + } + state.hover_link_x = mouse.x; + state.hover_link_y = mouse.y; + } else if (!state.hover_link_href.empty() || + state.hover_link_visible) { + state.hover_link_href.clear(); + state.hover_link_visible = false; + } + } } std::lock_guard lk(state.mu); @@ -8907,6 +9070,7 @@ static int run_interactive_app(const InteractiveCliOptions& cli, permissions, dangerous_mode, conhost_compat_layout, + hover_supported, clamp_chat_focus, chat_viewport_rows, sync_chat_line_counts_from_layout, diff --git a/src/markdown/link_safety.cpp b/src/markdown/link_safety.cpp new file mode 100644 index 00000000..729b750e --- /dev/null +++ b/src/markdown/link_safety.cpp @@ -0,0 +1,112 @@ +#include "link_safety.hpp" + +#include +#include + +namespace acecode::markdown { + +namespace { + +std::string trim_copy(const std::string& s) { + const auto is_space = [](unsigned char c) { + return c == ' ' || c == '\t' || c == '\r' || c == '\n'; + }; + std::string::size_type begin = 0; + while (begin < s.size() && is_space(static_cast(s[begin]))) { + ++begin; + } + std::string::size_type end = s.size(); + while (end > begin && is_space(static_cast(s[end - 1]))) { + --end; + } + return s.substr(begin, end - begin); +} + +// "URL 形状":含点号、不含空白。普通文字标签(如"我的博客")不满足; +// 伪装 URL 文本(如 "google.com" / "https://google.com/search")满足。 +bool looks_like_url(const std::string& s) { + if (s.find('.') == std::string::npos) { + return false; + } + return s.find_first_of(" \t\r\n") == std::string::npos; +} + +} // namespace + +std::optional extract_url_host(const std::string& url) { + std::string u = trim_copy(url); + if (u.empty()) { + return std::nullopt; + } + + // 非 ASCII(IDN 域名 / 中文文件名)→ 不做 IDN/百分号解码,按畸形处理。 + for (const char c : u) { + if (static_cast(c) > 0x7F) { + return std::nullopt; + } + } + + // scheme:跳过 "://" 之前的 authority 前缀。无 "://" 时整个字符串按 + // authority 解析(裸域名 / userinfo / 端口 / 路径形式),不做协议名判定—— + // "mailto:" 这类单冒号协议无法与 "user:pass@" 可靠区分,而两者解析出 + // 的 host 都是安全的比较对象(file:/// 等 host 为空的情况仍会失败)。 + const std::string::size_type scheme = u.find("://"); + if (scheme != std::string::npos) { + u = u.substr(scheme + 3); + } + + // userinfo:取最后一个 '@' 之后(host 之前的 user:pass@)。 + const std::string::size_type at = u.rfind('@'); + if (at != std::string::npos) { + u = u.substr(at + 1); + } + + // host 段:到 ':'(端口)、'/'(路径)、'?'(查询)、'#'(片段) 为止。 + const std::string::size_type end = u.find_first_of(":/?#"); + if (end != std::string::npos) { + u = u.substr(0, end); + } + + // 去尾部句点(句子结尾标点)。 + while (!u.empty() && u.back() == '.') { + u.pop_back(); + } + if (u.empty()) { + return std::nullopt; + } + + // 域名不区分大小写 → 统一小写,便于比较。 + std::transform(u.begin(), u.end(), u.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + return u; +} + +bool is_safe_link_label(const std::string& label, const std::string& href) { + // href 不是远程 URL(无 "://")→ 本地文件路径 / 裸域名通道,label 是 + // 文件名或任意文字,不参与防骗比较,放行。注意防骗只针对网页链接 + // (http/https),本地相对路径 `[foo.md](docs/foo.md)` 是常见写法, + // 不能因文件名带点号而被误降级。 + if (href.find("://") == std::string::npos) { + return true; + } + + // href 是远程 URL:host 无法解析(file:///、畸形)→ 无目标可比,放行。 + const auto href_host = extract_url_host(href); + if (!href_host.has_value()) { + return true; + } + + // label 不呈 URL 形状 → 普通文字标签(如"我的博客"),放行。 + if (!looks_like_url(label)) { + return true; + } + + // label 呈 URL 形状 → host 必须与 href host 一致;畸形/非 ASCII → 降级。 + const auto label_host = extract_url_host(label); + if (!label_host.has_value()) { + return false; + } + return *label_host == *href_host; +} + +} // namespace acecode::markdown diff --git a/src/markdown/link_safety.hpp b/src/markdown/link_safety.hpp new file mode 100644 index 00000000..48f432cf --- /dev/null +++ b/src/markdown/link_safety.hpp @@ -0,0 +1,33 @@ +#pragma once + +// 链接防骗校验(add-tui-hyperlinks 变更 4.4)。 +// +// 渲染 markdown 链接 `[label](href)` 时,label 可能被恶意文本伪装成某个知名 +// 网站(如 `[google.com](https://evil.example.com)`)。本模块只比域名(host), +// 不比完整路径——非目标:仿冒域名(faceb00k.com)不归本层管,归浏览器。 +// +// 决策规则(见 openspec/changes/add-tui-hyperlinks/design.md 决策 4): +// - href 不含 "://"(本地文件路径 / 裸域名)→ 本地链接通道,label 是文件名 +// 或任意文字,放行。防骗只针对网页链接:`[foo.md](docs/foo.md)` 是常见 +// 写法,不能因文件名带点号被误降级。 +// - href 是远程 URL 但 host 解析失败(file:///、畸形)→ 无目标可比,放行。 +// - label 无"URL 形状"(不含点号,或含空白)→ 普通文字标签,放行。 +// - label 呈 URL 形状 → 提取 host 与 href host 比较(忽略大小写);一致放行, +// 不一致降级;畸形/非 ASCII 的 URL 按不匹配处理(降级)。 +// +// 降级语义由调用方决定:不进 link_regions、去链接样式、按纯文本渲染。 + +#include +#include + +namespace acecode::markdown { + +// 从 URL / 裸域名中提取 host(去 scheme、userinfo、端口、路径/查询/片段, +// 去尾部句点,小写化)。本地路径、非 http(s) 协议(mailto:/file:/data: 等)、 +// 含非 ASCII(IDN/百分号编码不做)解析失败 → nullopt。 +std::optional extract_url_host(const std::string& url); + +// 防骗校验:label 与 href 组合是否安全渲染为可点击链接(见文件头决策规则)。 +bool is_safe_link_label(const std::string& label, const std::string& href); + +} // namespace acecode::markdown diff --git a/src/markdown/markdown_formatter.cpp b/src/markdown/markdown_formatter.cpp index 993295c6..9db65cee 100644 --- a/src/markdown/markdown_formatter.cpp +++ b/src/markdown/markdown_formatter.cpp @@ -1,4 +1,5 @@ #include "markdown_formatter.hpp" +#include "link_safety.hpp" #include "markdown_lexer.hpp" #include "mermaid_renderer.hpp" #include "syntax_highlight.hpp" @@ -7,7 +8,6 @@ #include "utils/logger.hpp" #include #include -#include #include #include @@ -75,43 +75,6 @@ std::string strip_xml_tags(const std::string& content) { return result; } -// --------------------------------------------------------------------------- -// OSC 8 hyperlink support + terminal capability detection -// --------------------------------------------------------------------------- - -static bool terminal_supports_hyperlinks() { - // Check known terminal emulators that support OSC 8 - auto check_env = [](const char* var) -> bool { - const char* val = std::getenv(var); - return val != nullptr && val[0] != '\0'; - }; - - // Windows Terminal - if (check_env("WT_SESSION")) return true; - - const char* term_program = std::getenv("TERM_PROGRAM"); - if (term_program) { - std::string tp(term_program); - if (tp == "iTerm.app" || tp == "WezTerm" || tp == "vscode") return true; - } - - const char* term = std::getenv("TERM"); - if (term) { - std::string t(term); - if (t.find("xterm") != std::string::npos) return true; - } - - return false; -} - -static std::string make_hyperlink(const std::string& url, const std::string& display) { - // OSC 8 format: \033]8;;URL\007DISPLAY\033]8;;\007 - // This only works at the terminal level, not in FTXUI Elements. - // We'll just return the display text since FTXUI handles rendering. - (void)url; - return display; -} - // --------------------------------------------------------------------------- // List numbering helpers (match claude-code exactly) // --------------------------------------------------------------------------- @@ -153,6 +116,28 @@ static std::string get_list_number(int depth, int number) { // Styled text building for paragraphs (word-wrapping with styles) // --------------------------------------------------------------------------- +// 递归拼接内联 children 的纯文本(用于防骗校验的完整显示文字)。 +static void append_inline_text(const std::vector& children, + std::string& out) { + for (const auto& child : children) { + out += child.text; + if (!child.children.empty()) { + append_inline_text(child.children, out); + } + } +} + +// 链接的完整显示文字:children 递归拼接;空 children 直接是 href 本身 +// (markdown 惯例 []() 简写)。 +static std::string link_display_text(const Token& tok) { + if (tok.children.empty()) { + return tok.href; + } + std::string display; + append_inline_text(tok.children, display); + return display; +} + // Flatten inline tokens into a list of styled runs static void flatten_inline(const std::vector& tokens, const TextStyle& inherited, @@ -181,9 +166,13 @@ static void flatten_inline(const std::vector& tokens, out.push_back({tok.text, style}); break; - case TokenType::Link: - style.is_link = true; - style.href = tok.href; + case TokenType::Link: { + // 防骗校验(add-tui-hyperlinks 4.4):显示文字伪装成 URL 且 host + // 与目标不符 → 降级为纯文本(不进 link_regions、无链接样式)。 + if (is_safe_link_label(link_display_text(tok), tok.href)) { + style.is_link = true; + style.href = tok.href; + } // If link text == href or empty, just show the URL if (tok.children.empty()) { out.push_back({tok.href, style}); @@ -191,6 +180,7 @@ static void flatten_inline(const std::vector& tokens, flatten_inline(tok.children, style, out); } break; + } case TokenType::Image: // Show alt text or URL @@ -247,6 +237,15 @@ static Element apply_style(const std::string& txt, e = e | reflect(region.box); } + // OSC 8 原生超链接(add-tui-hyperlinks 4.5):终端支持时套用上游 + // ftxui::hyperlink() 装饰器(Screen::RegisterHyperlink + ToString 发射 + // \x1B]8;;URL\x1B\\ 序列),让 Cmd/Ctrl+点击、原生悬停、右键打开/复制 + // 生效。与 link_regions(应用内点击)独立:不支持 OSC 8 的终端走 + // 应用内点击兜底,输出与现状字节级一致。 + if (style.is_link && opts.osc8_hyperlinks) { + e = e | hyperlink(style.href); + } + return e; } diff --git a/src/markdown/markdown_types.hpp b/src/markdown/markdown_types.hpp index 7676e06c..6a67b502 100644 --- a/src/markdown/markdown_types.hpp +++ b/src/markdown/markdown_types.hpp @@ -54,6 +54,11 @@ struct FormatOptions { int terminal_width = 80; bool syntax_highlight = true; bool hyperlinks = true; + // 终端支持 OSC 8(由 src/utils/terminal_capability 探测,main.cpp 接线): + // 渲染 is_link span 时套用 ftxui::hyperlink() 装饰器,让终端原生 + // Cmd/Ctrl+点击、悬停、右键打开/复制生效。与 hyperlinks(应用内点击 + // link_regions 收集)互相独立:两条通道共享链接元数据但代码路径分开。 + bool osc8_hyperlinks = false; bool strip_xml = true; MarkdownLinkRegionCollector* link_regions = nullptr; }; diff --git a/src/tui/chat_file_link.cpp b/src/tui/chat_file_link.cpp index 102304f6..23c03925 100644 --- a/src/tui/chat_file_link.cpp +++ b/src/tui/chat_file_link.cpp @@ -1,5 +1,6 @@ #include "chat_file_link.hpp" +#include "utils/open_url.hpp" #include "utils/utf8_path.hpp" #include @@ -185,6 +186,11 @@ TuiChatFileLinkResult open_tui_chat_file_link( const std::string& href, const std::string& cwd_utf8, acecode::desktop::OpenInExplorerLauncher launcher) { + // 注意: http/https 网页链接不在此函数处理。本函数的契约是"只解析本地 + // 文件链接"(见测试 ExternalLinkDoesNotCallLocalLauncher:外部链接必须 + // handled=false 且不触发本地 launcher)。网页链接的浏览器打开由调用方 + // (点击 handler)在调用本函数之前判定并走 open_url_in_browser 通道 + // (add-tui-hyperlinks 5.1)。 auto resolved = resolve_tui_chat_file_link(href, cwd_utf8); if (!resolved.handled || !resolved.ok) return resolved; diff --git a/src/tui/thinking_animation.hpp b/src/tui/thinking_animation.hpp index 663555e1..84c94b21 100644 --- a/src/tui/thinking_animation.hpp +++ b/src/tui/thinking_animation.hpp @@ -16,6 +16,11 @@ inline constexpr int kDefaultAnimationFrameMs = 300; inline constexpr int kConhostAnimationFrameMs = 1000; inline constexpr int kDragAutoscrollFrameMs = 50; +// link-hover-tooltip (add-tui-hyperlinks 5.3): 指针无按键悬停在链接上超过 +// 该时长后显示真实 URL 浮层。300ms 是 spec 验收值 —— 足够区分"扫过"与 +// "有意停留",又不会让快速浏览链接时气泡不停闪烁。 +inline constexpr int kLinkHoverTooltipDelayMs = 300; + inline constexpr double kThinkingShimmerCellsPerSecond = 20.25; inline constexpr double kThinkingShimmerEdgePaddingCells = 2.5; diff --git a/src/tui_state.hpp b/src/tui_state.hpp index bef848f6..3a27f952 100644 --- a/src/tui_state.hpp +++ b/src/tui_state.hpp @@ -429,6 +429,22 @@ struct TuiState { std::vector drag_scrollbar_snapshot; int drag_scrollbar_grab_offset_2x = 0; + // link-hover-tooltip (add-tui-hyperlinks 5.3): 指针无按键悬停在链接上 + // 约 300ms 后,在指针附近浮层显示该链接的真实 URL(防骗:显示 href 原文 + // 而非显示文本)。仅在 hover-motion 终端能力探测通过时才会有无按键 + // Mouse::Moved 事件流入(conhost 家族强制关,Apple Terminal.app 不支持), + // 字段本身全部由 `mu` 保护(事件线程写,anim_thread 与渲染线程读)。 + // hover_link_href — 当前指针下的 href;空 = 不在任何链接区域上 + // hover_link_since — 进入当前 href 的时刻(steady_clock),300ms 判定用; + // 指针在同一链接内微移不重置,避免计时永远到不了 + // hover_link_visible — 气泡是否已显示(anim_thread 在停留到期时置位) + // hover_link_x/y — 指针屏幕坐标,渲染层据此把气泡放到指针附近 + std::string hover_link_href; + std::chrono::steady_clock::time_point hover_link_since{}; + bool hover_link_visible = false; + int hover_link_x = -1; + int hover_link_y = -1; + // Async compact state bool is_compacting = false; // protected by mu std::chrono::steady_clock::time_point compact_animation_start_time{}; diff --git a/src/utils/open_url.cpp b/src/utils/open_url.cpp new file mode 100644 index 00000000..ba37dcae --- /dev/null +++ b/src/utils/open_url.cpp @@ -0,0 +1,104 @@ +#include "open_url.hpp" + +#include "utils/encoding.hpp" + +#include +#include + +#ifdef _WIN32 +# ifndef WIN32_LEAN_AND_MEAN +# define WIN32_LEAN_AND_MEAN +# endif +# ifndef NOMINMAX +# define NOMINMAX +# endif +# include +# include +#else +# include +#endif + +namespace acecode { + +namespace { + +// 默认平台打开器:POSIX 用 open(macOS) / xdg-open(Linux),fork+execlp 不经 +// shell;Windows 用 ShellExecuteW 的 "open" verb(等价于 `start`,但无 cmd +// 转义面)。URL 原样作参数,带空格/引号也安全。 +bool platform_open_url(const std::string& url, std::string& error) { +#ifdef _WIN32 + const std::wstring wide_url(acecode::utf8_to_wide(url)); + HINSTANCE result = ::ShellExecuteW( + nullptr, L"open", wide_url.c_str(), nullptr, nullptr, SW_SHOWNORMAL); + const auto code = reinterpret_cast(result); + if (code > 32) return true; + error = "ShellExecute failed: " + std::to_string(code); + return false; +#else +# ifdef __APPLE__ + const char* opener = "open"; +# else + const char* opener = "xdg-open"; +# endif + const pid_t pid = ::fork(); + if (pid < 0) { + error = "fork failed"; + return false; + } + if (pid == 0) { + ::execlp(opener, opener, url.c_str(), static_cast(nullptr)); + ::_exit(127); + } + return true; +#endif +} + +} // namespace + +bool is_openable_http_url(const std::string& url) { + // 前导空白/空串/控制字符一律拒绝。控制字符(尤其 ESC 0x1B)是终端转义 + // 注入通道,必须拦。 + if (url.empty() || + static_cast(url.front()) < 0x21 || + static_cast(url.back()) < 0x21) { + return false; + } + for (const char c : url) { + const unsigned char u = static_cast(c); + if (u < 0x20 || u == 0x7F) { + return false; // 控制字符 / DEL + } + } + + // 仅 http/https(大小写不敏感),且 scheme 后必须有内容(host 至少 1 字符, + // "http://" 裸 scheme 打开无意义)。 + const bool http = url.size() > 7 && + std::equal(url.begin(), url.begin() + 7, "http://", + [](char a, char b) { + return std::tolower(static_cast(a)) == b; + }); + const bool https = url.size() > 8 && + std::equal(url.begin(), url.begin() + 8, "https://", + [](char a, char b) { + return std::tolower(static_cast(a)) == b; + }); + return http || https; +} + +OpenUrlResult open_url_in_browser(const std::string& url, + OpenUrlLauncher launcher) { + if (!is_openable_http_url(url)) { + return {false, "URL must be an http/https link"}; + } + + std::string error; + auto launch = launcher ? std::move(launcher) + : OpenUrlLauncher(platform_open_url); + if (!launch(url, error)) { + if (error.empty()) error = "failed to open URL in browser"; + return {false, error}; + } + return {true, {}}; +} + +} // namespace acecode diff --git a/src/utils/open_url.hpp b/src/utils/open_url.hpp new file mode 100644 index 00000000..46be522c --- /dev/null +++ b/src/utils/open_url.hpp @@ -0,0 +1,34 @@ +#pragma once + +// 网页链接打开(add-tui-hyperlinks 5.1)——http/https 链接用系统默认浏览器打开。 +// +// 安全性: +// - 仅放行 http/https scheme(防 file:/data:/javascript: 等意外打开); +// - 禁止控制字符(含 ESC 0x1B,防终端转义注入)与空白开头等异常; +// - 默认 launcher 不经 shell(POSIX fork+execlp / Windows ShellExecuteW), +// URL 原样作参数传递,无 shell 注入面。 +// +// 可注入 launcher 便于单测(见 tests/utils/open_url_test.cpp)。 + +#include +#include + +namespace acecode { + +struct OpenUrlResult { + bool ok = false; + std::string error; +}; + +using OpenUrlLauncher = + std::function; + +// 校验 URL 可安全交给系统浏览器:http/https scheme + 无控制字符/前导空白。 +bool is_openable_http_url(const std::string& url); + +// 打开 URL:校验通过后交给 launcher(默认 = 平台浏览器打开器)。 +// 失败返回错误信息,绝不抛异常。 +OpenUrlResult open_url_in_browser(const std::string& url, + OpenUrlLauncher launcher = {}); + +} // namespace acecode diff --git a/src/utils/terminal_capability.cpp b/src/utils/terminal_capability.cpp index b55aec97..a892b7e1 100644 --- a/src/utils/terminal_capability.cpp +++ b/src/utils/terminal_capability.cpp @@ -230,4 +230,90 @@ bool detect_synchronized_output_support() { default_env_lookup); } +bool detect_osc8_support_with( + const TerminalCapabilities& caps, + const std::function(const char* name)>& env_lookup) { + // 黑名单优先:任何一条命中都关闭。 + auto conemu = env_lookup("ConEmuPID"); + if (conemu.has_value() && !conemu->empty()) { + return false; + } + if (caps.is_legacy_conhost || caps.is_classic_conhost) { + return false; + } + auto term = env_lookup("TERM"); + if (term.has_value() && term_blacklisted(*term)) { + return false; + } + + // 白名单:命中任意一条即开启。 + auto wt_session = env_lookup("WT_SESSION"); + if (wt_session.has_value() && !wt_session->empty()) { + return true; // Windows Terminal + } + auto kitty_window_id = env_lookup("KITTY_WINDOW_ID"); + if (kitty_window_id.has_value() && !kitty_window_id->empty()) { + return true; // kitty + } + auto term_program = env_lookup("TERM_PROGRAM"); + if (term_program.has_value() && term_program_whitelisted(*term_program)) { + return true; + } + if (term.has_value() && term_whitelisted(*term)) { + return true; + } + + // 未知终端:默认关闭(保守)。Apple Terminal.app 无 OSC 8,不在白名单。 + return false; +} + +bool detect_osc8_support() { + return detect_osc8_support_with(detect_terminal_capabilities(), + default_env_lookup); +} + +bool detect_hover_motion_support_with( + const TerminalCapabilities& caps, + const std::function(const char* name)>& env_lookup) { + // 黑名单优先:任何一条命中都关闭。conhost 家族(legacy/classic)必须关: + // ?1003 any-event 上报会引发悬停移动时的重绘抖动,这正是 idle-mouse-redraw + // 补丁当初把上报降为 ?1002 button-event 的原因。 + auto conemu = env_lookup("ConEmuPID"); + if (conemu.has_value() && !conemu->empty()) { + return false; + } + if (caps.is_legacy_conhost || caps.is_classic_conhost) { + return false; + } + auto term = env_lookup("TERM"); + if (term.has_value() && term_blacklisted(*term)) { + return false; + } + + // 白名单:与 OSC 8 名单一致 —— 这些终端对 any-event 上报支持良好。 + auto wt_session = env_lookup("WT_SESSION"); + if (wt_session.has_value() && !wt_session->empty()) { + return true; // Windows Terminal + } + auto kitty_window_id = env_lookup("KITTY_WINDOW_ID"); + if (kitty_window_id.has_value() && !kitty_window_id->empty()) { + return true; // kitty + } + auto term_program = env_lookup("TERM_PROGRAM"); + if (term_program.has_value() && term_program_whitelisted(*term_program)) { + return true; + } + if (term.has_value() && term_whitelisted(*term)) { + return true; + } + + // 未知终端:默认关闭(保守)。Apple Terminal.app 同样不支持 any-event。 + return false; +} + +bool detect_hover_motion_support() { + return detect_hover_motion_support_with(detect_terminal_capabilities(), + default_env_lookup); +} + } // namespace acecode diff --git a/src/utils/terminal_capability.hpp b/src/utils/terminal_capability.hpp index e13dc1de..11f1be80 100644 --- a/src/utils/terminal_capability.hpp +++ b/src/utils/terminal_capability.hpp @@ -79,4 +79,41 @@ bool detect_synchronized_output_support_with( // 真实探测:读当前进程环境变量。 bool detect_synchronized_output_support(); +// OSC 8 超链接支持判定 —— 环境变量启发式,决策表与同步刷新一致 +// (blacklist > whitelist > unknown-off),吸收自 markdown 渲染器里 +// 从未被调用的 terminal_supports_hyperlinks() 死代码: +// - ConEmu/Cmder(ConEmuPID) → false +// - legacy / classic conhost → false +// - TERM 以 "tmux" / "screen" 开头(复用器) → false +// - WT_SESSION / KITTY_WINDOW_ID 存在 → true +// - TERM_PROGRAM ∈ {iTerm.app, WezTerm, ghostty, vscode, +// WarpTerminal, contour, mintty} → true +// (Apple_Terminal / macOS Terminal.app 无 OSC 8,不进白名单) +// - TERM == xterm-kitty 或以 foot/ghostty 开头 → true +// - 其它(未知终端,如裸 xterm-256color) → false +// +// 未知默认关闭。注意:死代码曾用 TERM 含 "xterm" 子串放行,这里收紧为 +// 只认 xterm-kitty——裸 xterm-256color 被大量不支持的终端伪装,保守关。 +// 即使误判发射 OSC 8 序列也无害(终端忽略),优雅降级为纯文本下划线。 +bool detect_osc8_support_with( + const TerminalCapabilities& caps, + const std::function(const char* name)>& env_lookup); + +// 真实探测:读当前进程环境变量。 +bool detect_osc8_support(); + +// 悬停移动上报(DEC mode 1003, any-event)安全判定 —— 门控 +// ftxui::App::EnableMouseHoverMotion()。决策表与 OSC 8 一致,但语义独立: +// ?1003 在老式/经典 Windows conhost 上会触发重绘抖动(idle-mouse-redraw +// 补丁当初特意降为 ?1002 button-event 的动机),因此 conhost 家族强制关; +// 未知终端默认关闭。支持 OSC 8 的现代终端(iTerm2 / kitty / WezTerm / +// ghostty / VS Code / Windows Terminal / Warp / contour / mintty) +// 对 any-event 上报同样支持,进白名单。 +bool detect_hover_motion_support_with( + const TerminalCapabilities& caps, + const std::function(const char* name)>& env_lookup); + +// 真实探测:读当前进程环境变量。 +bool detect_hover_motion_support(); + } // namespace acecode diff --git a/tests/markdown/link_safety_test.cpp b/tests/markdown/link_safety_test.cpp new file mode 100644 index 00000000..e6c7ab23 --- /dev/null +++ b/tests/markdown/link_safety_test.cpp @@ -0,0 +1,120 @@ +// 覆盖 src/markdown/link_safety.cpp 的链接防骗校验(add-tui-hyperlinks 4.4/4.6)。 +// +// 只比域名(host),不比完整路径。规则(design.md 决策 4): +// - href 不含 "://"(本地路径/裸域名)→ 放行(本地通道,防骗只针对网页链接) +// - href 远程但 host 解析失败 → 放行(无目标可比) +// - label 无 URL 形状(无点号或含空白)→ 放行(标签文字) +// - label 呈 URL 形状 → host 一致放行(忽略大小写),不一致降级, +// 畸形/非 ASCII 按不匹配(降级) + +#include + +#include "markdown/link_safety.hpp" + +#include +#include + +using namespace acecode::markdown; + +// ---------- extract_url_host ---------- + +TEST(LinkSafety, ExtractHostFullUrl) { + EXPECT_EQ(extract_url_host("https://www.example.com:8080/path?q=1#frag"), + std::optional("www.example.com")); +} + +TEST(LinkSafety, ExtractHostBareDomainWithPath) { + EXPECT_EQ(extract_url_host("github.com/foo/bar"), + std::optional("github.com")); +} + +TEST(LinkSafety, ExtractHostUserinfoAndPort) { + EXPECT_EQ(extract_url_host("user:pass@EXAMPLE.com:8080/x"), + std::optional("example.com")); +} + +TEST(LinkSafety, ExtractHostTrailingDotStripped) { + EXPECT_EQ(extract_url_host("example.com."), + std::optional("example.com")); +} + +TEST(LinkSafety, ExtractHostLocalPathFails) { + EXPECT_EQ(extract_url_host("./docs/foo.md"), std::nullopt); + EXPECT_EQ(extract_url_host("/etc/passwd"), std::nullopt); + EXPECT_EQ(extract_url_host("docs/说明.md"), std::nullopt); +} + +TEST(LinkSafety, ExtractHostOtherSchemeFails) { + // file:/// 的 authority 是空 → host 解析失败 + EXPECT_EQ(extract_url_host("file:///tmp/x"), std::nullopt); + // mailto: 单冒号协议无 "://",按 authority 宽松解析出 userinfo 后的 host + // (安全:比较对象仍是域名;is_safe_link_label 只对含 "://" 的 href 做比较) + EXPECT_EQ(extract_url_host("mailto:foo@bar.com"), + std::optional("bar.com")); +} + +TEST(LinkSafety, ExtractHostMalformedFails) { + EXPECT_EQ(extract_url_host(""), std::nullopt); + EXPECT_EQ(extract_url_host("https://"), std::nullopt); + EXPECT_EQ(extract_url_host("https://例子.中国"), std::nullopt); +} + +// ---------- is_safe_link_label:伪装 host → 降级 ---------- + +TEST(LinkSafety, SpoofedHostDowngraded) { + EXPECT_FALSE(is_safe_link_label("google.com", "https://evil.example.com")); + EXPECT_FALSE( + is_safe_link_label("https://www.google.com", "https://evil.example.com")); + // 子域名陷阱:label host 是 google.com.evil.example.com,不是 google.com + EXPECT_FALSE( + is_safe_link_label("google.com", "https://google.com.evil.example.com")); +} + +// ---------- 标签文字(无 URL 形状)→ 放行 ---------- + +TEST(LinkSafety, PlainLabelPasses) { + EXPECT_TRUE(is_safe_link_label("我的博客", "https://example.com")); + EXPECT_TRUE(is_safe_link_label("click here", "https://example.com")); + EXPECT_TRUE(is_safe_link_label("", "https://example.com")); + EXPECT_TRUE(is_safe_link_label("入门指南", "https://example.com/docs")); +} + +// ---------- host 一致(省略 scheme / 截断路径 / 大小写)→ 放行 ---------- + +TEST(LinkSafety, MatchingHostPasses) { + EXPECT_TRUE(is_safe_link_label("https://github.com/foo/bar", + "https://github.com/foo/bar")); + // 省略 scheme、截断路径 + EXPECT_TRUE(is_safe_link_label("github.com/foo/bar", + "https://github.com/foo")); + // 域名大小写不敏感 + EXPECT_TRUE(is_safe_link_label("Google.com", "https://google.com/x")); + // label 带 userinfo/端口,host 仍一致 + EXPECT_TRUE(is_safe_link_label("user@example.com:8080/path", + "https://example.com/x")); +} + +// ---------- 畸形 / 非 ASCII → 降级 ---------- + +TEST(LinkSafety, MalformedUrlDowngraded) { + EXPECT_FALSE(is_safe_link_label("https://例子.中国", "https://example.com")); + // label 呈 URL 形状但 host 为空("https://.." 去尾部点后无内容) + EXPECT_FALSE(is_safe_link_label("https://..", "https://example.com")); + // label host 是仿冒子域,与目标不符 + EXPECT_FALSE(is_safe_link_label("https://example..com", + "https://example.com")); +} + +// ---------- 本地链接通道(href 无 "://")→ 放行 ---------- + +TEST(LinkSafety, LocalHrefPasses) { + // 常见 markdown 写法 [foo.md](./foo.md):label 是文件名,不参与防骗比较 + EXPECT_TRUE(is_safe_link_label("foo.md", "./docs/foo.md")); + // 无 "./" 前缀的相对路径也放行——防骗只针对含 "://" 的远程 href + EXPECT_TRUE(is_safe_link_label("foo.md", "docs/foo.md")); + EXPECT_TRUE(is_safe_link_label("/etc/passwd", "/etc/passwd")); + EXPECT_TRUE(is_safe_link_label("配置说明", "docs/说明.md")); + // 裸域名 href(无协议)→ 本地通道,即使 label 伪装也不降级 + // (点击走本地路径打开,不会打开浏览器) + EXPECT_TRUE(is_safe_link_label("google.com", "docs/evil.md")); +} diff --git a/tests/utils/open_url_test.cpp b/tests/utils/open_url_test.cpp new file mode 100644 index 00000000..0bed59e7 --- /dev/null +++ b/tests/utils/open_url_test.cpp @@ -0,0 +1,95 @@ +// 覆盖 src/utils/open_url.cpp 的 URL 打开(add-tui-hyperlinks 5.1/5.4)。 +// +// 关键保证: +// - 仅 http/https 放行;控制字符(ESC 注入)/前导空白/其它 scheme 拒绝; +// - launcher 可注入(mock 断言 URL 原样传递,不真实打开浏览器); +// - 校验失败时不触碰 launcher,返回错误而非崩溃。 + +#include + +#include "utils/open_url.hpp" + +#include + +using namespace acecode; + +namespace { + +// 记录收到的 URL 的 mock launcher。 +class UrlRecorder { +public: + explicit UrlRecorder(bool succeed) : succeed_(succeed) {} + + OpenUrlLauncher launcher() { + return [this](const std::string& url, std::string& error) { + received_ = url; + if (!succeed_) { + error = "mock open failed"; + return false; + } + return true; + }; + } + + const std::string& received() const { return received_; } + +private: + bool succeed_; + std::string received_; +}; + +} // namespace + +// ---------- is_openable_http_url ---------- + +TEST(OpenUrl, AcceptsHttpAndHttps) { + EXPECT_TRUE(is_openable_http_url("https://example.com")); + EXPECT_TRUE(is_openable_http_url("http://example.com/path?q=1")); + // scheme 大小写不敏感 + EXPECT_TRUE(is_openable_http_url("HTTP://EXAMPLE.COM")); + // URL 中段空格合法(参数原样传递,不经 shell) + EXPECT_TRUE(is_openable_http_url("https://exa mple.com/x")); +} + +TEST(OpenUrl, RejectsOtherSchemes) { + EXPECT_FALSE(is_openable_http_url("ftp://example.com")); + EXPECT_FALSE(is_openable_http_url("file:///tmp/x")); + EXPECT_FALSE(is_openable_http_url("javascript:alert(1)")); + EXPECT_FALSE(is_openable_http_url("mailto:foo@bar.com")); +} + +TEST(OpenUrl, RejectsControlAndEdgeCases) { + EXPECT_FALSE(is_openable_http_url("")); + EXPECT_FALSE(is_openable_http_url(" https://example.com")); // 前导空白 + EXPECT_FALSE(is_openable_http_url("https://example.com ")); // 尾部空白 + // ESC 终端转义注入必须拦 + EXPECT_FALSE(is_openable_http_url( + std::string("https://example.com") + std::string("\x1b]8;;"))); + // https 前缀不完整 + EXPECT_FALSE(is_openable_http_url("https:/example.com")); + EXPECT_FALSE(is_openable_http_url("http://")); +} + +// ---------- open_url_in_browser ---------- + +TEST(OpenUrl, LauncherReceivesUrl) { + UrlRecorder recorder(true); + const auto result = open_url_in_browser("https://example.com/a", recorder.launcher()); + EXPECT_TRUE(result.ok); + EXPECT_EQ(recorder.received(), "https://example.com/a"); +} + +TEST(OpenUrl, InvalidUrlNeverReachesLauncher) { + UrlRecorder recorder(true); + const auto result = open_url_in_browser("ftp://example.com", recorder.launcher()); + EXPECT_FALSE(result.ok); + EXPECT_FALSE(result.error.empty()); + EXPECT_TRUE(recorder.received().empty()); // launcher 未被调用 +} + +TEST(OpenUrl, LauncherFailurePropagatesError) { + UrlRecorder recorder(false); + const auto result = open_url_in_browser("https://example.com", recorder.launcher()); + EXPECT_FALSE(result.ok); + EXPECT_EQ(result.error, "mock open failed"); +} diff --git a/tests/utils/terminal_capability_test.cpp b/tests/utils/terminal_capability_test.cpp index e222dbbb..8ee054e9 100644 --- a/tests/utils/terminal_capability_test.cpp +++ b/tests/utils/terminal_capability_test.cpp @@ -345,3 +345,217 @@ TEST(SynchronizedOutputSupport, UnknownOff) { std::nullopt, std::string("xterm-256color")))); } + +// ---------- detect_osc8_support_with ---------- +// OSC 8 超链接探测:决策表与同步刷新一致(blacklist > whitelist > unknown-off), +// 吸收自 markdown 渲染器的 terminal_supports_hyperlinks() 死代码。 + +// 场景:Windows Terminal(WT_SESSION 命中)→ 开启 +TEST(Osc8Support, WindowsTerminalOn) { + TerminalCapabilities caps; + EXPECT_TRUE(detect_osc8_support_with( + caps, make_sync_env_lookup(std::nullopt, std::string("guid"), + std::nullopt, std::nullopt, std::nullopt))); +} + +// 场景:kitty(KITTY_WINDOW_ID 命中)→ 开启 +TEST(Osc8Support, KittyWindowIdOn) { + TerminalCapabilities caps; + EXPECT_TRUE(detect_osc8_support_with( + caps, make_sync_env_lookup(std::nullopt, std::nullopt, + std::string("1"), std::nullopt, std::nullopt))); +} + +// 场景:kitty(TERM=xterm-kitty)→ 开启 +TEST(Osc8Support, KittyTermOn) { + TerminalCapabilities caps; + EXPECT_TRUE(detect_osc8_support_with( + caps, make_sync_env_lookup(std::nullopt, std::nullopt, std::nullopt, + std::nullopt, std::string("xterm-kitty")))); +} + +// 场景:TERM_PROGRAM 白名单(iTerm.app / WezTerm / ghostty / vscode / +// WarpTerminal / contour / mintty)→ 开启 +TEST(Osc8Support, TermProgramWhitelistOn) { + for (const char* name : {"iTerm.app", "WezTerm", "ghostty", "vscode", + "WarpTerminal", "contour", + "mintty"}) { + TerminalCapabilities caps; + EXPECT_TRUE(detect_osc8_support_with( + caps, make_sync_env_lookup(std::nullopt, std::nullopt, std::nullopt, + std::string(name), std::nullopt))) + << "TERM_PROGRAM=" << name; + } +} + +// 场景:Apple Terminal.app 无 OSC 8,不进白名单(auto 默认关闭; +// 即使误发 OSC 8 序列也无害,优雅降级为下划线纯文本) +TEST(Osc8Support, AppleTerminalOff) { + TerminalCapabilities caps; + EXPECT_FALSE(detect_osc8_support_with( + caps, make_sync_env_lookup(std::nullopt, std::nullopt, std::nullopt, + std::string("Apple_Terminal"), std::nullopt))) + << "Apple_Terminal must NOT be whitelisted for OSC 8"; +} + +// 场景:TERM 前缀白名单(foot / ghostty)→ 开启 +TEST(Osc8Support, TermPrefixWhitelistOn) { + for (const char* term : {"foot", "foot-256color", "ghostty", + "xterm-ghostty"}) { + TerminalCapabilities caps; + EXPECT_TRUE(detect_osc8_support_with( + caps, make_sync_env_lookup(std::nullopt, std::nullopt, std::nullopt, + std::nullopt, std::string(term)))) + << "TERM=" << term; + } +} + +// 场景:裸 xterm-256color → 关闭。死代码 terminal_supports_hyperlinks() +// 曾用 TERM 含 "xterm" 子串放行,这里收紧:大量不支持的终端伪装成 +// xterm-256color,unknown-off 保守原则优先。 +TEST(Osc8Support, BareXtermOff) { + TerminalCapabilities caps; + EXPECT_FALSE(detect_osc8_support_with( + caps, make_sync_env_lookup(std::nullopt, std::nullopt, std::nullopt, + std::nullopt, + std::string("xterm-256color")))); +} + +// 场景:ConEmu/Cmder → 关闭(即使 WT_SESSION 也命中,黑名单优先) +TEST(Osc8Support, ConEmuOffEvenWithWt) { + TerminalCapabilities caps; + EXPECT_FALSE(detect_osc8_support_with( + caps, make_sync_env_lookup(std::string("12345"), + std::string("guid"), std::nullopt, + std::nullopt, std::nullopt))); +} + +// 场景:legacy conhost → 关闭(即使 TERM_PROGRAM 白名单命中) +TEST(Osc8Support, LegacyConhostOff) { + TerminalCapabilities caps; + caps.is_legacy_conhost = true; + EXPECT_FALSE(detect_osc8_support_with( + caps, make_sync_env_lookup(std::nullopt, std::nullopt, std::nullopt, + std::string("iTerm.app"), std::nullopt))); +} + +// 场景:classic conhost → 关闭 +TEST(Osc8Support, ClassicConhostOff) { + TerminalCapabilities caps; + caps.is_classic_conhost = true; + EXPECT_FALSE(detect_osc8_support_with( + caps, make_sync_env_lookup(std::nullopt, std::nullopt, std::nullopt, + std::nullopt, std::nullopt))); +} + +// 场景:复用器 tmux / screen → 关闭(即使 TERM_PROGRAM 白名单命中) +TEST(Osc8Support, TmuxOff) { + TerminalCapabilities caps; + EXPECT_FALSE(detect_osc8_support_with( + caps, make_sync_env_lookup(std::nullopt, std::nullopt, std::nullopt, + std::string("iTerm.app"), + std::string("tmux-256color")))); +} + +TEST(Osc8Support, ScreenOff) { + TerminalCapabilities caps; + EXPECT_FALSE(detect_osc8_support_with( + caps, make_sync_env_lookup(std::nullopt, std::nullopt, std::nullopt, + std::nullopt, + std::string("screen.xterm-256color")))); +} + +// 场景:全空 / 未知终端 → 默认关闭 +TEST(Osc8Support, UnknownOff) { + TerminalCapabilities caps; + EXPECT_FALSE(detect_osc8_support_with( + caps, make_sync_env_lookup(std::nullopt, std::nullopt, std::nullopt, + std::nullopt, std::nullopt))); +} + +// ---------- detect_hover_motion_support_with ---------- +// 悬停移动(?1003 any-event)安全探测:门控 EnableMouseHoverMotion。 +// 白名单与 OSC 8 一致;老式/经典 conhost 强制关(重绘抖动,见 +// idle-mouse-redraw 补丁动机)。 + +// 场景:现代终端(WT_SESSION / kitty / TERM_PROGRAM 白名单 / TERM 白名单)→ 开启 +TEST(HoverMotionSupport, ModernTerminalsOn) { + struct Case { + std::optional wt; + std::optional kitty; + std::optional tp; + std::optional term; + }; + const Case kCases[] = { + {std::string("guid"), std::nullopt, std::nullopt, std::nullopt}, + {std::nullopt, std::string("1"), std::nullopt, std::nullopt}, + {std::nullopt, std::nullopt, std::string("iTerm.app"), std::nullopt}, + {std::nullopt, std::nullopt, std::string("WezTerm"), std::nullopt}, + {std::nullopt, std::nullopt, std::string("ghostty"), std::nullopt}, + {std::nullopt, std::nullopt, std::string("vscode"), std::nullopt}, + {std::nullopt, std::nullopt, std::nullopt, std::string("xterm-kitty")}, + {std::nullopt, std::nullopt, std::nullopt, std::string("foot-256color")}, + }; + for (const auto& c : kCases) { + TerminalCapabilities caps; + EXPECT_TRUE(detect_hover_motion_support_with( + caps, make_sync_env_lookup(std::nullopt, c.wt, c.kitty, c.tp, + c.term))); + } +} + +// 场景:classic conhost → 关闭(?1003 会引发悬停重绘抖动) +TEST(HoverMotionSupport, ClassicConhostOff) { + TerminalCapabilities caps; + caps.is_classic_conhost = true; + EXPECT_FALSE(detect_hover_motion_support_with( + caps, make_sync_env_lookup(std::nullopt, std::string("guid"), + std::nullopt, std::nullopt, std::nullopt))); +} + +// 场景:legacy conhost → 关闭 +TEST(HoverMotionSupport, LegacyConhostOff) { + TerminalCapabilities caps; + caps.is_legacy_conhost = true; + EXPECT_FALSE(detect_hover_motion_support_with( + caps, make_sync_env_lookup(std::nullopt, std::nullopt, std::nullopt, + std::string("iTerm.app"), std::nullopt))); +} + +// 场景:ConEmu/Cmder → 关闭(底层仍是 conhost) +TEST(HoverMotionSupport, ConEmuOff) { + TerminalCapabilities caps; + EXPECT_FALSE(detect_hover_motion_support_with( + caps, make_sync_env_lookup(std::string("12345"), std::nullopt, + std::nullopt, std::nullopt, std::nullopt))); +} + +// 场景:复用器 tmux / screen → 关闭(透传不可靠) +TEST(HoverMotionSupport, TmuxOff) { + TerminalCapabilities caps; + EXPECT_FALSE(detect_hover_motion_support_with( + caps, make_sync_env_lookup(std::nullopt, std::nullopt, std::nullopt, + std::string("iTerm.app"), + std::string("tmux-256color")))); +} + +// 场景:Apple Terminal.app → 关闭(不支持 any-event 上报) +TEST(HoverMotionSupport, AppleTerminalOff) { + TerminalCapabilities caps; + EXPECT_FALSE(detect_hover_motion_support_with( + caps, make_sync_env_lookup(std::nullopt, std::nullopt, std::nullopt, + std::string("Apple_Terminal"), + std::nullopt))); +} + +// 场景:全空 / 未知终端 → 默认关闭 +TEST(HoverMotionSupport, UnknownOff) { + TerminalCapabilities caps; + EXPECT_FALSE(detect_hover_motion_support_with( + caps, make_sync_env_lookup(std::nullopt, std::nullopt, std::nullopt, + std::nullopt, std::nullopt))); + EXPECT_FALSE(detect_hover_motion_support_with( + caps, make_sync_env_lookup(std::nullopt, std::nullopt, std::nullopt, + std::nullopt, + std::string("xterm-256color")))); +} From b1872b0e03014702e117d2f286e6d6ba55497d76 Mon Sep 17 00:00:00 2001 From: slyxyllt Date: Thu, 3 Sep 2026 00:03:56 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E9=A1=B5=E6=96=B0=E5=A2=9E=E6=A8=A1=E5=9E=8B=E5=BC=B9=E7=AA=97?= =?UTF-8?q?=E8=A2=AB=E5=B1=82=E5=8F=A0=E4=B8=8A=E4=B8=8B=E6=96=87=E9=81=AE?= =?UTF-8?q?=E6=8C=A1=20+=20=E6=96=B0=E5=A2=9E=20desktop=20=E4=B8=80?= =?UTF-8?q?=E9=94=AE=20dev=20=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SettingsPage: 将 transition-all + translate-y 改为 transition-opacity + 纯 opacity, 消除 transform 创建的层叠上下文,解决新增模型弹窗被设置页面遮挡的问题 - 新增 scripts/dev_desktop.py: 一键启动 desktop 开发环境,自动构建 web/dist 并 设置 ACECODE_DEV_WEB_DIR 环境变量,改 H5 无需重编 C++ - 新增 scripts/dev_desktop.sh / .bat: 跨平台启动器 --- scripts/dev_desktop.bat | 25 ++ scripts/dev_desktop.py | 366 ++++++++++++++++++++++++++++ scripts/dev_desktop.sh | 20 ++ web/src/components/SettingsPage.jsx | 4 +- 4 files changed, 413 insertions(+), 2 deletions(-) create mode 100644 scripts/dev_desktop.bat create mode 100644 scripts/dev_desktop.py create mode 100755 scripts/dev_desktop.sh diff --git a/scripts/dev_desktop.bat b/scripts/dev_desktop.bat new file mode 100644 index 00000000..750125fa --- /dev/null +++ b/scripts/dev_desktop.bat @@ -0,0 +1,25 @@ +@echo off +REM ACECode Desktop 一键开发脚本 (Windows) +REM 用法: scripts\dev_desktop.bat [选项] +REM 详见 python scripts\dev_desktop.py --help + +setlocal enabledelayedexpansion + +set "SCRIPT_DIR=%~dp0" + +REM 选择 python 解释器 +where python >nul 2>&1 +if %errorlevel%==0 ( + set "PYTHON=python" +) else ( + where py >nul 2>&1 + if %errorlevel%==0 ( + set "PYTHON=py" + ) else ( + echo [ERROR] 未找到 python 或 py,请先安装 Python 3.8+ + exit /b 1 + ) +) + +"%PYTHON%" "%SCRIPT_DIR%dev_desktop.py" %* +exit /b %errorlevel% diff --git a/scripts/dev_desktop.py b/scripts/dev_desktop.py new file mode 100644 index 00000000..65737c87 --- /dev/null +++ b/scripts/dev_desktop.py @@ -0,0 +1,366 @@ +#!/usr/bin/env python3 +""" +ACECode Desktop 一键开发脚本 + +用途:只修改了 web/H5 代码时,快速启动 desktop 开发环境验证, + 无需重新编译 C++。 + +原理:desktop 内置 dev 模式 —— 检测到 web/dist/ 目录后, + daemon 会从文件系统加载前端资源,pnpm build 后按 F5 即生效。 + +用法: + python scripts/dev_desktop.py # 自动构建(如需)并启动 desktop + python scripts/dev_desktop.py --rebuild # 强制重新构建 web 再启动 + python scripts/dev_desktop.py --no-build # 跳过构建,直接启动 + python scripts/dev_desktop.py --build-dir # 指定 desktop 构建目录 + python scripts/dev_desktop.py --list # 列出可用的 desktop 构建产物 +""" + +import argparse +import os +import platform +import shutil +import subprocess +import sys +from pathlib import Path + + +# ─── 颜色输出(终端支持时) ─────────────────────────────────────────────── + +def _supports_color() -> bool: + if os.environ.get("NO_COLOR"): + return False + if sys.platform == "win32": + return os.environ.get("WT_SESSION") is not None or os.environ.get("ANSICON") is not None + return sys.stdout.isatty() + + +_COLOR = _supports_color() + + +def _c(text: str, code: str) -> str: + return f"\033[{code}m{text}\033[0m" if _COLOR else text + + +def info(msg: str) -> None: + print(_c("[INFO] ", "36m") + msg) + + +def ok(msg: str) -> None: + print(_c("[OK] ", "32m") + msg) + + +def warn(msg: str) -> None: + print(_c("[WARN] ", "33m") + msg) + + +def error(msg: str) -> None: + print(_c("[ERROR]", "31m") + " " + msg, file=sys.stderr) + + +def banner(msg: str) -> None: + width = max(len(msg) + 4, 40) + print(_c("=" * width, "35m")) + print(_c(f" {msg}", "35m")) + print(_c("=" * width, "35m")) + + +# ─── 项目根目录定位 ──────────────────────────────────────────────────────── + +def find_project_root() -> Path: + """从脚本位置向上查找项目根目录(包含 CMakeLists.txt 和 web/ 的目录)。""" + script_dir = Path(__file__).resolve().parent + cur = script_dir + for _ in range(6): + if (cur / "CMakeLists.txt").exists() and (cur / "web").is_dir(): + return cur + if cur.parent == cur: + break + cur = cur.parent + # 兜底:用脚本所在目录的父目录 + return script_dir.parent + + +# ─── 依赖检查 ────────────────────────────────────────────────────────────── + +def check_command(name: str) -> str | None: + """检查命令是否可用,返回路径或 None。""" + return shutil.which(name) + + +def ensure_node_and_pnpm() -> tuple[str, str]: + """确保 node 和 pnpm 可用,返回 (node_path, pnpm_path)。""" + node = check_command("node") + if not node: + error("未找到 node.js,请先安装 Node.js 18+") + sys.exit(1) + pnpm = check_command("pnpm") + if not pnpm: + warn("未找到 pnpm,尝试用 npm 安装...") + npm = check_command("npm") + if not npm: + error("未找到 npm,无法安装 pnpm") + sys.exit(1) + subprocess.run([npm, "install", "-g", "pnpm"], check=True) + pnpm = check_command("pnpm") + if not pnpm: + error("pnpm 安装失败") + sys.exit(1) + return node, pnpm + + +# ─── Web 构建 ────────────────────────────────────────────────────────────── + +def build_web(web_dir: Path, pnpm: str, force: bool = False) -> None: + """构建 web 前端到 web/dist/。""" + dist_dir = web_dir / "dist" + index_html = dist_dir / "index.html" + + if not force and index_html.exists(): + # 检查 src/ 下是否有比 dist/index.html 更新的文件 + src_dir = web_dir / "src" + if src_dir.is_dir(): + dist_mtime = index_html.stat().st_mtime + needs_rebuild = False + for f in src_dir.rglob("*"): + if f.is_file() and f.stat().st_mtime > dist_mtime: + needs_rebuild = True + break + if not needs_rebuild: + ok("web/dist/ 已是最新,跳过构建") + return + info("检测到 web/src/ 有更新,重新构建...") + else: + ok("web/dist/ 已存在,跳过构建") + return + else: + if force: + info("强制重新构建 web...") + else: + info("web/dist/ 不存在,开始构建...") + + # 确保 node_modules 存在 + if not (web_dir / "node_modules").is_dir(): + info("安装 web 依赖 (pnpm install)...") + subprocess.run([pnpm, "install"], cwd=web_dir, check=True) + + info("运行 pnpm build...") + result = subprocess.run([pnpm, "build"], cwd=web_dir) + if result.returncode != 0: + error(f"pnpm build 失败 (exit code {result.returncode})") + sys.exit(1) + ok("web 构建完成") + + +# ─── Desktop 构建产物定位 ─────────────────────────────────────────────────── + +def find_desktop_builds(build_dir: Path) -> list[Path]: + """在 build/ 目录下查找所有可用的 desktop 构建产物。""" + results: list[Path] = [] + if not build_dir.is_dir(): + return results + + for child in sorted(build_dir.iterdir()): + if not child.is_dir(): + continue + # macOS .app bundle + app_bundle = child / "ACECode.app" + if app_bundle.is_dir(): + results.append(app_bundle) + continue + # Windows .exe + exe = child / "acecode-desktop.exe" + if exe.exists(): + results.append(exe) + continue + # Linux / macOS 裸可执行文件(非 .app) + binary = child / "acecode-desktop" + if binary.exists() and os.access(binary, os.X_OK): + results.append(binary) + continue + + return results + + +def pick_desktop_build(builds: list[Path], preferred: str | None = None) -> Path | None: + """从可用构建中选择一个。优先选 release,其次按名称排序。""" + if not builds: + return None + if preferred: + for b in builds: + if preferred in str(b): + return b + # 优先选 desktop-release + for b in builds: + if "desktop-release" in str(b): + return b + # 其次选 release + for b in builds: + if "release" in str(b).lower(): + return b + # 兜底选第一个 + return builds[0] + + +# ─── 启动 Desktop ─────────────────────────────────────────────────────────── + +def launch_desktop(desktop_path: Path, dev_web_dir: Path) -> None: + """启动 desktop app,并设置 ACECODE_DEV_WEB_DIR 环境变量。""" + env = os.environ.copy() + env["ACECODE_DEV_WEB_DIR"] = str(dev_web_dir.resolve()) + + info(f"ACECODE_DEV_WEB_DIR = {dev_web_dir.resolve()}") + + if sys.platform == "darwin": + if desktop_path.suffix == ".app": + # macOS .app bundle + info(f"启动 {desktop_path.name}...") + subprocess.Popen(["open", str(desktop_path)], env=env) + else: + # 裸可执行文件 + info(f"启动 {desktop_path.name}...") + subprocess.Popen([str(desktop_path)], env=env) + elif sys.platform == "win32": + info(f"启动 {desktop_path.name}...") + # Windows 下用 DETACHED_PROCESS 避免阻塞 + DETACHED_PROCESS = 0x00000008 + subprocess.Popen( + [str(desktop_path)], + env=env, + creationflags=DETACHED_PROCESS, + close_fds=True, + ) + else: + # Linux + info(f"启动 {desktop_path.name}...") + subprocess.Popen([str(desktop_path)], env=env, start_new_session=True) + + +# ─── 主流程 ───────────────────────────────────────────────────────────────── + +def main() -> None: + parser = argparse.ArgumentParser( + description="ACECode Desktop 一键开发脚本 — 只改 H5 时快速验证,无需重编 C++", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +示例: + %(prog)s # 自动构建(如需)并启动 desktop + %(prog)s --rebuild # 强制重新构建 web 再启动 + %(prog)s --no-build # 跳过构建,直接启动 + %(prog)s --list # 列出可用的 desktop 构建产物 + %(prog)s --build-dir build/macos-x64-desktop-release # 指定构建目录 + """, + ) + parser.add_argument("--rebuild", action="store_true", help="强制重新构建 web 前端") + parser.add_argument("--no-build", action="store_true", help="跳过 web 构建,直接启动 desktop") + parser.add_argument("--list", action="store_true", help="列出可用的 desktop 构建产物并退出") + parser.add_argument("--build-dir", type=str, default=None, help="指定 desktop 构建目录(相对于项目根或绝对路径)") + parser.add_argument("--root", type=str, default=None, help="指定项目根目录(自动检测失败时使用)") + + args = parser.parse_args() + + banner("ACECode Desktop Dev Launcher") + + # 1. 定位项目根目录 + if args.root: + project_root = Path(args.root).resolve() + else: + project_root = find_project_root() + if not (project_root / "CMakeLists.txt").exists(): + error(f"项目根目录无效: {project_root} (未找到 CMakeLists.txt)") + sys.exit(1) + info(f"项目根目录: {project_root}") + + web_dir = project_root / "web" + build_dir = project_root / "build" + dev_web_dir = web_dir / "dist" + + # 2. --list 模式 + if args.list: + builds = find_desktop_builds(build_dir) + if not builds: + warn("未找到任何 desktop 构建产物") + info(f"请先在 {build_dir} 下构建 desktop:") + info(" cmake -S . -B build/ -DACECODE_BUILD_DESKTOP=ON ...") + info(" cmake --build build/ --target acecode-desktop") + else: + ok(f"找到 {len(builds)} 个 desktop 构建产物:") + for i, b in enumerate(builds, 1): + print(f" {i}. {b.relative_to(project_root)}") + return + + # 3. 检查并构建 web + if not args.no_build: + _, pnpm = ensure_node_and_pnpm() + build_web(web_dir, pnpm, force=args.rebuild) + else: + if not (dev_web_dir / "index.html").exists(): + error(f"web/dist/index.html 不存在,无法跳过构建。请先运行 pnpm build,或去掉 --no-build") + sys.exit(1) + ok("跳过 web 构建 (--no-build)") + + # 4. 定位 desktop 构建产物 + if args.build_dir: + specified = Path(args.build_dir) + if not specified.is_absolute(): + specified = project_root / specified + # 支持指定到 .app / .exe / 目录 + if specified.suffix == ".app" or specified.name.endswith(".exe"): + desktop_path = specified + else: + # 目录下查找 + candidates = find_desktop_builds(specified.parent if specified.name.startswith("build") else specified) + if not candidates: + # 直接在指定目录下找 + app = specified / "ACECode.app" + exe = specified / "acecode-desktop.exe" + binf = specified / "acecode-desktop" + if app.is_dir(): + desktop_path = app + elif exe.exists(): + desktop_path = exe + elif binf.exists() and os.access(binf, os.X_OK): + desktop_path = binf + else: + error(f"在指定目录下未找到 desktop 构建产物: {specified}") + sys.exit(1) + else: + desktop_path = candidates[0] + else: + builds = find_desktop_builds(build_dir) + if not builds: + error("未找到任何 desktop 构建产物") + info("请先构建 desktop:") + info(" cmake -S . -B build/ -DACECODE_BUILD_DESKTOP=ON ...") + info(" cmake --build build/ --target acecode-desktop") + info("或使用 --list 查看可用构建") + sys.exit(1) + desktop_path = pick_desktop_build(builds) + if len(builds) > 1: + info(f"找到 {len(builds)} 个构建产物,自动选择: {desktop_path.relative_to(project_root)}") + info("(可用 --build-dir 指定其他构建,或 --list 查看全部)") + + if not desktop_path.exists(): + error(f"desktop 构建产物不存在: {desktop_path}") + sys.exit(1) + ok(f"Desktop 构建: {desktop_path.relative_to(project_root)}") + + # 5. 启动 desktop + launch_desktop(desktop_path, dev_web_dir) + ok("Desktop 已启动") + + # 6. 打印使用提示 + print() + print(_c("── 开发提示 ──────────────────────────────────────", "35m")) + print(" 1. 修改 web/src/ 下的代码后,重新构建:") + print(f" cd {web_dir} && pnpm build") + print(" 或重新运行本脚本加 --rebuild") + print(" 2. 在 Desktop 窗口按 F5 刷新页面即可看到改动") + print(" 3. 按 F11 打开 WebView 开发者工具调试") + print(" 4. 无需重新编译 C++!") + print(_c("──────────────────────────────────────────────────", "35m")) + print() + + +if __name__ == "__main__": + main() diff --git a/scripts/dev_desktop.sh b/scripts/dev_desktop.sh new file mode 100755 index 00000000..09d7b360 --- /dev/null +++ b/scripts/dev_desktop.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# ACECode Desktop 一键开发脚本 (macOS / Linux) +# 用法: ./scripts/dev_desktop.sh [选项] +# 详见 python scripts/dev_desktop.py --help + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# 选择 python 解释器 +if command -v python3 &>/dev/null; then + PYTHON=python3 +elif command -v python &>/dev/null; then + PYTHON=python +else + echo "[ERROR] 未找到 python3 或 python,请先安装 Python 3.8+" + exit 1 +fi + +exec "$PYTHON" "$SCRIPT_DIR/dev_desktop.py" "$@" diff --git a/web/src/components/SettingsPage.jsx b/web/src/components/SettingsPage.jsx index 8affd503..7b8c5654 100644 --- a/web/src/components/SettingsPage.jsx +++ b/web/src/components/SettingsPage.jsx @@ -179,8 +179,8 @@ export function SettingsPage({