Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@
set(ACECODE_WEB_DIST "${CMAKE_SOURCE_DIR}/web/dist")
set(ACECODE_WEB_EMBED_DIR "${ACECODE_WEB_DIST}")
if(NOT EXISTS "${ACECODE_WEB_DIST}")
message(WARNING

Check warning on line 98 in CMakeLists.txt

View workflow job for this annotation

GitHub Actions / unit-tests (linux-x64)

[acecode] web/dist/ not found — embedding a minimal fallback page. Run
"[acecode] web/dist/ not found — embedding a minimal fallback page. "
"Run `pnpm install && pnpm build` inside web/ before re-configuring for the full UI.")
set(ACECODE_WEB_EMBED_DIR "${CMAKE_BINARY_DIR}/generated/web-dist-fallback")
Expand Down Expand Up @@ -254,6 +254,7 @@
${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
Expand Down
2 changes: 2 additions & 0 deletions openspec/changes/add-tui-hyperlinks/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-01
69 changes: 69 additions & 0 deletions openspec/changes/add-tui-hyperlinks/design.md
Original file line number Diff line number Diff line change
@@ -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 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 隐患已解决**(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(目标 / 非目标)

**Goals:**

- 聊天区内任何 `http`/`https` 链接点击即用系统默认浏览器打开,两种渲染模式均生效。
- 渲染期中和"文字伪装 URL"(host 不符)的链接;不影响正常命名的链接。
- 在支持的终端上发射 OSC 8,让 Cmd/Ctrl+点击、原生悬停、右键打开/复制生效——终端用户已有的肌肉记忆。
- 悬停时在应用内浮层显示真实 URL,作为人眼兜底。
- 不支持的终端输出与现状字节级一致。
- 孤儿 commit 隐患已在实施前修复(PR #1 合并,见决策 7)。

**Non-Goals:**

- 内容滚入终端历史后仍可点击(报告差距 #8,滚屏改造——本轮只打 OSC 8 地基,收益后置)。
- 完整 URL 匹配校验(只比域名;`faceb00k.com` 这类仿冒域名不归本层管,归浏览器)。
- 老式/经典 Windows conhost 的悬停支持(1003 在那里保持关闭)。
- DA1/DECRQM 查询式探测。
- web UI 或 daemon 的 OSC 8(仅 TUI)。

## Decisions(决策)

### 1. OSC 8 透传采用上游现成实现,不写补丁

核实(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()` 在换行处先经 `UpdateCellStyle(..., default_cell)` 复位样式(含 hyperlink),再输出 `\r\n`;结尾同样复位——行尾必关闭 ✓。`UpdateCellStyle()` 只在 `next.hyperlink != prev.hyperlink` 时发射——相邻同 id 不重发 ✓。`hyperlink_test.cpp` 已有字节级断言(开/关/相邻切换)✓。这些验收点 spec 里已有覆盖,无需新增框架测试。

### 3. `ftxui::hyperlink()` 装饰器现成可用

上游 `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. 只比域名的防骗校验

纯函数(无 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`、不在任何命名分支上而构成孤儿 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 单次交付

按既定范围,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 面积扩大**:仅新增悬停补丁一个,分叉面比原方案(OSC 8 + 悬停两个补丁)小一半;OSC 8 是上游功能零分叉。悬停补丁为 opt-in、记录在 `ACECODE_PATCHES.md`。
33 changes: 33 additions & 0 deletions openspec/changes/add-tui-hyperlinks/proposal.md
Original file line number Diff line number Diff line change
@@ -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. 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` 已通过 shaohaozhi286/FTXUI PR #1 合并(`20c99b5d` 真 merge)进入 `main` 历史;`.gitmodules` 已回切官方 URL 并删除 `branch` 行(`97e6e351`);`feat/osc8-hyperlink` 分支已从 `658c942` 创建,补丁落地后 push 到可写远程并更新父仓库 gitlink。

## Capabilities

### New Capabilities

- `tui-hyperlinks`:聊天区网页链接可点击(应用内点击开系统浏览器)、伪装 URL 的显示文字在渲染期被中和、支持终端发射 OSC 8 原生超链接、悬停显示真实 URL 气泡。

### Modified Capabilities

无。终端能力探测(`terminal_capability`)新增 helper,现有结构体、签名、行为不变。

## Impact

- `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 打开分支。
- `src/main.cpp`:悬停气泡元素 + 现有 `link_regions` 命中检测循环中的鼠标移动事件处理。
- `tests/markdown/`、`tests/tui/`、`tests/utils/`:校验、OSC 8 发射、探测的单元覆盖。
- 不涉及协议、daemon、web 改动。
73 changes: 73 additions & 0 deletions openspec/changes/add-tui-hyperlinks/specs/tui-hyperlinks/spec.md
Original file line number Diff line number Diff line change
@@ -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 控制台宿主)
- **则** 悬停移动上报保持关闭、不显示气泡,其余行为不变
Loading
Loading