[fix] improve CLI mouse reporting and click positioning

This commit is contained in:
dijunkun
2026-10-30 16:38:34 +08:00
parent 7b18937df0
commit de1a663ab4
5 changed files with 115 additions and 41 deletions
+7 -3
View File
@@ -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` 表示支持本文的持续本地命令模式;启动日志同时记录实际执行路径,以便区分不同工程的构建产物。
+7
View File
@@ -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 包名均不代表已发布或已验收。
+13 -3
View File
@@ -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},
@@ -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
+83 -35
View File
@@ -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<unsigned char>(pending_[3]) - 32;
const int x = static_cast<unsigned char>(pending_[4]) - 32;
const int y = static_cast<unsigned char>(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.