From 5d0a8f857f9637d355ce4a124f4a389a80ea9a98 Mon Sep 17 00:00:00 2001 From: dijunkun Date: Fri, 30 Oct 2026 11:22:40 +0800 Subject: [PATCH] [feat] improve CLI shortcuts and local command controls --- apps/cli/README.md | 2 +- apps/cli/docs/bridge-protocol.md | 4 +- apps/cli/docs/terminal-client.md | 23 ++-- apps/cli/docs/validation.md | 11 ++ apps/cli/main.cpp | 51 ++++++--- apps/cli/plugins/pi/index.ts | 22 ++-- libs/terminal/include/terminal_renderer.h | 6 +- libs/terminal/src/input.cpp | 121 +++++++++++++++------- 8 files changed, 174 insertions(+), 66 deletions(-) diff --git a/apps/cli/README.md b/apps/cli/README.md index 6ac1b10e..bc6c94a8 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -21,7 +21,7 @@ xmake run crossdesk-cli doctor --json xmake run crossdesk-cli view 123456789 --quality high --zoom native ``` -最后一个设备 ID 是格式示例,使用时替换为你有权访问的设备。密码通过隐藏提示输入。只读时点击目标或按 `1` 进入原始细节模式;`+/-` 或滚轮缩放,方向键或 `hjkl` 平移,`0` 回到完整桌面;Ctrl+] 退出,Ctrl+T 切换控制。ANSI 全屏缩略图仍受字符网格限制,默认请求 high 画质,阅读小字建议使用细节模式;支持 Kitty 协议的终端可使用 `--renderer kitty` 获得像素画面。 +最后一个设备 ID 是格式示例,使用时替换为你有权访问的设备。密码通过隐藏提示输入。只读时 `c` 开启控制、`q` 或 `Ctrl+C` 退出;点击目标或按 `d` 进入原始细节模式,`+/-` 或滚轮缩放,方向键或 `i` 上、`j` 左、`k` 下、`l` 右平移,`f` 回到完整桌面。任何模式下先按 `Ctrl+]` 打开本地命令提示,再按 `c` 切换控制、`q` 退出或视图按键调整一次画面,`Esc` 取消。控制模式中的 `Ctrl+T`、`Ctrl+C` 和普通按键均传给远端。ANSI 全屏缩略图仍受字符网格限制,默认请求 high 画质,阅读小字建议使用细节模式;支持 Kitty 协议的终端可使用 `--renderer kitty` 获得像素画面。 - [构建、使用、Pi 安装和故障排查](docs/terminal-client.md) - [私有桥接协议](docs/bridge-protocol.md) diff --git a/apps/cli/docs/bridge-protocol.md b/apps/cli/docs/bridge-protocol.md index ff2dc36f..90f47874 100644 --- a/apps/cli/docs/bridge-protocol.md +++ b/apps/cli/docs/bridge-protocol.md @@ -12,7 +12,7 @@ | connect | device:9位数字字符串, display:0..63(默认0) | 从专用 password fd 读取至换行/EOF(最多256字节、等待5秒),开始异步注册/连接;ok不是远端已连接 | | status | 无 | 返回当前状态 | | control | enabled:boolean | 显式启停控制;关闭时释放输入 | -| input | data:string,最多16KiB | 终端原始按键/UTF-8/bracketed paste/SGR鼠标;只读时不发送到远端,1/鼠标点击进入细节,+/-/滚轮缩放,方向键/hjkl 平移,0 全景;本地 Ctrl+] 退出,Ctrl+T 切换控制 | +| input | data:string,最多16KiB | 终端原始按键/UTF-8/bracketed paste/SGR鼠标;只读时 c 开启控制、q/Ctrl+C 退出,d/鼠标点击进入细节,+/-/滚轮缩放,方向键或 i 上、j 左、k 下、l 右平移,f 全景;Ctrl+] 打开本地命令,随后 c 切换控制、q 退出或视图键调整一次画面,Esc/Ctrl+C 取消;控制模式中 Ctrl+T/Ctrl+C 传给远端,Ctrl+] 两次传递远端 Ctrl+] | | display | index:0..63 | 选择已广告的零起始显示器,提升 generation | | ack | frame_id、generation:非负整数 | 确认宿主消费帧;匹配当前等待帧才开放下一帧 | | disconnect | 无 | 释放输入、结束当前媒体会话 | @@ -30,6 +30,8 @@ 状态事件每秒最多一次:`{"event":"state","status":{...},"output_bytes":123,"submitted_frames":3,"elapsed_seconds":1.5}`。status 含 registering/connecting/connected/failed/closed/idle、client_id(当前或刚结束会话的 `cli-xxxxxxxxx`,注册前为空)、error、control、generation、显示器、文本/预览能力、预览确认状态、请求的 preview_width/height/quality/bitrate_bps、帧计数与网络统计。接收/提交 FPS 由相应计数的增量除以时间增量计算。 +状态和成功响应中的 `status.command_mode` 表示正在等待本地命令,此时 `control=false`,远端输入暂停;执行或取消命令后恢复此前模式。`status.exit_requested` 表示用户按键请求退出,宿主可据此关闭组件。宿主应从 `input` 响应立即更新命令提示,不必等待下一次状态事件;不要自行截获 `Ctrl+]` 退出。显式 `control` 请求会结束命令状态。具体快捷键见 [终端客户端](terminal-client.md)。 + 边界:输入每条及当前解析缓冲上限64KiB、嵌套32层;响应队列64条;输出一条在途消息上限2MiB;1MiB/s;最多一条待 ACK 帧;一个最新待渲染 NV12 加一个最近画面的重绘缓存。协议错误明确响应;超限缓冲后结束。EOF 清理会话,完整的已收请求按序处理,不完整末尾报错;最多两秒排空输出后退出。宿主应持续消费响应,不能无限发送而不读取。失去连接后的释放只能尽力发送。 没有 `screenshot`、`audio`、文件、剪贴板、任意远端动作或 shell 命令方法;未知方法报错。模型接入者必须另行取得截图/自动操作授权,不能把持续帧事件附加到聊天记录。 diff --git a/apps/cli/docs/terminal-client.md b/apps/cli/docs/terminal-client.md index 9c24161a..85fd5ce9 100644 --- a/apps/cli/docs/terminal-client.md +++ b/apps/cli/docs/terminal-client.md @@ -45,7 +45,7 @@ ANSI 默认使用四象限字符:每个字符从 2×2 样本选择误差最小 画面按字符单元比例等比缩放,留黑边及状态栏,默认假定字符宽高比 0.5;TTY 提供像素尺寸时使用其比例。每次只输出变化行,合并连续颜色设置;缩放在完整输出边界清理旧画面。上限 240 列、100 画面行;默认预留两行及一列,避免自动换行。 -ANSI 在完整桌面缩略图中仍无法保留普通字号的文字。先增加终端列数(缩小终端字体或扩大窗口),阅读局部时用 `--zoom native`。只读全景中点击目标位置可直接进入该区域的细节模式;按 `1` 则以当前中心进入。细节模式依据视口和字符宽高比选择源区域,避免为了完整装入桌面而把文字缩小;ANSI 视野较小,借助方向键平移阅读。`--zoom 1..32` 是相对整桌面的倍率;在细节模式中 `+/-` 是相对原始细节尺寸的倍率。画面缩放完全在本地进行,控制鼠标坐标映射回原始桌面的正确区域。 +ANSI 在完整桌面缩略图中仍无法保留普通字号的文字。先增加终端列数(缩小终端字体或扩大窗口),阅读局部时用 `--zoom native`。只读全景中点击目标位置可直接进入该区域的细节模式;按 `d` 则以当前中心进入。细节模式依据视口和字符宽高比选择源区域,避免为了完整装入桌面而把文字缩小;ANSI 视野较小,借助方向键平移阅读。`--zoom 1..32` 是相对整桌面的倍率;在细节模式中 `+/-` 是相对原始细节尺寸的倍率。画面缩放完全在本地进行,控制鼠标坐标映射回原始桌面的正确区域。 auto 模式发送 Kitty 查询与设备属性查询,等待最多 250ms;没有有效 Kitty 回应就用 ANSI。显式 kitty 模式探测失败时报错。tmux/screen 中 auto 使用 ANSI,显式 kitty 报不支持;不修改用户终端配置。 @@ -79,9 +79,20 @@ crossdesk-cli view 123456789 --renderer ansi --fps 8 ## 输入与退出 -默认只读、静音。`--control` 或 Ctrl+T 明确启用输入;Ctrl+T 再次关闭并释放已记录按键/鼠标。Ctrl+] 始终退出;控制模式下 q 是远端文本。Ctrl+T 和 Ctrl+] 是保留本地组合,目前不可重绑定。 +默认只读、静音。`--control` 或只读时按 `c` 明确启用输入。`Ctrl+]` 是本地命令前缀:先按下并松开,再按下表中的命令键。状态栏即时显示当前模式与可用按键,单按前缀不会退出。 -只读时,`1` 或在画面中单击进入该位置的原始细节模式;`+` / `=` 放大、`-` 缩小(1~32倍),滚轮以鼠标指向为中心调整倍率。方向键或 `h/j/k/l` 向左/下/上/右平移,`0` 恢复完整画面。黑边点击被忽略。这些按键和鼠标在控制模式下仍传给远端;调整视图时先 Ctrl+T 切回只读。放大、平移和只读鼠标定位不会向远端发送操作,也适用于静止画面。 +| 操作 | 只读模式 | 控制模式 | +|---|---|---| +| 开启/关闭远端控制 | `c` | `Ctrl+]`,再按 `c` | +| 退出 CLI | `q` 或 `Ctrl+C` | `Ctrl+]`,再按 `q` | +| 显示本地命令提示 | `?` 或 `Ctrl+]` | `Ctrl+]` | +| 放大/缩小 | `+` / `=` / `-` | `Ctrl+]`,再按对应键 | +| 原始细节/完整画面 | `d`(detail)/ `f`(fit),相邻字母键 | `Ctrl+]`,再按对应键 | +| 平移 | 方向键或 `i` 上、`j` 左、`k` 下、`l` 右 | `Ctrl+]`,再按对应键 | + +进入本地命令状态时暂停远端输入,并释放已记录按键/鼠标;执行一个命令后恢复原模式,`c` 则切换模式。`Esc` 或 `Ctrl+C` 取消命令并恢复原模式;未知命令显示提示,不发送给远端。命令状态中的鼠标事件被忽略,bracketed paste 内容被丢弃并取消命令,不会触发快捷键。控制模式下普通 `c/q/?`、`Ctrl+T`、`Ctrl+C` 和单独 `Esc` 均传给远端;连续按两次 `Ctrl+]` 可向远端发送一次 `Ctrl+]`。目前不支持重绑定。 + +只读时,`d` 或在画面中单击进入该位置的原始细节模式;`+` / `=` 放大、`-` 缩小(1~32倍),滚轮以鼠标指向为中心调整倍率。方向键或 `i/j/k/l` 分别向上/左/下/右平移,`f` 恢复完整画面。黑边点击被忽略。这些按键和鼠标在控制模式下仍传给远端;使用本地命令前缀可调整一次视图,持续查看时可先切回只读。放大、平移和只读鼠标定位不会向远端发送操作,也适用于静止画面。 支持 SGR 鼠标移动、左右中键、拖动和滚轮;黑边不发送新点击,黑边处松开仍释放旧按钮。输入坐标映射到所选显示器,`--display` 是 HostInformation 列表的零起始索引,视频流为 Display1、Display2…… @@ -100,11 +111,11 @@ export CROSSDESK_CLI="$PWD/build/macosx/arm64/release/crossdesk-cli" pi -e ./apps/cli/plugins/pi/index.ts ``` -在 Pi 输入 `/crossdesk` 或 `/crossdesk 123456789`。先输入 ID,再在自定义遮蔽组件输入密码。扩展使用独立 fd 3 写密码,随后关闭;不写配置、聊天、工具结果或模型上下文。键盘 Ctrl+] 关闭,Ctrl+T 开关控制。插件默认请求 high 预览并显示 ANSI 象限字符行,支持同样的只读细节/放大/平移键,颜色深度依据终端环境回退,由 Pi 自身布局和刷新;不直接占用 TTY,不发送 Kitty 图形或自行打开鼠标报告。 +在 Pi 输入 `/crossdesk` 或 `/crossdesk 123456789`。先输入 ID,再在自定义遮蔽组件输入密码。扩展使用独立 fd 3 写密码,随后关闭;不写配置、聊天、工具结果或模型上下文。查看组件与独立 CLI 使用相同的 `Ctrl+]` 命令前缀和上述模式按键,由 bridge 统一解析。插件默认请求 high 预览并显示 ANSI 象限字符行,支持同样的只读细节/放大/平移键,颜色深度依据终端环境回退,由 Pi 自身布局和刷新;不直接占用 TTY,不发送 Kitty 图形或自行打开鼠标报告。 需要全局发现时,把整个 `apps/cli/plugins/pi` 目录复制到 `~/.pi/agent/extensions/crossdesk/` 后重启 Pi 或执行 `/reload`。卸载时先关闭视图,删除该目录并 `/reload`;使用 `-e` 时从启动参数移除即可。无需修改包中的密码或设备配置。默认连接官方信令;自建信令当前通过独立 view 命令使用,插件尚无服务器设置界面。 -仅在 `ctx.mode === "tui"`、hasUI 且进程双向 TTY 时打开组件。RPC/print/json 返回独立播放器提示,不声称内嵌视频。UI 关闭/会话退出先关闭 bridge stdin,超时 SIGTERM,再超时 SIGKILL。桥接异常会显示在仍可 Ctrl+] 退出的组件内。SDK 类型和模拟 UI 生命周期已验证,真实 Pi TUI 视觉刷新、增强按键与鼠标传递未实机验收。真实设备的 bridge 与独立 view 验证见 [验证记录](validation.md)。 +仅在 `ctx.mode === "tui"`、hasUI 且进程双向 TTY 时打开组件。RPC/print/json 返回独立播放器提示,不声称内嵌视频。UI 关闭/会话退出先关闭 bridge stdin,超时 SIGTERM,再超时 SIGKILL。bridge 尚未就绪或已经停止时,组件可直接用 `q`、`Esc` 或 `Ctrl+C` 关闭。SDK 类型和模拟 UI 生命周期已验证,真实 Pi TUI 视觉刷新、增强按键与鼠标传递未实机验收。真实设备的 bridge 与独立 view 验证见 [验证记录](validation.md)。 官方依据:[扩展文档](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/extensions.md)、[TUI 文档](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/tui.md)、[Kitty 图形协议](https://sw.kovidgoyal.net/kitty/graphics-protocol/)。 @@ -118,7 +129,7 @@ pi -e ./apps/cli/plugins/pi/index.ts - `connection_timeout` / `connection_failed`:检查 NAT/TURN 与被控端。TURN 凭据只取服务端下发值。 - `display_not_available`:换成远端声明的索引;单会话一次只显示一个屏幕。 - `media_initialization_failed`:检查编译依赖和本地媒体运行环境。 -- 只读下按键无动作是预期;远端未声明文本协议时文本输入不可用。 +- 只读下按键不会发送到远端;远端未声明文本协议时文本输入不可用。 - 日志在进程创建的私有临时目录(POSIX `/tmp/crossdesk-cli-*`、Windows `%TEMP%\crossdesk-cli-*`)。view 的摘要在 client.log;底层诊断默认关闭且敏感 Join 日志已修补。目录不保存密码;使用完可删除。错误退出信息在恢复终端后显示。 - `doctor --json` 是环境报告,不会联网登录、主动探测 Kitty 或证明远端可连接。 diff --git a/apps/cli/docs/validation.md b/apps/cli/docs/validation.md index bbddf956..16f67750 100644 --- a/apps/cli/docs/validation.md +++ b/apps/cli/docs/validation.md @@ -22,6 +22,17 @@ 新增回归代码、基准、假传输和验证产物均位于工程外临时目录,未向工程或子模块添加测试。此次完整构建与运行验证覆盖 macOS arm64,未重新进行 Linux/Windows 实机验证。 +## 控制按键调整(2026-10-30) + +- 当前桌面与 CLI 同时启用的 debug 配置下,`xmake build crossdesk-cli`、`xmake run crossdesk-cli doctor --json` 和命令帮助检查通过。 +- 工程外 ASan/UBSan 检查覆盖本地命令前缀、只读快捷键、远端 Ctrl+T/Ctrl+C/Ctrl+]、鼠标释放、取消命令、粘贴隔离、UTF-8/CSI 分片、失焦与退出;原有会话/渲染检查通过。 +- 平移字母键采用 `i` 上、`j` 左、`k` 下、`l` 右;工程外 ASan/UBSan 检查确认四个方向与普通/应用模式方向键一致,控制模式中普通字母仍传给远端。 +- 细节/全景采用相邻的 `d`/`f`;工程外 ASan/UBSan 检查确认细节保留中心、全景复位缩放与中心、前缀命令和鼠标点击行为正确,控制模式中普通字母仍传给远端。 +- 实际二进制 bridge 验证命令状态、错误恢复和退出响应;生产 view 配合假传输的 PTY 检查验证快捷键、模式提示、隐藏密码和终端恢复。实际二进制的 bridge 与 PTY 回归同样通过。 +- Pi 1.4.0 SDK 严格类型检查和模拟组件运行通过,覆盖控制按键转发、命令提示、取消、缩放、退出与 bridge 启动失败后的关闭操作。 + +此次没有连接真实远端设备,也没有进行 Linux/Windows 或真实 Pi TUI 实机验证。新增测试代码与产物均保存在工程外系统临时目录。 + ## 独立开发阶段的验证范围 以下为迁入前的验证记录摘要,不表示本次重新连接了远程设备: diff --git a/apps/cli/main.cpp b/apps/cli/main.cpp index b86c1524..90e27b00 100644 --- a/apps/cli/main.cpp +++ b/apps/cli/main.cpp @@ -247,8 +247,14 @@ int View(SessionOptions options, const std::string &device, s.Tick(); char bytes[8192]; auto n = Read(0, bytes, sizeof(bytes)); + const bool previous_command = input.command_mode; + const bool previous_control = input.control; + const auto previous_error = input.error; input.Feed(n > 0 ? std::string(bytes, n) : "", s, displayed ? &*displayed : nullptr); + if (previous_command != input.command_mode || + previous_control != input.control || previous_error != input.error) + status_tick = Clock::now(); if (output.Pump() && pending_frame) { displayed = std::move(pending); pending.reset(); @@ -282,7 +288,10 @@ int View(SessionOptions options, const std::string &device, auto st = s.Status(); const auto last_state = st["state"].get(); auto line = (input.error.empty() ? last_state : input.error) + " " + - (input.control ? "CONTROL" : "READ ONLY") + " " + + (input.command_mode ? "LOCAL COMMAND" + : input.control ? "CONTROL" + : "READ ONLY") + + " " + (kitty ? "KITTY" : detail == AnsiDetail::Quadrant ? "QUAD" : "HALF") + @@ -291,13 +300,18 @@ int View(SessionOptions options, const std::string &device, (last_frame ? " " + std::to_string(last_frame->width) + "x" + std::to_string(last_frame->height) : "") + - " | Ctrl+] exit Ctrl+T control"; + " | Ctrl+] commands"; + const std::string hint = + input.command_mode ? "c control q quit Esc cancel | i up j left k down " + "l right | +/- zoom f fit d detail" + : input.control + ? "Keys/mouse -> remote | Ctrl+] then c release, q quit" + : "c control q quit ? help | i up j left k down l right | " + "+/- zoom f fit d detail"; output.Put("\033[" + std::to_string(v.rows + 1) + ";1H\033[0m\033[K" + line.substr(0, v.columns) + "\033[" + std::to_string(v.rows + 2) + ";1H\033[K" + - std::string("Read-only: click/1 detail, wheel/+/- zoom, " - "arrows pan, 0 fit | out=" + - std::to_string(submitted) + + std::string(hint + " | out=" + std::to_string(submitted) + " bytes=" + std::to_string(output.bytes)) .substr(0, v.columns)); status_tick = now + std::chrono::seconds(1); @@ -360,6 +374,12 @@ int Bridge(int fd, SessionOptions options, int password_fd) { Output output(fd); ControllerSession s; Input input; + auto status = [&] { + auto value = s.Status(); + value["command_mode"] = input.command_mode; + value["exit_requested"] = input.exit; + return value; + }; Viewport viewport; viewport.detail = AnsiDetail::Quadrant; NonBlocking input_mode(0); @@ -459,6 +479,7 @@ int Bridge(int fd, SessionOptions options, int password_fd) { s.Connect(options, j.at("device").get(), std::move(password)); input.control = false; + input.command_mode = false; input.center_x = input.center_y = .5; last_frame.reset(); redraw = true; @@ -466,6 +487,7 @@ int Bridge(int fd, SessionOptions options, int password_fd) { awaiting = 0; } else if (method == "control") { input.control = Boolean(j, "enabled", false); + input.command_mode = false; s.SetControl(input.control); } else if (method == "input") { auto text = j.at("data").get(); @@ -493,6 +515,8 @@ int Bridge(int fd, SessionOptions options, int password_fd) { awaiting = 0; } else if (method == "disconnect") { s.Disconnect(); + input.control = false; + input.command_mode = false; last_frame.reset(); current.reset(); awaiting = 0; @@ -500,14 +524,13 @@ int Bridge(int fd, SessionOptions options, int password_fd) { quit = true; } else if (method != "status") throw std::invalid_argument("unsupported_method"); - respond({{"id", id}, - {"ok", true}, - {"version", 1}, - {"status", s.Status()}}); + respond( + {{"id", id}, {"ok", true}, {"version", 1}, {"status", status()}}); } catch (const std::exception &) { respond({{"id", id}, {"ok", false}, - {"error", "invalid_request_or_unavailable_operation"}}); + {"error", "invalid_request_or_unavailable_operation"}, + {"status", status()}}); } } } @@ -532,7 +555,7 @@ int Bridge(int fd, SessionOptions options, int password_fd) { break; else if (hello && now >= status_tick) { output.Put(json{{"event", "state"}, - {"status", s.Status()}, + {"status", status()}, {"output_bytes", output.bytes}, {"submitted_frames", submitted_frames}, {"elapsed_seconds", @@ -605,7 +628,11 @@ crossdesk-cli view <9-digit-device-id> [--renderer auto|ansi|kitty] [--fps 1..30 crossdesk-cli bridge --stdio [--password-fd 3] [--server host] [--port 9099] view: read-only and silent by default; hidden password prompt on the local terminal. -Ctrl+] closes; Ctrl+T toggles control. Read-only: click/1 native detail, wheel/+/- zoom, arrows/hjkl pan, 0 fit. +Read-only: c enables control; q or Ctrl+C quits; ? shows local commands. +Ctrl+] opens local commands: c toggles control, q quits, Esc/Ctrl+C cancels. +View keys: click/d native detail, wheel/+/- zoom, arrows/ijkl pan (i up, j left, k down, l right), f fit. +In control mode, Ctrl+] then a view key adjusts the view once; ordinary keys go to the remote. +Ctrl+T and Ctrl+C pass through in control mode; Ctrl+] twice sends remote Ctrl+]. Default ANSI detail: quadrant. Default quality: high (1920x1080); ultra requests up to 3840x2160. Display index is zero-based. ANSI cannot reproduce full desktop text at fit scale. --fps controls local output; negotiated previews use supported 1/8/15 FPS. diff --git a/apps/cli/plugins/pi/index.ts b/apps/cli/plugins/pi/index.ts index 819651c3..9278116d 100644 --- a/apps/cli/plugins/pi/index.ts +++ b/apps/cli/plugins/pi/index.ts @@ -22,7 +22,7 @@ export default function crossdesk(pi: ExtensionAPI) { let stop: (() => void) | undefined; pi.on("session_shutdown", async () => stop?.()); pi.registerCommand("crossdesk", { - description: "Open a human-only remote desktop view (Ctrl+] closes)", + description: "Open a human-only remote desktop view (Ctrl+] for commands)", handler: async (args, ctx) => { if (ctx.mode !== "tui" || !ctx.hasUI || !process.stdin.isTTY || !process.stdout.isTTY) { ctx.ui.notify("CrossDesk embedded view requires Pi interactive TUI. Run crossdesk-cli view in a terminal.", "warning"); @@ -74,11 +74,12 @@ export default function crossdesk(pi: ExtensionAPI) { let columns = 0, rows = 0, request = 0, ready = false, generation = 0; let frame: { frame_id: number; generation: number } | undefined; let helloId = 0, zoom = 1, native = false; + let control = false, commandMode = false, bridgeStopped = false; const colorHint = process.env.COLORTERM || ""; const colors = /^(truecolor|24bit)$/.test(colorHint) ? 24 : process.env.TERM?.includes("256color") ? 256 : 8; const finish = () => { if (closed) return; closed = true; cleanup(); stop = undefined; lines = []; done(); }; stop = finish; - const fail = (reason: string) => { error = clean(reason); cleanup(); tui.requestRender(); }; + const fail = (reason: string) => { bridgeStopped = true; error = clean(reason); cleanup(); tui.requestRender(); }; child = spawn(process.env.CROSSDESK_CLI || "crossdesk-cli", ["bridge", "--stdio", "--password-fd", "3"], { shell: false, stdio: ["pipe", "pipe", "pipe", "pipe"], }); @@ -95,7 +96,7 @@ export default function crossdesk(pi: ExtensionAPI) { secrets.end(password + "\n"); password = undefined; child.stderr?.resume(); // Never insert native diagnostics in the TUI or chat. child.on("error", () => fail("Cannot start crossdesk-cli; check CROSSDESK_CLI or PATH")); - child.on("exit", (code) => { if (!closed) { error ||= `Bridge exited (${code ?? "signal"})`; tui.requestRender(); } }); + child.on("exit", (code) => { if (!closed) { bridgeStopped = true; error ||= `Bridge exited (${code ?? "signal"})`; tui.requestRender(); } }); child.stdout?.setEncoding("utf8"); child.stdout?.on("data", (data: string) => { if (closed) return; @@ -113,8 +114,11 @@ export default function crossdesk(pi: ExtensionAPI) { } if (message.ok === false) error = clean(message.error); if (message.status) { + if (message.status.exit_requested) { clearTimeout(watchdog); finish(); return; } if (Number.isSafeInteger(message.status.generation)) generation = message.status.generation; - status = clean(message.status.state) + (message.status.control ? " CONTROL" : " READ ONLY"); + control = message.status.control === true; + commandMode = message.status.command_mode === true; + status = clean(message.status.state) + (commandMode ? " LOCAL COMMAND" : control ? " CONTROL" : " READ ONLY"); if (message.status.error) error = clean(message.status.error); if (message.status.state !== "connected") lines = []; } @@ -152,14 +156,18 @@ export default function crossdesk(pi: ExtensionAPI) { } const ack = frame; frame = undefined; if (ack) queueMicrotask(() => send("ack", ack)); // Host consumed the frame; not a physical display timestamp. - return [truncateToWidth(`CrossDesk ${device} | ${status} ${native ? "detail" : "fit"} x${zoom} | Ctrl+] close, Ctrl+T control`, width), + const hint = bridgeStopped ? "q / Esc / Ctrl+C close" + : commandMode ? "c control q quit Esc cancel | i up j left k down l right | +/- zoom f fit d detail" + : control ? "Keys -> remote | Ctrl+] then c release, q quit" + : "c control q quit ? help | i up j left k down l right | +/- zoom f fit d detail"; + return [truncateToWidth(`CrossDesk ${device} | ${status} ${native ? "detail" : "fit"} x${zoom} | Ctrl+] commands`, width), ...lines.slice(0, h).map(line => truncateToWidth(line, width)), - truncateToWidth(error || (lines.length ? "Read-only: 1 detail, +/- zoom, arrows/hjkl pan, 0 fit" : "Waiting for remote video…"), width)]; + truncateToWidth(`${hint}${error ? " | " + error : lines.length ? "" : " | Waiting for remote video…"}`, width)]; }, handleInput(data: string) { data = legacyInput(data); if (!data) return; - if (data === "\x1d") { clearTimeout(watchdog); finish(); } + if ((bridgeStopped || !ready) && (data === "q" || data === "\x1b" || data === "\x03")) { clearTimeout(watchdog); finish(); } else if (ready && Buffer.byteLength(data) <= 16384) send("input", { data }); }, invalidate() {}, diff --git a/libs/terminal/include/terminal_renderer.h b/libs/terminal/include/terminal_renderer.h index b51eff5a..9afb9261 100644 --- a/libs/terminal/include/terminal_renderer.h +++ b/libs/terminal/include/terminal_renderer.h @@ -53,11 +53,12 @@ inline constexpr char kKittyDelete[] = "\033_Ga=d,d=I,i=31,q=2\033\\"; bool KittyReply(const std::string &response); // Incremental parser: protocol replies and unknown escapes are consumed // locally. Key taps always release immediately; paste uses the UTF-8 text -// protocol. +// protocol. In command_mode, control records the mode to restore while the +// session's remote input is suspended. class Input { public: void Feed(const std::string &bytes, ControllerSession &, const Grid *grid); - bool exit = false, control = false, native = false; + bool exit = false, control = false, native = false, command_mode = false; std::string error; double zoom = 1, center_x = .5, center_y = .5; uint64_t view_revision = 0; @@ -68,6 +69,7 @@ private: bool pasting_ = false; 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 *); }; } // namespace crossdesk::terminal diff --git a/libs/terminal/src/input.cpp b/libs/terminal/src/input.cpp index 45bdd4be..903509e2 100644 --- a/libs/terminal/src/input.cpp +++ b/libs/terminal/src/input.cpp @@ -10,17 +10,57 @@ void Input::Tap(ControllerSession &s, int key, int modifier) { if (modifier) s.Key(modifier, false); } +bool Input::LocalKey(int key, ControllerSession &s, const Grid *grid) { + if (key == 29) { + if (command_mode) { + command_mode = false; + s.SetControl(control); + if (control) + Tap(s, 0xDD, 17); // VK_OEM_6: right bracket, not ASCII ']'. + } else { + command_mode = true; + // Release held buttons and suspend remote input while choosing a command. + s.SetControl(false); + } + return true; + } + if (control && !command_mode) + return false; + if (key == '?') { + command_mode = true; + s.SetControl(false); + return true; + } + bool command = command_mode; + command_mode = false; + if (key == 'q' || (key == 3 && !command)) { + exit = true; + control = false; + } else if (key == 'c') { + control = !control; + } else if (key == '+' || key == '=' || key == '-' || key == 'f' || + key == 'd' || key == 'i' || key == 'j' || key == 'k' || + key == 'l') { + Navigate(char(key), grid); + } else if (!command) { + return false; + } else if (key != 27 && key != 3) { + error = "Unknown local command; Ctrl+] for help"; + } + s.SetControl(control); + return true; +} void Input::Navigate(char key, const Grid *grid) { if (key == '+' || key == '=') zoom = std::min(32.0, zoom * 2); if (key == '-') zoom = std::max(1.0, zoom / 2); - if (key == '0') { + if (key == 'f') { native = false; zoom = 1; center_x = center_y = .5; } - if (key == '1') { + if (key == 'd') { native = true; zoom = 1; } @@ -31,17 +71,17 @@ void Input::Navigate(char key, const Grid *grid) { const double dy = grid && grid->source_height ? double(grid->source.height) / grid->source_height : 1 / zoom; - if (key == 'h' || key == 'j' || key == 'k' || key == 'l') { + if (key == 'i' || key == 'j' || key == 'k' || key == 'l') { center_x = std::clamp(center_x, dx / 2, 1 - dx / 2); center_y = std::clamp(center_y, dy / 2, 1 - dy / 2); } - if (key == 'h') + if (key == 'j') center_x -= .2 * dx; if (key == 'l') center_x += .2 * dx; - if (key == 'k') + if (key == 'i') center_y -= .2 * dy; - if (key == 'j') + if (key == 'k') center_y += .2 * dy; center_x = std::clamp(center_x, 0.0, 1.0); center_y = std::clamp(center_y, 0.0, 1.0); @@ -49,9 +89,12 @@ void Input::Navigate(char key, const Grid *grid) { } void Input::Feed(const std::string &bytes, ControllerSession &s, const Grid *grid) { + if (exit) + return; if (pending_.size() + bytes.size() > 32768) { pending_.clear(); pasting_ = false; + command_mode = false; s.SetControl(false); control = false; return; @@ -63,7 +106,8 @@ void Input::Feed(const std::string &bytes, ControllerSession &s, if (bytes.empty() && pending_ == "\033" && std::chrono::steady_clock::now() - last_input_ > std::chrono::milliseconds(80)) { - Tap(s, 27); + if (!LocalKey(27, s, grid)) + Tap(s, 27); pending_.clear(); return; } @@ -73,7 +117,11 @@ void Input::Feed(const std::string &bytes, ControllerSession &s, auto end = pending_.find("\033[201~"); if (end == std::string::npos) return; // bounded by 32 KiB; one UTF-8 transaction - if (control && !s.Text(pending_.substr(0, end))) + if (command_mode) { + // Pasted text must never become local commands or leak to the remote. + command_mode = false; + s.SetControl(control); + } else if (control && !s.Text(pending_.substr(0, end))) error = "Remote UTF-8 text input unavailable"; pending_.erase(0, end + 6); pasting_ = false; @@ -81,20 +129,7 @@ void Input::Feed(const std::string &bytes, ControllerSession &s, } unsigned char c = pending_[0]; if (c == 29) { - exit = true; - pending_.erase(0, 1); - s.SetControl(false); - return; - } - if (c == 20) { - control = !control; - s.SetControl(control); - pending_.erase(0, 1); - continue; - } - if (!control && (c == '+' || c == '=' || c == '-' || c == '0' || c == '1' || - c == 'h' || c == 'j' || c == 'k' || c == 'l')) { - Navigate(c, grid); + LocalKey(c, s, grid); pending_.erase(0, 1); continue; } @@ -124,17 +159,18 @@ void Input::Feed(const std::string &bytes, ControllerSession &s, pending_.erase(0, end + 1); if (seq == "\033[O") { control = false; + command_mode = false; s.SetControl(false); continue; } int key = 0; - if (seq == "\033[A") + if (seq == "\033[A" || seq == "\033OA") key = 38; - else if (seq == "\033[B") + else if (seq == "\033[B" || seq == "\033OB") key = 40; - else if (seq == "\033[C") + else if (seq == "\033[C" || seq == "\033OC") key = 39; - else if (seq == "\033[D") + else if (seq == "\033[D" || seq == "\033OD") key = 37; else if (seq == "\033[H") key = 36; @@ -155,15 +191,18 @@ void Input::Feed(const std::string &bytes, ControllerSession &s, else if (seq == "\033OS") key = 115; if (key) { - if (!control && key >= 37 && key <= 40) { - Navigate(key == 37 ? 'h' - : key == 38 ? 'k' + if ((!control || command_mode) && key >= 37 && key <= 40) { + LocalKey(key == 37 ? 'j' + : key == 38 ? 'i' : key == 39 ? 'l' - : 'j', - grid); + : 'k', + s, grid); continue; } - Tap(s, key); + if (command_mode) + LocalKey(0, s, grid); + else + Tap(s, key); continue; } int b = 0, x = 0, y = 0; @@ -185,7 +224,7 @@ void Input::Feed(const std::string &bytes, ControllerSession &s, } else mouse = mouse && cur == limit; } - if (grid && mouse) { + if (grid && mouse && !command_mode) { auto pos = MapPointer(*grid, x - 1, y - 1); if (!control) { if (pos && action == 'M' && ((b & 64) || b == 0)) { @@ -194,7 +233,7 @@ void Input::Feed(const std::string &bytes, ControllerSession &s, if (b & 64) Navigate((b & 1) ? '-' : '+', grid); else - Navigate('1', grid); + Navigate('d', grid); } continue; } @@ -222,14 +261,19 @@ void Input::Feed(const std::string &bytes, ControllerSession &s, continue; } // Alt+ASCII letter: a tap with explicit modifier release. - if (pending_[1] >= 'a' && pending_[1] <= 'z') + if (command_mode) + LocalKey(0, s, grid); + else if (pending_[1] >= 'a' && pending_[1] <= 'z') Tap(s, pending_[1] - 'a' + 'A', 18); pending_.erase(0, 2); continue; } if (c < 32 || c == 127) { pending_.erase(0, 1); - if (c == 13 || c == 10) + if (LocalKey(c, s, grid)) { + if (exit) + return; + } else if (c == 13 || c == 10) Tap(s, 13); else if (c == 9) Tap(s, 9); @@ -248,7 +292,10 @@ void Input::Feed(const std::string &bytes, ControllerSession &s, return; auto text = pending_.substr(0, n); pending_.erase(0, n); - if (control && !s.Text(text)) + if (LocalKey(c, s, grid)) { + if (exit) + return; + } else if (control && !s.Text(text)) error = "Remote UTF-8 text input unavailable"; } }