From de1a663ab4baec7841f856efddbf81ab8f608682 Mon Sep 17 00:00:00 2001 From: dijunkun Date: Fri, 30 Oct 2026 16:38:34 +0800 Subject: [PATCH] [fix] improve CLI mouse reporting and click positioning --- apps/cli/docs/terminal-client.md | 10 +- apps/cli/docs/validation.md | 7 ++ apps/cli/main.cpp | 16 ++- libs/terminal/include/terminal_renderer.h | 5 + libs/terminal/src/input.cpp | 118 +++++++++++++++------- 5 files changed, 115 insertions(+), 41 deletions(-) diff --git a/apps/cli/docs/terminal-client.md b/apps/cli/docs/terminal-client.md index 00cf4cab..1150f36e 100644 --- a/apps/cli/docs/terminal-client.md +++ b/apps/cli/docs/terminal-client.md @@ -100,7 +100,11 @@ macOS 自带 Terminal 还需要在当前窗口勾选“显示 → 允许鼠标 只读时,`d` 或在画面中单击进入该位置的原始细节模式;`+` / `=` 放大、`-` 缩小(1~32倍),滚轮以鼠标指向为中心调整倍率。方向键或 `i/j/k/l` 分别向上/左/下/右平移,`f` 恢复完整画面。黑边点击被忽略。这些按键和鼠标在控制模式下仍传给远端;进入本地命令模式后可连续调整视图,直至手动返回。放大、平移和只读鼠标定位不会向远端发送操作,也适用于静止画面。 -支持 SGR 鼠标移动、左右中键、拖动和滚轮;黑边不发送新点击,黑边处松开仍释放旧按钮。输入坐标映射到所选显示器,`--display` 是 HostInformation 列表的零起始索引,视频流为 Display1、Display2…… +支持 SGR 和传统 `CSI M` 鼠标报告,包括移动、左右中键、拖动和滚轮;完整缓冲跨读取分片的传统报告,坐标字节不会作为按键执行。启动时依次启用普通、拖动和全移动报告,兼容只支持部分跟踪模式的终端;退出时关闭这些模式。传统报告的坐标上限为 223,较大窗口应使用支持 SGR 的终端。协议格式依据 [XTerm 鼠标协议](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-Mouse-Tracking)。 + +点击和滚轮先发送对应位置的移动事件,不依赖终端提前上报移动;黑边不发送新点击,黑边处松开仍释放旧按钮。传统报告无法区分松开的按钮,因此释放本客户端记录的全部已按下鼠标按钮。输入坐标映射到所选显示器,`--display` 是 HostInformation 列表的零起始索引,视频流为 Display1、Display2…… + +控制模式显示画面后,状态栏的 `Mouse RX`(窄窗口为 `M:`)显示累计收到的有效鼠标报告数,即使画面中看不清远端指针也能检查输入。若点击或移动时计数一直为 0,检查本地终端的鼠标报告设置及 SSH 是否分配 PTY(`ssh -t`);若计数增长但远端仍无响应,退出后查看以下日志统计。窗口失焦会退回只读,重新聚焦后需再按 `c` 开启控制。 支持 UTF-8 文本与 bracketed paste(远端须声明 text_input),Enter、Tab、Backspace、Esc、方向、Home/End、Delete、PageUp/Down、F1–F4、Ctrl/Alt+英文字母。不支持的远端文本能力会提示,不把粘贴执行为本地 shell 命令。普通终端采用立即按下/释放的 tap 策略;不承诺持续按键、所有组合键、IME 或完整键盘布局。Pi 使用宿主 SDK 解码增强按键后转为这组基础输入。 @@ -136,8 +140,8 @@ pi -e ./apps/cli/plugins/pi/index.ts - `display_not_available`:换成远端声明的索引;单会话一次只显示一个屏幕。 - `media_initialization_failed`:检查编译依赖和本地媒体运行环境。 - 只读下按键不会发送到远端;远端未声明文本协议时文本输入不可用。 -- 日志在进程创建的私有临时目录(POSIX `/tmp/crossdesk-cli-*`、Windows `%TEMP%\crossdesk-cli-*`)。交互连接的摘要在 client.log,包含收到的终端鼠标事件数 `mouse_reports`;transport.log 只记录白名单中的 ICE/DTLS 状态及本地控制开关,不记录密码、SDP 或原始信令。使用完可删除。错误退出信息在恢复终端后显示,并给出本次日志目录。 -- `connection_failed` 表示连接建立失败;`connection_lost` 表示连接成功后传输失效。xmake 随后的 `execv ... failed(1)` 表示程序以错误码 1 退出,本身不代表进程崩溃。遇到控制失效时,对照 transport.log 中的控制开关和连接状态;`mouse_reports` 为 0 表示终端没有向 CLI 提供可识别的 SGR 鼠标事件。 +- 日志在进程创建的私有临时目录(POSIX `/tmp/crossdesk-cli-*`、Windows `%TEMP%\crossdesk-cli-*`)。交互连接的摘要在 client.log,包含收到的终端鼠标报告数 `mouse_reports`、其中的传统报告数 `mouse_legacy_reports`、成功交给传输层的鼠标事件数 `mouse_forwarded`、发送失败数 `mouse_send_failures`。一次点击报告可能生成定位和按键两条事件,所以发送数可能大于报告数;发送成功不表示被控端已执行。transport.log 只记录白名单中的 ICE/DTLS 状态及本地控制开关,不记录密码、SDP 或原始信令。使用完可删除。错误退出信息在恢复终端后显示,并给出本次日志目录。 +- `connection_failed` 表示连接建立失败;`connection_lost` 表示连接成功后传输失效。xmake 随后的 `execv ... failed(1)` 表示程序以错误码 1 退出,本身不代表进程崩溃。遇到控制失效时,对照 transport.log 中的控制开关和连接状态;`mouse_reports` 为 0 表示终端没有向 CLI 提供可识别的鼠标事件。报告数增长但 `mouse_forwarded` 为 0 时,检查是否仍为只读/本地命令模式、是否已有画面,以及点击是否落在视频区域;`mouse_send_failures` 增长时检查连接状态。 - 如果整个 macOS Terminal 窗口退出且 CLI 记录 `output closed`,还应检查系统 DiagnosticReports 中的 Terminal 报告。已观察到 Terminal 2.15 在创建标签页并调整窗口尺寸时发生 `TTLogicalScreen expandUnicharOffsetCacheToLine:` 行缓存越界;这与 CLI 的 `connection_failed` 不同。临时可在独立窗口运行,避开该标签页操作,不能据此认为 Terminal 缺陷已修复。 - `doctor --json` 是环境报告,不会联网登录、主动探测 Kitty 或证明远端可连接。 `input_shortcuts=local_commands_v2` 表示支持本文的持续本地命令模式;启动日志同时记录实际执行路径,以便区分不同工程的构建产物。 diff --git a/apps/cli/docs/validation.md b/apps/cli/docs/validation.md index 736853e7..a62d0a20 100644 --- a/apps/cli/docs/validation.md +++ b/apps/cli/docs/validation.md @@ -51,6 +51,13 @@ - 改为 `cli-` 临时身份后,正式 CLI 与本地 WSS 服务端验证了临时注册、Join、身份不可复用及退出清理;线上服务尚未在该轮部署,早期画面验证不能代替新身份协议的线上联调。 - npm 的 macOS arm64 本地生成及安装已验证;Linux / Windows 当时仅完成 CLI 主程序和平台 I/O 的交叉编译检查,没有完整 MiniRTC 链接和实机连接结果。 +## 终端鼠标兼容修复(2026-10-30) + +- 工程外假传输复现:旧解析器把传统 `CSI M` 报告的三个负载字节作为键盘文本发送,`mouse_reports` 仍为 0;SGR 点击只发送按下/松开,没有提前定位。 +- 修复后的 ASan/UBSan 检查通过,覆盖 SGR/传统报告的全部二段分片与逐字节输入、左右中键、拖动、滚轮、点击前定位、高位坐标字节、黑边松开、只读/本地命令模式、失焦释放及发送失败。既有持续本地命令模式回归同样通过。 +- macOS arm64 的 CLI debug 构建和 `doctor --json` 通过。生产 CLI 配合工程外假传输的 POSIX PTY 检查在 120/50 列窗口通过,验证逐字节鼠标输入、`Mouse RX`/`M:` 状态栏计数、日志统计、鼠标报告模式启停和原始终端模式恢复。 +- 所有额外测试代码、脚本和产物均位于工程外临时目录。上述结果未覆盖真实 SSH 链路、GNOME Terminal 或 Linux 被控端实机,不能据此确认用户现场的问题已消失。 + ## 实测边界 本地检查不涵盖 Linux / Windows 完整构建、跨平台远程控制、Kitty/SSH 实际终端显示、真实 Pi TUI 或长时间资源占用。五个平台 CI 配置与 npm 包名均不代表已发布或已验收。 diff --git a/apps/cli/main.cpp b/apps/cli/main.cpp index 23ad1bb0..0975c86c 100644 --- a/apps/cli/main.cpp +++ b/apps/cli/main.cpp @@ -81,14 +81,16 @@ struct Tty { int out; RawTerminal terminal; Tty(int input, int output) : out(output), terminal(input, output) { - const char start[] = "\033[?1049h\033[?25l\033[?7l\033[?1003h\033[?" - "1006h\033[?1004h\033[?2004h\033[2J"; + // Fall back to button/drag reporting if all-motion tracking is unsupported. + const char start[] = "\033[?1049h\033[?25l\033[?7l\033[?1000h\033[?1002h" + "\033[?1003h\033[?1006h\033[?1004h\033[?2004h\033[2J"; Write(out, start, sizeof(start) - 1); } ~Tty() { // Cancel any incomplete string/control sequence before restoration. std::string end = "\030\033\\" + std::string(kKittyDelete) + - "\033[0m\033[?1003l\033[?1006l\033[?1004l\033[?2004l\033[" + "\033[0m\033[?1003l\033[?1002l\033[?1000l" + "\033[?1006l\033[?1004l\033[?2004l\033[" "?7h\033[?25h\033[?1049l"; Write(out, end.data(), end.size()); } @@ -341,6 +343,11 @@ int View(SessionOptions options, const std::string &device, bar.compact_progress = first_output_at ? "LIVE" : s.Connected() ? "WAIT " + elapsed : "CONNECT " + elapsed; + if (first_output_at && input.control && !input.command_mode) { + const auto reports = std::to_string(input.mouse_reports); + bar.progress += " | Mouse RX " + reports; + bar.compact_progress += " M:" + reports; + } bar.error = input.error; bar.control = input.control; bar.command_mode = input.command_mode; @@ -439,6 +446,9 @@ int View(SessionOptions options, const std::string &device, {"first_frame", elapsed_ms(first_frame_at)}, {"first_output", elapsed_ms(first_output_at)}}}, {"mouse_reports", input.mouse_reports}, + {"mouse_legacy_reports", input.mouse_legacy_reports}, + {"mouse_forwarded", input.mouse_forwarded}, + {"mouse_send_failures", input.mouse_send_failures}, {"submitted", submitted}, {"output_bytes", output.bytes}, {"last_queue_ms", queue_ms}, diff --git a/libs/terminal/include/terminal_renderer.h b/libs/terminal/include/terminal_renderer.h index c83deeca..2337054d 100644 --- a/libs/terminal/include/terminal_renderer.h +++ b/libs/terminal/include/terminal_renderer.h @@ -70,15 +70,20 @@ public: double zoom = 1, center_x = .5, center_y = .5; uint64_t view_revision = 0; uint64_t mouse_reports = 0; + uint64_t mouse_legacy_reports = 0, mouse_forwarded = 0; + uint64_t mouse_send_failures = 0; private: std::string pending_; std::chrono::steady_clock::time_point last_input_{}; bool pasting_ = false; + unsigned mouse_buttons_ = 0; float pointer_x_ = .5f, pointer_y_ = .5f; void Tap(ControllerSession &, int key, int modifier = 0); bool LocalKey(int key, ControllerSession &, const Grid *); void Navigate(char key, const Grid *); + void Mouse(int button, int x, int y, bool release, ControllerSession &, + const Grid *); }; } // namespace crossdesk::terminal diff --git a/libs/terminal/src/input.cpp b/libs/terminal/src/input.cpp index a3877056..6c21cd88 100644 --- a/libs/terminal/src/input.cpp +++ b/libs/terminal/src/input.cpp @@ -21,6 +21,7 @@ bool Input::LocalKey(int key, ControllerSession &s, const Grid *grid) { command_mode = true; // Release held buttons and suspend remote input while choosing a command. s.SetControl(false); + mouse_buttons_ = 0; } return true; } @@ -29,6 +30,7 @@ bool Input::LocalKey(int key, ControllerSession &s, const Grid *grid) { if (key == '?') { command_mode = true; s.SetControl(false); + mouse_buttons_ = 0; return true; } if (command_mode && (key == 27 || key == 3)) { @@ -52,6 +54,8 @@ bool Input::LocalKey(int key, ControllerSession &s, const Grid *grid) { error = "Unknown local command; ? for help"; } s.SetControl(control && !command_mode); + if (!control || command_mode) + mouse_buttons_ = 0; return true; } void Input::Navigate(char key, const Grid *grid) { @@ -91,6 +95,65 @@ void Input::Navigate(char key, const Grid *grid) { center_y = std::clamp(center_y, 0.0, 1.0); ++view_revision; } +void Input::Mouse(int b, int x, int y, bool release, ControllerSession &s, + const Grid *grid) { + ++mouse_reports; + // Extra mouse buttons must not alias the three supported remote buttons. + if (!grid || command_mode || b < 0 || b >= 128) + return; + auto pos = MapPointer(*grid, x - 1, y - 1); + if (!control) { + if (pos && !release && ((b & 64) || b == 0)) { + center_x = pos->first; + center_y = pos->second; + Navigate((b & 64) ? ((b & 1) ? '-' : '+') : 'd', grid); + } + return; + } + if (!pos && !release) + return; + if (pos) { + pointer_x_ = pos->first; + pointer_y_ = pos->second; + } + const auto send = [&](int flag, int wheel = 0) { + if (s.Pointer(pointer_x_, pointer_y_, flag, wheel)) { + ++mouse_forwarded; + return true; + } + ++mouse_send_failures; + return false; + }; + // Linux/X11 only positions the cursor on move events. A click or wheel + // report can arrive without prior motion, including over an SSH terminal. + // Always position before a button/scroll event; still release on failure. + if (pos && !send(0) && !release) + return; + if (b & 64) { + if (!release) + send((b & 2) ? 8 : 7, (b & 1) ? -120 : 120); + return; + } + if (b & 32) + return; + const int base = b & 3; + if (base == 3) { + // Legacy reports identify a release, but not which button was released. + if (release) { + for (unsigned i = 0; i < 3; ++i) + if ((mouse_buttons_ & (1u << i)) && send(2 + i * 2)) + mouse_buttons_ &= ~(1u << i); + } + return; + } + const int index = base == 0 ? 0 : base == 1 ? 2 : 1; + if (send(1 + index * 2 + int(release))) { + if (release) + mouse_buttons_ &= ~(1u << index); + else + mouse_buttons_ |= 1u << index; + } +} void Input::Feed(const std::string &bytes, ControllerSession &s, const Grid *grid) { if (exit) @@ -99,6 +162,7 @@ void Input::Feed(const std::string &bytes, ControllerSession &s, pending_.clear(); pasting_ = false; s.SetControl(false); + mouse_buttons_ = 0; control = false; return; } @@ -149,6 +213,23 @@ void Input::Feed(const std::string &bytes, ControllerSession &s, continue; } if (pending_[1] == '[' || pending_[1] == 'O') { + if (pending_.compare(0, 3, "\033[M") == 0) { + // Normal (non-SGR) reports have three raw bytes after CSI M. + // Keep the whole packet buffered across reads so its coordinates + // cannot become keystrokes or local commands. + if (pending_.size() < 6) + return; + const int b = static_cast(pending_[3]) - 32; + const int x = static_cast(pending_[4]) - 32; + const int y = static_cast(pending_[5]) - 32; + pending_.erase(0, 6); + if (b >= 0 && x > 0 && y > 0) { + ++mouse_legacy_reports; + const bool release = (b & 3) == 3 && !(b & (32 | 64)); + Mouse(b, x, y, release, s, grid); + } + continue; + } size_t end = 2; while (end < pending_.size() && !(pending_[end] >= '@' && pending_[end] <= '~')) @@ -160,6 +241,7 @@ void Input::Feed(const std::string &bytes, ControllerSession &s, if (seq == "\033[O") { control = false; s.SetControl(false); + mouse_buttons_ = 0; continue; } int key = 0; @@ -224,41 +306,7 @@ void Input::Feed(const std::string &bytes, ControllerSession &s, mouse = mouse && cur == limit; } if (mouse) - ++mouse_reports; - if (grid && mouse && !command_mode) { - auto pos = MapPointer(*grid, x - 1, y - 1); - if (!control) { - if (pos && action == 'M' && ((b & 64) || b == 0)) { - center_x = pos->first; - center_y = pos->second; - if (b & 64) - Navigate((b & 1) ? '-' : '+', grid); - else - Navigate('d', grid); - } - continue; - } - if (!pos && action != 'm') - continue; - if (pos) { - pointer_x_ = pos->first; - pointer_y_ = pos->second; - } - int flag = 0, wheel = 0; - if (b & 64) { - flag = (b & 2) ? 8 : 7; - wheel = (b & 1) ? -120 : 120; - } else if (!(b & 32)) { - int base = b & 3; - if (base == 3) - continue; - flag = (base == 0 ? 1 - : base == 1 ? 5 - : 3) + - (action == 'm' ? 1 : 0); - } - s.Pointer(pointer_x_, pointer_y_, flag, wheel); - } + Mouse(b, x, y, action == 'm', s, grid); continue; } // Alt+ASCII letter: a tap with explicit modifier release.