From 64c7c0ff1ab034bfe4707c489e16aaa0309095f2 Mon Sep 17 00:00:00 2001 From: kuddev Date: Thu, 13 Aug 2026 13:06:49 +0800 Subject: [PATCH] feat(completion,settings,paste): dual completion styles, real color picker, screenshot paste over SSH - completion: switchable inline ghost vs popup candidate list (history / frecency dirs / PATH commands / files, Up/Down + accept key + Esc); settings dropdown, palette action, persisted as completion_style - settings: real HSV picker in the background-color popup (SV plane + hue bar, drag picks live, release persists); presets and hex stay in sync - paste: image-only clipboard pastes a PNG path - local temp file, or SFTP upload to remote /tmp with the path typed back so codex/claude receive usable image paths on both sides - fix: Explorer context-menu launches attach the resident instance and open a cwd tab (tab.new gains optional cwd) instead of a lone fresh window - fix: default-shell dropdown hit-test counts imported profiles, making the trailing rows selectable again - fix: git drawer works on dubious-ownership roots (wsl$ UNC, elevated) via per-invocation safe.directory; failures now logged instead of silent blank - deps: gpui/gpui-component redirected to Kuddev forks pinned at the published base revs (patch playbook in the root manifest) - wire the in-flight resume_ai toggle (display field, toggle, persistence) so the parallel session-restore work builds and tests stay green --- CHANGELOG.md | 14 +- Cargo.lock | 351 +++++++------ Cargo.toml | 39 ++ nebula_app/Cargo.toml | 4 + nebula_app/src/ai_hook.rs | 32 +- nebula_app/src/cli.rs | 52 +- nebula_app/src/clipboard.rs | 165 ++++++ nebula_app/src/config/bell.rs | 12 + nebula_app/src/config/ui_config.rs | 10 + nebula_app/src/display/command_palette.rs | 8 + nebula_app/src/display/content.rs | 36 +- nebula_app/src/display/mod.rs | 610 ++++++++++++++++++++-- nebula_app/src/display/settings.rs | 502 ++++++++++++++++-- nebula_app/src/display/side_panel.rs | 20 +- nebula_app/src/display/state.rs | 88 +++- nebula_app/src/display/terminal_math.rs | 34 +- nebula_app/src/display/ui/icons.rs | 58 +- nebula_app/src/display/ui/keycap.rs | 30 +- nebula_app/src/event.rs | 141 ++++- nebula_app/src/event/input_state.rs | 12 +- nebula_app/src/input/action.rs | 5 + nebula_app/src/input/chrome.rs | 40 +- nebula_app/src/input/keyboard.rs | 44 +- nebula_app/src/input/mod.rs | 20 + nebula_app/src/input/mouse.rs | 62 ++- nebula_app/src/logging.rs | 5 +- nebula_app/src/main.rs | 20 +- nebula_app/src/nebula_history.rs | 40 ++ nebula_app/src/platform/mod.rs | 50 ++ nebula_app/src/runtime_api.rs | 34 +- nebula_app/src/session.rs | 152 +++++- nebula_app/src/ssh_sftp.rs | 41 ++ nebula_app/src/window_context.rs | 118 ++++- nebula_app/src/window_context/runtime.rs | 11 +- nebula_gpui/Cargo.toml | 4 + 35 files changed, 2449 insertions(+), 415 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f4a8da..ae4036a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ Every release entry is provided in English and Simplified Chinese. #### Added +- **Two completion styles with a switch** — Settings → Terminal → Completion adds a "Completion style" choice between the existing inline ghost text and a new popup candidate list. The popup gathers history, frecency directories, PATH commands, and filesystem matches (up to 8, with source tags), navigates with Up/Down, accepts with the configured accept key (Tab/Right), and dismisses with Esc without re-opening until the line changes. A command-palette action toggles the style, and the choice persists as `completion_style`. +- **A real color picker for the custom background** — the background-color popup now leads with a saturation/value plane and a hue bar; dragging picks continuously with live terminal preview and persists on release. The preset swatches and the hex field remain, and all three inputs stay in sync (including hue retention across gray/black/white picks). +- **Clipboard screenshot paste, locally and over SSH** — pasting with an image-only clipboard (e.g. right after Win+Shift+S) now converts the bitmap to PNG and pastes a file path instead of nothing. Local panes write a temp file; SSH panes upload via the existing SFTP stack to `/tmp/nebula-paste-.png` in the background and then type the remote path into the pane — so image-accepting CLIs like codex and claude receive a usable path on both sides (interaction modeled on Netcatty). - **Configurable new-tab placement** — Settings → Interaction can place newly created tabs immediately after the active tab or at the end of the tab list. The choice persists, while restored tabs keep their original ordering semantics. - **Configurable terminal cell width** — Settings → Appearance offers Compact and Relaxed cell-width modes. Changing the mode immediately rebuilds font metrics, the terminal grid, pane layout, and PTY dimensions, and the choice persists across launches. - **Ordered font fallback chains** — `font_family` accepts comma-separated families such as `JetBrains Mono, LXGW WenKai, Cascadia Code`. The first family remains the primary face, later families fill missing glyphs in order for regular, bold, italic, and bold-italic text, and the embedded Maple font remains the final fallback. System and imported/private fonts now use the same family lookup path for validation, preview, and rendering. (#33) @@ -17,7 +20,9 @@ Every release entry is provided in English and Simplified Chinese. #### Fixed -- **Cursor focus and blinking stay in sync across tabs and panes** — cursor rendering now combines the live window focus with the active pane, and switching tabs or panes re-evaluates blinking for the newly focused terminal. Unfocused cursors remain hollow and do not retain a stale blink timer. +- **Explorer context-menu launches join the resident instance** — "Open in Nebula" used to be treated as explicit intent and always started an independent window, so tabs detached in the resident process looked lost. A directory launch without `-e` now attaches the existing instance first (restoring its tabs) and opens the directory as a new tab in that window, brought to the foreground; without a resident instance it still starts standalone. The runtime API's `tab.new` gained an optional `cwd` parameter to carry this. +- **Imported terminals become selectable in the default-shell dropdown** — the dropdown's hit test counted only detected shells while the list also renders imported quick-launch profiles, leaving the trailing rows visible but unclickable. Both sides now share one count. +- **Git panel no longer blanks on repositories owned by another user** — `git status`/`diff` in the drawer run with a per-invocation `safe.directory` exemption, fixing the silently empty Git view on `\\wsl$\…` roots and elevated-owner checkouts where the same commands work in the user's own shell. Failures now leave a debug-log trace instead of a silent blank. - **Visible Windows windows recover from stuck render gates** — startup-time occlusion misreports and missing frame callbacks can no longer leave an already visible window accepting input without repainting. Occlusion is ignored unless the window is actually minimized, and the existing 1 Hz window heartbeat idempotently releases stale `occluded` and `has_frame` gates. (#21) #### Improved @@ -28,6 +33,9 @@ Every release entry is provided in English and Simplified Chinese. #### 新增 +- **两种补全样式可切换** — “设置 → 终端 → 补全”新增「补全样式」:在既有的行内灰字与新的弹窗候选列表之间选择。弹窗汇总历史命令、常用目录、PATH 命令与文件系统匹配(至多 8 项,带来源标签),↑/↓ 选行、按补全接受键(Tab/→)接受、Esc 关闭且同一行不再重弹;命令面板提供切换动作,选择以 `completion_style` 持久化。 +- **自定义背景色改用真调色盘** — 背景色浮层顶部新增饱和度/明度取色面与色相条,按住拖动即连续取色、终端实时预览、松手落盘。预设色板与 16 进制输入保留,三种输入互相同步(灰/黑/白取色时保留既有色相)。 +- **剪贴板截图粘贴(本地与 SSH)** — 剪贴板只有位图没有文本时(如 Win+Shift+S 之后),粘贴会把位图转成 PNG 并粘出文件路径:本地 pane 写入临时文件;SSH pane 经既有 SFTP 栈后台上传到远端 `/tmp/nebula-paste-<时间戳>.png` 再把远端路径敲进会话——codex/claude 这类接受图片路径的 CLI 在两侧都能直接用(交互参考 Netcatty)。 - **可配置新标签页插入位置** — “设置 → 交互”可选择把新建标签页插在当前标签页之后,或放到标签列表末尾。选择会持久化;恢复会话中的标签页仍保持原有顺序语义。 - **可配置终端单元格宽度** — “设置 → 外观”新增“紧凑”和“宽松”两种单元格宽度模式。切换后会立即重算字体度量、终端网格、分屏布局和 PTY 尺寸,并在下次启动时保留选择。 - **有序字体 fallback 链** — `font_family` 支持逗号分隔的字体族,例如 `JetBrains Mono, 霞鹜文楷, Cascadia Code`。第一个字体族仍是主字体,后续字体族按顺序为常规、粗体、斜体和粗斜体补齐缺失字形,内置 Maple 字体始终作为最后兜底。系统字体与导入/私有字体现在统一通过同一族名查找路径完成校验、预览和渲染。(#33) @@ -35,7 +43,9 @@ Every release entry is provided in English and Simplified Chinese. #### 修复 -- **切换标签页和分屏后光标焦点与闪烁保持同步** — 光标渲染现在同时依据实时窗口焦点与活动分屏,切换标签页或分屏时会重新评估新终端的闪烁状态。失焦光标保持空心,也不会残留旧终端的闪烁计时器。 +- **资源管理器右键打开并入驻留实例** — 「在 Nebula 中打开」此前被当作显式意图而总是启动独立窗口,驻留进程里 detached 的标签看起来就“丢了”。带目录、无 `-e` 命令的启动现在会先 ATTACH 既有实例(找回原有标签),再在该窗口新开定目录标签并置前;没有驻留实例时仍独立启动。runtime API 的 `tab.new` 为此新增可选 `cwd` 参数。 +- **导入的终端在默认 Shell 下拉中可以选中** — 下拉的命中测试此前只统计检测到的 shell,而列表还渲染了导入的快速启动配置,末尾几行看得见点不中。绘制与命中现在共用同一计数。 +- **Git 面板在他人所有的仓库上不再空白** — 抽屉里的 `git status`/`diff` 以单次调用范围的 `safe.directory` 豁免运行,修复 `\\wsl$\…` 根目录与提升权限检出下“自己 shell 里 git status 正常、面板却空白”的问题;失败现在会留下调试日志而不是无声空白。 - **Windows 可见窗口可从卡死的渲染门控中恢复** — 启动期的遮挡误报或帧回调丢失不再让可见窗口陷入“能接收输入但不重绘”。窗口没有真正最小化时会忽略遮挡误报,既有的 1 Hz 窗口心跳也会以幂等方式释放卡住的 `occluded` 与 `has_frame` 门控。(#21) #### 改进 diff --git a/Cargo.lock b/Cargo.lock index 025e819..935cf52 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1247,6 +1247,15 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "collections" +version = "0.1.0" +source = "git+https://github.com/Kuddev/zed?rev=69e2130295c2649963eb639fc70b4f2ee8ea1624#69e2130295c2649963eb639fc70b4f2ee8ea1624" +dependencies = [ + "indexmap", + "rustc-hash 2.1.3", +] + [[package]] name = "color_quant" version = "1.1.0" @@ -1837,6 +1846,16 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "derive_refineable" +version = "0.1.0" +source = "git+https://github.com/Kuddev/zed?rev=69e2130295c2649963eb639fc70b4f2ee8ea1624#69e2130295c2649963eb639fc70b4f2ee8ea1624" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "des" version = "0.9.0" @@ -2976,8 +2995,7 @@ dependencies = [ [[package]] name = "gpui" version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "979b45cfa6ec723b6f42330915a1b3769b930d02b2d505f9697f8ca602bee707" +source = "git+https://github.com/Kuddev/zed?rev=69e2130295c2649963eb639fc70b4f2ee8ea1624#69e2130295c2649963eb639fc70b4f2ee8ea1624" dependencies = [ "anyhow", "as-raw-xcb-connection", @@ -2994,6 +3012,7 @@ dependencies = [ "cbindgen", "cocoa 0.26.0", "cocoa-foundation 0.2.0", + "collections", "core-foundation 0.10.0", "core-foundation-sys", "core-graphics 0.24.0", @@ -3008,21 +3027,15 @@ dependencies = [ "flume", "foreign-types", "futures", - "gpui-macros", - "gpui_collections", - "gpui_http_client", - "gpui_media", - "gpui_refineable", - "gpui_semantic_version", - "gpui_sum_tree", - "gpui_util", - "gpui_util_macros", + "gpui_macros", + "http_client", "image", "inventory", "itertools 0.14.0", "libc", "log", "lyon", + "media", "metal 0.29.0", "naga", "num_cpus", @@ -3037,9 +3050,11 @@ dependencies = [ "profiling", "rand 0.9.5", "raw-window-handle", + "refineable", "resvg", "schemars", "seahash", + "semantic_version", "serde", "serde_json", "slotmap", @@ -3047,9 +3062,12 @@ dependencies = [ "smol", "stacksafe", "strum 0.27.2", + "sum_tree", "taffy", "thiserror 2.0.18", "usvg", + "util", + "util_macros", "uuid", "waker-fn", "wayland-backend", @@ -3072,8 +3090,7 @@ dependencies = [ [[package]] name = "gpui-component" version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d021d46b4088d3d93a57ccdf443da85695a77272108caca2f6fe5369f584966a" +source = "git+https://github.com/Kuddev/gpui-component?rev=0f0ab35233212f8f3277028995caf0c41e13ee6c#0f0ab35233212f8f3277028995caf0c41e13ee6c" dependencies = [ "aho-corasick", "anyhow", @@ -3112,8 +3129,7 @@ dependencies = [ [[package]] name = "gpui-component-assets" version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afc6e4c6551a1a12d4e8b69c3e8eba3cef43331c8c87898a0d4d040c78c6865e" +source = "git+https://github.com/Kuddev/gpui-component?rev=0f0ab35233212f8f3277028995caf0c41e13ee6c#0f0ab35233212f8f3277028995caf0c41e13ee6c" dependencies = [ "anyhow", "gpui", @@ -3123,8 +3139,7 @@ dependencies = [ [[package]] name = "gpui-component-macros" version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86fbc2d84bf91717b171320e6adc600d91ccb3ed259448f3b006787633c1c615" +source = "git+https://github.com/Kuddev/gpui-component?rev=0f0ab35233212f8f3277028995caf0c41e13ee6c#0f0ab35233212f8f3277028995caf0c41e13ee6c" dependencies = [ "proc-macro2", "quote", @@ -3144,156 +3159,16 @@ dependencies = [ ] [[package]] -name = "gpui_collections" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae39dc6d3d201be97e4bc08d96dbef2bc5b5c3d5734e05786e8cc3043342351c" -dependencies = [ - "indexmap", - "rustc-hash 2.1.3", -] - -[[package]] -name = "gpui_derive_refineable" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "644de174341a87b3478bd65b66bca38af868bcf2b2e865700523734f83cfc664" +name = "gpui_macros" +version = "0.1.0" +source = "git+https://github.com/Kuddev/zed?rev=69e2130295c2649963eb639fc70b4f2ee8ea1624#69e2130295c2649963eb639fc70b4f2ee8ea1624" dependencies = [ + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.106", ] -[[package]] -name = "gpui_http_client" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23822b0a6d2c5e6a42507980a0ab3848610ea908942c8ef98187f646f690335e" -dependencies = [ - "anyhow", - "async-compression", - "async-fs", - "bytes", - "derive_more", - "futures", - "gpui_util", - "http", - "http-body", - "log", - "parking_lot", - "serde", - "serde_json", - "sha2 0.10.9", - "tempfile", - "url", - "zed-async-tar", - "zed-reqwest", -] - -[[package]] -name = "gpui_media" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05cb8912ae17371725132d2b7eec6797a255accc95d58ee5c1134b529810f14b" -dependencies = [ - "anyhow", - "bindgen", - "core-foundation 0.10.0", - "core-video", - "ctor", - "foreign-types", - "metal 0.29.0", - "objc", -] - -[[package]] -name = "gpui_perf" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40a0961dcf598955130e867f4b731150a20546427b41b1a63767c1037a86d77" -dependencies = [ - "gpui_collections", - "serde", - "serde_json", -] - -[[package]] -name = "gpui_refineable" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "258cb099254e9468181aee5614410fba61db4ae115fc1d51b4a0b985f60d6641" -dependencies = [ - "gpui_derive_refineable", -] - -[[package]] -name = "gpui_semantic_version" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "201e45eff7b695528fb3af6560a534943fbc2db5323d755b9d198bd743948e35" -dependencies = [ - "anyhow", - "serde", -] - -[[package]] -name = "gpui_sum_tree" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4f3bedd573fafafa13d1200b356c588cf094fb2786e3684bb3f5ea59b549fa9" -dependencies = [ - "arrayvec", - "log", - "rayon", -] - -[[package]] -name = "gpui_util" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68faea25903ae524de9af83990b9aa51bcbc8dd085929ac0aea7fd41905e05c3" -dependencies = [ - "anyhow", - "async-fs", - "async_zip", - "command-fds", - "dirs 4.0.0", - "dunce", - "futures", - "futures-lite 1.13.0", - "globset", - "gpui_collections", - "itertools 0.14.0", - "libc", - "log", - "nix 0.29.0", - "regex", - "rust-embed", - "schemars", - "serde", - "serde_json", - "serde_json_lenient", - "shlex", - "smol", - "take-until", - "tempfile", - "tendril", - "unicase", - "walkdir", - "which 6.0.3", -] - -[[package]] -name = "gpui_util_macros" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c28f65ef47fb97e21e82fd4dd75ccc2506eda010c846dc8054015ea234f1a22" -dependencies = [ - "gpui_perf", - "quote", - "syn 2.0.106", -] - [[package]] name = "grid" version = "0.18.0" @@ -3508,6 +3383,31 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http_client" +version = "0.1.0" +source = "git+https://github.com/Kuddev/zed?rev=69e2130295c2649963eb639fc70b4f2ee8ea1624#69e2130295c2649963eb639fc70b4f2ee8ea1624" +dependencies = [ + "anyhow", + "async-compression", + "async-fs", + "bytes", + "derive_more", + "futures", + "http", + "http-body", + "log", + "parking_lot", + "serde", + "serde_json", + "sha2 0.10.9", + "tempfile", + "url", + "util", + "zed-async-tar", + "zed-reqwest", +] + [[package]] name = "httparse" version = "1.10.1" @@ -4383,6 +4283,21 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c" +[[package]] +name = "media" +version = "0.1.0" +source = "git+https://github.com/Kuddev/zed?rev=69e2130295c2649963eb639fc70b4f2ee8ea1624#69e2130295c2649963eb639fc70b4f2ee8ea1624" +dependencies = [ + "anyhow", + "bindgen", + "core-foundation 0.10.0", + "core-video", + "ctor", + "foreign-types", + "metal 0.29.0", + "objc", +] + [[package]] name = "memchr" version = "2.7.6" @@ -4725,11 +4640,20 @@ dependencies = [ "gpui", "gpui-component", "gpui-component-assets", + "nebula-settings", "nebula_terminal", "serde", "toml 0.9.11+spec-1.1.0", ] +[[package]] +name = "nebula-settings" +version = "1.0.0" + +[[package]] +name = "nebula-split" +version = "1.0.0" + [[package]] name = "nebula_config" version = "1.0.0" @@ -5689,6 +5613,16 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "perf" +version = "0.1.0" +source = "git+https://github.com/Kuddev/zed?rev=69e2130295c2649963eb639fc70b4f2ee8ea1624#69e2130295c2649963eb639fc70b4f2ee8ea1624" +dependencies = [ + "collections", + "serde", + "serde_json", +] + [[package]] name = "phc" version = "0.6.1" @@ -6509,6 +6443,14 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "refineable" +version = "0.1.0" +source = "git+https://github.com/Kuddev/zed?rev=69e2130295c2649963eb639fc70b4f2ee8ea1624#69e2130295c2649963eb639fc70b4f2ee8ea1624" +dependencies = [ + "derive_refineable", +] + [[package]] name = "regex" version = "1.12.3" @@ -7183,6 +7125,15 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" +[[package]] +name = "semantic_version" +version = "0.1.0" +source = "git+https://github.com/Kuddev/zed?rev=69e2130295c2649963eb639fc70b4f2ee8ea1624#69e2130295c2649963eb639fc70b4f2ee8ea1624" +dependencies = [ + "anyhow", + "serde", +] + [[package]] name = "semver" version = "1.0.27" @@ -7821,6 +7772,16 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "sum_tree" +version = "0.1.0" +source = "git+https://github.com/Kuddev/zed?rev=69e2130295c2649963eb639fc70b4f2ee8ea1624#69e2130295c2649963eb639fc70b4f2ee8ea1624" +dependencies = [ + "arrayvec", + "log", + "rayon", +] + [[package]] name = "sval" version = "2.21.1" @@ -8761,6 +8722,51 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "util" +version = "0.1.0" +source = "git+https://github.com/Kuddev/zed?rev=69e2130295c2649963eb639fc70b4f2ee8ea1624#69e2130295c2649963eb639fc70b4f2ee8ea1624" +dependencies = [ + "anyhow", + "async-fs", + "async_zip", + "collections", + "command-fds", + "dirs 4.0.0", + "dunce", + "futures", + "futures-lite 1.13.0", + "globset", + "itertools 0.14.0", + "libc", + "log", + "nix 0.29.0", + "regex", + "rust-embed", + "schemars", + "serde", + "serde_json", + "serde_json_lenient", + "shlex", + "smol", + "take-until", + "tempfile", + "tendril", + "unicase", + "walkdir", + "which 6.0.3", +] + +[[package]] +name = "util_macros" +version = "0.1.0" +source = "git+https://github.com/Kuddev/zed?rev=69e2130295c2649963eb639fc70b4f2ee8ea1624#69e2130295c2649963eb639fc70b4f2ee8ea1624" +dependencies = [ + "perf", + "quote", + "syn 2.0.106", +] + [[package]] name = "uuid" version = "1.24.0" @@ -10223,17 +10229,15 @@ checksum = "2fb433233f2df9344722454bc7e96465c9d03bff9d77c248f9e7523fe79585b5" [[package]] name = "xim-ctext" version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ac61a7062c40f3c37b6e82eeeef835d5cc7824b632a72784a89b3963c33284c" +source = "git+https://github.com/zed-industries/xim-rs.git?rev=16f35a2c881b815a2b6cdfd6687988e84f8447d8#16f35a2c881b815a2b6cdfd6687988e84f8447d8" dependencies = [ "encoding_rs", ] [[package]] name = "xim-parser" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dcee45f89572d5a65180af3a84e7ddb24f5ea690a6d3aa9de231281544dd7b7" +version = "0.2.1" +source = "git+https://github.com/zed-industries/xim-rs.git?rev=16f35a2c881b815a2b6cdfd6687988e84f8447d8#16f35a2c881b815a2b6cdfd6687988e84f8447d8" dependencies = [ "bitflags 2.13.0", ] @@ -10411,8 +10415,7 @@ dependencies = [ [[package]] name = "zed-async-tar" version = "0.5.0-zed" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cf4b5f655e29700e473cb1acd914ab112b37b62f96f7e642d5fc6a0c02eb881" +source = "git+https://github.com/zed-industries/async-tar?rev=a307f6bf3e4219c3a457bea0cab198b6d7c36e25#a307f6bf3e4219c3a457bea0cab198b6d7c36e25" dependencies = [ "async-std", "filetime", @@ -10425,8 +10428,7 @@ dependencies = [ [[package]] name = "zed-font-kit" version = "0.14.1-zed" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3898e450f36f852edda72e3f985c34426042c4951790b23b107f93394f9bff5" +source = "git+https://github.com/zed-industries/font-kit?rev=110523127440aefb11ce0cf280ae7c5071337ec5#110523127440aefb11ce0cf280ae7c5071337ec5" dependencies = [ "bitflags 2.13.0", "byteorder", @@ -10450,8 +10452,7 @@ dependencies = [ [[package]] name = "zed-reqwest" version = "0.12.15-zed" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac2d05756ff48539950c3282ad7acf3817ad3f08797c205ad1c34a2ce03b9970" +source = "git+https://github.com/zed-industries/reqwest.git?rev=c15662463bda39148ba154100dd44d3fba5873a4#c15662463bda39148ba154100dd44d3fba5873a4" dependencies = [ "base64", "bytes", @@ -10500,8 +10501,7 @@ dependencies = [ [[package]] name = "zed-scap" version = "0.0.8-zed" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6b338d705ae33a43ca00287c11129303a7a0aa57b101b72a1c08c863f698ac8" +source = "git+https://github.com/zed-industries/scap?rev=4afea48c3b002197176fb19cd0f9b180dd36eaac#4afea48c3b002197176fb19cd0f9b180dd36eaac" dependencies = [ "anyhow", "cocoa 0.25.0", @@ -10534,8 +10534,7 @@ dependencies = [ [[package]] name = "zed-xim" version = "0.4.0-zed" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0b46ed118eba34d9ba53d94ddc0b665e0e06a2cf874cfa2dd5dec278148642" +source = "git+https://github.com/zed-industries/xim-rs.git?rev=16f35a2c881b815a2b6cdfd6687988e84f8447d8#16f35a2c881b815a2b6cdfd6687988e84f8447d8" dependencies = [ "ahash", "hashbrown 0.14.5", diff --git a/Cargo.toml b/Cargo.toml index 6766790..c7a91ef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "nebula_hook", "nebula_gpui", "nebula_settings", + "nebula_split", ] resolver = "2" @@ -34,3 +35,41 @@ x11-clipboard = { git = "https://github.com/quininer/x11-clipboard.git", rev = " # 488c036a's Windows 11 mixed-DPI positioning fix. The upstream revision has # since moved to a different EventLoop API and cannot be used directly here. winit = { path = "third_party/winit-0.30.13" } + +# ---- gpui / gpui-component fork(C 路线,nebula_gpui)---------------------- +# +# 版本配对(发布线):gpui-component 0.5.1(含 -assets / -macros)编译在 +# crates.io 的 gpui 0.2.2 上——longbridge 仓库 v0.5.1 的 workspace 依赖就是 +# `gpui = "0.2.2"`,不是 zed git。nebula_gpui/Cargo.toml 用 "=0.2.2" / +# "=0.5.1" 精确 pin;[patch] 要求版本匹配,fork 上的版本号保持不动。 +# +# 下面两个 fork 的 `nebula` 分支基于 crates.io 发布基点(读自 registry 包内 +# 的 .cargo_vcs_info.json,包名/版本已在基点 rev 上核对过),当前补丁栈为空, +# 构建产物应与 crates.io 版本一致: +# Kuddev/zed nebula @ 69e2130295c2649963eb639fc70b4f2ee8ea1624 +# (= zed-industries/zed 发布 gpui 0.2.2 的 rev, +# crates/gpui;上游发布时 dirty=true,若首次编译出现 +# 与 registry src 的差异以 diff 为准补一个对齐 commit) +# Kuddev/gpui-component nebula @ 0f0ab35233212f8f3277028995caf0c41e13ee6c +# (= longbridge/gpui-component v0.5.1,crates/ui; +# 上级目录 gpui-component-v0.5.1/ 有源码检出可对照) +# +# 加补丁 = 提交到 fork 的 nebula 分支 → push → 更新下面的 rev(永远 pin +# exact rev、不 pin branch)。每个补丁注明原因 + upstream PR 链接,上游合并 +# 即撤;升级配对版本 = rebase nebula 分支到新发布基点(参照 ../tty7 的 +# Cargo.toml 注释风格与守护测试做法)。gpui 的仓内兄弟 crate(gpui_macros、 +# sum_tree、zed-reqwest 等)经 git 仓库内 path 依赖自动解析,无需逐个 patch。 +# +# 升级基点到 gpui-component 0.5.2+ 时的回归检查单(2026-08-13 对照 +# l0ng-ai/gpui-component 的 tty7 分支得出;0.5.1 上这些行为本来是对的, +# 是 0.5.2 重构 button 颜色推导时弄丢的,届时把 tty7 的两个 fix 带上): +# 1. ButtonVariant::Custom 的 text_color 必须回 colors.foreground(0.5.2 +# 回归成背景色 colors.color,透明底按钮字直接隐形)→ tty7 3826fafacd3c +# 2. hovered()/active() 必须读 ButtonCustomVariant::hover/active(0.5.2 +# 回归成从 color 派生,透明底按钮没有任何悬停/按下反馈)→ 469ab7a44f72 +# 3. 新增的 text/inline_flow.rs:shape_line 前把 \n\r 换成空格(gpui +# shaper 遇换行 panic;替换保 byte 长度,run 偏移不失效) +# 本地 fork 工作拷贝在 ../gpui-component-fork(已挂 tty7 remote 可 diff)。 +gpui = { git = "https://github.com/Kuddev/zed", rev = "69e2130295c2649963eb639fc70b4f2ee8ea1624" } +gpui-component = { git = "https://github.com/Kuddev/gpui-component", rev = "0f0ab35233212f8f3277028995caf0c41e13ee6c" } +gpui-component-assets = { git = "https://github.com/Kuddev/gpui-component", rev = "0f0ab35233212f8f3277028995caf0c41e13ee6c" } diff --git a/nebula_app/Cargo.toml b/nebula_app/Cargo.toml index f08b972..de8b372 100644 --- a/nebula_app/Cargo.toml +++ b/nebula_app/Cargo.toml @@ -160,6 +160,10 @@ windows-sys = { version = "0.59", features = [ # gate ConnectNamedPipe/ReadFile/CreateNamedPipeW's OVERLAPPED/DACL params. "Win32_System_Pipes", "Win32_Storage_FileSystem", + # 剪贴板截图粘贴(clipboard::clipboard_image_png):OpenClipboard/ + # GetClipboardData(CF_DIB) 与 GlobalLock/GlobalSize。 + "Win32_System_DataExchange", + "Win32_System_Memory", "Win32_System_IO", "Win32_Security", "Win32_Security_Credentials", diff --git a/nebula_app/src/ai_hook.rs b/nebula_app/src/ai_hook.rs index 90cbc0d..312bf44 100644 --- a/nebula_app/src/ai_hook.rs +++ b/nebula_app/src/ai_hook.rs @@ -98,6 +98,10 @@ pub struct AiHookEvent { /// Human text when the event carries one (claude's notification message, /// codex's last assistant message). pub message: Option, + /// CLI 自己的会话身份:claude hook 载荷的 `session_id`、codex notify 的 + /// `thread-id`(即 rollout 文件名尾部的 uuid,`codex resume` 认它)。 + /// 冷恢复接续对话的唯一事实源——文件系统扫描只能靠 mtime 猜。 + pub session_id: Option, } /// Parse one pipe message: a `nebula-hook/1 source= pane=` header line, @@ -123,6 +127,14 @@ fn parse_envelope(bytes: &[u8]) -> Option { let source = source?; let payload: Value = serde_json::from_slice(raw).unwrap_or(Value::Null); + // 会话身份随事件顺带捕获:claude 每个 hook 载荷都带 `session_id` + // (snake_case);codex notify 带 `thread-id`(kebab-case,即 rollout + // uuid)。opencode/pi 的桥接载荷不带 id,读不到就是 None。 + let session_id = payload + .get("session_id") + .or_else(|| payload.get("thread-id")) + .and_then(Value::as_str) + .map(str::to_owned); let (kind, message) = match source.as_str() { "claude" => match payload.get("hook_event_name").and_then(Value::as_str) { Some("UserPromptSubmit") => (AiHookKind::PromptSubmit, None), @@ -159,7 +171,7 @@ fn parse_envelope(bytes: &[u8]) -> Option { }, _ => return None, }; - Some(AiHookEvent { pane, source, kind, message }) + Some(AiHookEvent { pane, source, kind, message, session_id }) } /// 远端会话只能提交事件语义,Pane 身份始终由本地 SSH 通道覆盖, @@ -236,6 +248,24 @@ mod remote_tests { assert_eq!(event.message.as_deref(), Some("done")); } + #[test] + fn claude_events_carry_their_session_id_for_cold_resume() { + let raw = b"nebula-hook/1 source=claude pane=3\n{\"session_id\":\"0199a213-c2a4-7cf5-8f6b-d746fbb6e86c\",\"hook_event_name\":\"Stop\"}"; + let event = parse_remote_envelope(raw, Some(3)).unwrap(); + assert_eq!(event.session_id.as_deref(), Some("0199a213-c2a4-7cf5-8f6b-d746fbb6e86c")); + } + + #[test] + fn codex_thread_id_is_the_session_identity() { + // codex notify 的 `thread-id` 就是 rollout uuid,`codex resume` 认它。 + let raw = b"nebula-hook/1 source=codex pane=3\n{\"type\":\"agent-turn-complete\",\"thread-id\":\"b5f6c1c2-1111-2222-3333-444455556666\"}"; + let event = parse_remote_envelope(raw, Some(3)).unwrap(); + assert_eq!(event.session_id.as_deref(), Some("b5f6c1c2-1111-2222-3333-444455556666")); + // 桥接载荷没有 id 的来源读出 None,不许瞎编。 + let raw = b"nebula-hook/1 source=opencode pane=3\n{\"kind\":\"done\"}"; + assert_eq!(parse_remote_envelope(raw, Some(3)).unwrap().session_id, None); + } + #[test] fn pi_extension_events_use_the_normalized_lifecycle_shape() { let prompt = parse_remote_envelope( diff --git a/nebula_app/src/cli.rs b/nebula_app/src/cli.rs index cc67a25..20b8b35 100644 --- a/nebula_app/src/cli.rs +++ b/nebula_app/src/cli.rs @@ -168,6 +168,26 @@ pub struct TerminalOptions { command: Vec, } +/// Undo the trailing-backslash mangling Explorer's context-menu quoting +/// inflicts on drive-root working directories. +/// +/// The installed verb passes `--working-directory "%V"`; for a drive root +/// `%V` is `D:\`, so the command line ends in `\"` and `CommandLineToArgvW` +/// reads that trailing backslash as an escaped quote — Nebula receives `D:"` +/// and rejects it as an invalid directory (issue #36). A double quote can +/// never appear in a Windows path, so a trailing one is unambiguously that +/// swallowed separator: restore it. Any other path is returned unchanged, and +/// off Windows the input is untouched (a quote is a legal Unix filename byte). +pub(crate) fn repair_context_menu_dir(dir: PathBuf) -> PathBuf { + #[cfg(windows)] + { + if let Some(stripped) = dir.to_string_lossy().strip_suffix('"') { + return PathBuf::from(format!("{stripped}\\")); + } + } + dir +} + impl TerminalOptions { /// Shell override passed through the CLI. pub fn command(&self) -> Option { @@ -175,11 +195,18 @@ impl TerminalOptions { Some(Program::WithArgs { program: program.clone(), args: args.to_vec() }) } + /// Working directory with Explorer's context-menu quote-mangling undone. + /// Every shell-launching path resolves the directory through here so the + /// repair applies whether the options came from the CLI or over IPC. + pub fn resolved_working_directory(&self) -> Option { + self.working_directory.clone().map(repair_context_menu_dir) + } + /// Override the [`PtyOptions`]'s fields with the [`TerminalOptions`]. pub fn override_pty_config(&self, pty_config: &mut PtyOptions) { - if let Some(working_directory) = &self.working_directory { + if let Some(working_directory) = self.resolved_working_directory() { if working_directory.is_dir() { - pty_config.working_directory = Some(working_directory.to_owned()); + pty_config.working_directory = Some(working_directory); } else { error!("Invalid working directory: {working_directory:?}"); } @@ -195,8 +222,10 @@ impl TerminalOptions { impl From for PtyOptions { fn from(mut options: TerminalOptions) -> Self { + let working_directory = options.resolved_working_directory(); + options.working_directory = None; PtyOptions { - working_directory: options.working_directory.take(), + working_directory, shell: options.command().map(Into::into), drain_on_exit: options.hold, env: HashMap::new(), @@ -732,6 +761,23 @@ mod tests { assert!(class.is_err()); } + #[cfg(windows)] + #[test] + fn repairs_drive_root_mangled_by_explorer_quoting() { + // `--working-directory "D:\"` reaches us as `D:"` because the trailing + // backslash escaped the closing quote (issue #36); restore the root. + assert_eq!(repair_context_menu_dir(PathBuf::from("D:\"")), PathBuf::from("D:\\")); + assert_eq!( + repair_context_menu_dir(PathBuf::from("\\\\server\\share\"")), + PathBuf::from("\\\\server\\share\\") + ); + // A normal folder (no trailing backslash, so no mangling) is untouched. + assert_eq!( + repair_context_menu_dir(PathBuf::from("D:\\temp_build")), + PathBuf::from("D:\\temp_build") + ); + } + #[test] fn valid_decimal() { let value = parse_hex_or_decimal("10485773"); diff --git a/nebula_app/src/clipboard.rs b/nebula_app/src/clipboard.rs index 88daf94..f7662bc 100644 --- a/nebula_app/src/clipboard.rs +++ b/nebula_app/src/clipboard.rs @@ -98,3 +98,168 @@ impl Clipboard { } } } + +/// 剪贴板里的截图(无文本、只有位图,Win+Shift+S 的产物)转成 PNG 字节。 +/// 图片粘贴回退用:本地 pane 落成临时文件粘路径,SSH pane 经 SFTP 上传。 +/// 只认 CF_DIB 的 24/32bpp BI_RGB / BI_BITFIELDS——Windows 截图与主流应用 +/// 复制出来的就是这两种;其余罕见编码返回 `None`,粘贴回退安静放弃。 +#[cfg(windows)] +pub fn clipboard_image_png() -> Option> { + use windows_sys::Win32::Foundation::HANDLE; + use windows_sys::Win32::System::DataExchange::{ + CloseClipboard, GetClipboardData, IsClipboardFormatAvailable, OpenClipboard, + }; + use windows_sys::Win32::System::Memory::{GlobalLock, GlobalSize, GlobalUnlock}; + + const CF_DIB: u32 = 8; + + // OpenClipboard 与剪贴板监听者(IME、Win+V、管理器)竞争,参照 + // `Clipboard::store` 的短退避重试。 + let mut opened = false; + for delay_ms in [0u64, 15, 40] { + if delay_ms > 0 { + std::thread::sleep(std::time::Duration::from_millis(delay_ms)); + } + if unsafe { OpenClipboard(std::ptr::null_mut()) } != 0 { + opened = true; + break; + } + } + if !opened { + return None; + } + + // 从这里起任何路径都必须 CloseClipboard:拿数据后立即复制到自有缓冲。 + let dib = unsafe { + if IsClipboardFormatAvailable(CF_DIB) == 0 { + CloseClipboard(); + return None; + } + let handle: HANDLE = GetClipboardData(CF_DIB); + if handle.is_null() { + CloseClipboard(); + return None; + } + let size = GlobalSize(handle); + let ptr = GlobalLock(handle) as *const u8; + if ptr.is_null() || size == 0 { + GlobalUnlock(handle); + CloseClipboard(); + return None; + } + let bytes = std::slice::from_raw_parts(ptr, size).to_vec(); + GlobalUnlock(handle); + CloseClipboard(); + bytes + }; + + dib_to_png(&dib) +} + +#[cfg(not(windows))] +pub fn clipboard_image_png() -> Option> { + None +} + +/// CF_DIB(BITMAPINFOHEADER + 裸像素)→ PNG。独立成纯函数以便测试。 +#[cfg(windows)] +fn dib_to_png(dib: &[u8]) -> Option> { + use image::codecs::png::PngEncoder; + use image::{ExtendedColorType, ImageEncoder}; + + let u32_at = |offset: usize| -> Option { + dib.get(offset..offset + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])) + }; + let header_size = u32_at(0)? as usize; + if header_size < 40 || dib.len() < header_size { + return None; + } + let width = u32_at(4)? as i32; + let raw_height = u32_at(8)? as i32; + let bit_count = dib.get(14..16).map(|b| u16::from_le_bytes([b[0], b[1]]))? as usize; + let compression = u32_at(16)?; + + // 0 = BI_RGB;3 = BI_BITFIELDS(截图常见,BGRA 顺序的默认掩码)。老式 + // BITMAPINFOHEADER(40) 的 BITFIELDS 掩码在头之后另占 12 字节;V4/V5 头 + // 已把掩码含在 header_size 里。 + if !(compression == 0 || compression == 3) || !(bit_count == 24 || bit_count == 32) { + return None; + } + let masks = if compression == 3 && header_size == 40 { 12 } else { 0 }; + let data_offset = header_size + masks; + + let top_down = raw_height < 0; + let width_px = usize::try_from(width).ok().filter(|w| *w > 0)?; + let height_px = usize::try_from(raw_height.abs()).ok().filter(|h| *h > 0)?; + // DIB 行按 4 字节对齐。 + let stride = (width_px * bit_count).div_ceil(32) * 4; + let pixels = dib.get(data_offset..data_offset + stride * height_px)?; + + let mut rgba = Vec::with_capacity(width_px * height_px * 4); + for row in 0..height_px { + let source_row = if top_down { row } else { height_px - 1 - row }; + let line = &pixels[source_row * stride..source_row * stride + stride]; + for x in 0..width_px { + let (b, g, r, a) = if bit_count == 32 { + let p = &line[x * 4..x * 4 + 4]; + // 图标以外的 32bpp DIB 常把 alpha 通道整个写 0(截图即是); + // 全 0 当不透明处理,否则粘出来是一张全透明图。 + (p[0], p[1], p[2], p[3]) + } else { + let p = &line[x * 3..x * 3 + 3]; + (p[0], p[1], p[2], 255) + }; + rgba.extend_from_slice(&[r, g, b, a]); + } + } + if bit_count == 32 && rgba.iter().skip(3).step_by(4).all(|a| *a == 0) { + for alpha in rgba.iter_mut().skip(3).step_by(4) { + *alpha = 255; + } + } + + let mut png = Vec::new(); + PngEncoder::new(&mut png) + .write_image(&rgba, width_px as u32, height_px as u32, ExtendedColorType::Rgba8) + .ok()?; + Some(png) +} + +#[cfg(all(test, windows))] +mod tests { + use super::dib_to_png; + + /// 手工构造一张 2×2 的 32bpp bottom-up DIB,验证行序翻转与 BGRA→RGBA。 + #[test] + fn dib_round_trips_bottom_up_bgra() { + let mut dib = Vec::new(); + dib.extend_from_slice(&40u32.to_le_bytes()); // biSize + dib.extend_from_slice(&2i32.to_le_bytes()); // width + dib.extend_from_slice(&2i32.to_le_bytes()); // height (bottom-up) + dib.extend_from_slice(&1u16.to_le_bytes()); // planes + dib.extend_from_slice(&32u16.to_le_bytes()); // bit count + dib.extend_from_slice(&0u32.to_le_bytes()); // BI_RGB + dib.extend_from_slice(&[0u8; 20]); // 剩余头字段 + // 像素(bottom-up:文件里第一行是图像的最后一行)。BGRA。 + // 图像期望:左上红、右上绿 / 左下蓝、右下白。 + dib.extend_from_slice(&[255, 0, 0, 0, 255, 255, 255, 0]); // 蓝、白(底行) + dib.extend_from_slice(&[0, 0, 255, 0, 0, 255, 0, 0]); // 红、绿(顶行) + + let png = dib_to_png(&dib).expect("dib decodes"); + let decoded = image::load_from_memory(&png).expect("png decodes").to_rgba8(); + assert_eq!(decoded.dimensions(), (2, 2)); + assert_eq!(decoded.get_pixel(0, 0).0, [255, 0, 0, 255], "top-left red"); + assert_eq!(decoded.get_pixel(1, 0).0, [0, 255, 0, 255], "top-right green"); + assert_eq!(decoded.get_pixel(0, 1).0, [0, 0, 255, 255], "bottom-left blue"); + assert_eq!(decoded.get_pixel(1, 1).0, [255, 255, 255, 255], "bottom-right white"); + } + + #[test] + fn unsupported_formats_are_rejected() { + assert!(dib_to_png(&[]).is_none()); + let mut dib = vec![0u8; 40]; + dib[0] = 40; // header size ok + dib[14] = 8; // 8bpp 调色板图不支持 + assert!(dib_to_png(&dib).is_none()); + } +} diff --git a/nebula_app/src/config/bell.rs b/nebula_app/src/config/bell.rs index 719a262..f61c3cc 100644 --- a/nebula_app/src/config/bell.rs +++ b/nebula_app/src/config/bell.rs @@ -15,6 +15,17 @@ pub struct BellConfig { /// Command to run on bell. pub command: Option, + /// Play an audible system sound when the bell rings (BEL / `\a`). + /// + /// AI CLIs ring BEL when a turn finishes or they need input, so this is + /// the "claude/codex needs you" cue even while Nebula is focused on a + /// different tab — the visual bell and background-tab toast never cover + /// that case. Plays regardless of focus and is throttled in + /// [`crate::platform::beep`] so a bell-happy program cannot machine-gun + /// it. On by default, matching a conventional terminal; set + /// `bell.audible = false` to silence it. + pub audible: bool, + /// Visual bell flash color. pub color: Rgb, @@ -28,6 +39,7 @@ impl Default for BellConfig { color: Rgb::new(255, 255, 255), animation: Default::default(), command: Default::default(), + audible: true, duration: Default::default(), } } diff --git a/nebula_app/src/config/ui_config.rs b/nebula_app/src/config/ui_config.rs index 3e498d1..90103ef 100644 --- a/nebula_app/src/config/ui_config.rs +++ b/nebula_app/src/config/ui_config.rs @@ -131,6 +131,15 @@ impl UiConfig { #[cfg(not(windows))] let suppress_bringup_da1 = false; + // Windows child sessions are presented through ConPTY. It silently + // re-anchors its primary buffer on resize and keeps repainting with + // absolute CUP coordinates, so the renderer grid must use the matching + // row semantics. Alternate-screen applications retain normal behavior. + #[cfg(windows)] + let conpty_resize = true; + #[cfg(not(windows))] + let conpty_resize = false; + TermConfig { semantic_escape_chars: self.selection.semantic_escape_chars.clone(), scrolling_history: self.scrolling.history() as usize, @@ -139,6 +148,7 @@ impl UiConfig { osc52: self.terminal.osc52.0, kitty_keyboard: true, suppress_bringup_da1, + conpty_resize, } } diff --git a/nebula_app/src/display/command_palette.rs b/nebula_app/src/display/command_palette.rs index 69f93f3..bdf173e 100644 --- a/nebula_app/src/display/command_palette.rs +++ b/nebula_app/src/display/command_palette.rs @@ -213,6 +213,7 @@ pub enum PaletteAction { OpenSettingsFile, ToggleGhost, CycleAccept, + CycleCompletionStyle, PickBackgroundImage, CycleBackground, ResetAppearance, @@ -401,6 +402,12 @@ const ITEMS: &[PaletteItem] = &[ search: "切换补全接受键 cycle accept key completion jieshou", action: PaletteAction::CycleAccept, }, + PaletteItem { + label: "切换补全样式(行内 / 弹窗)", + hint: "", + search: "切换补全样式 行内 弹窗 completion style inline popup list buquan yangshi", + action: PaletteAction::CycleCompletionStyle, + }, PaletteItem { label: "选择背景图片…", hint: "", @@ -1495,6 +1502,7 @@ fn localized_item_label(item: &PaletteItem, language: super::UiLanguage) -> &'st OpenSettingsFile => "Open configuration file", ToggleGhost => "Toggle ghost completion", CycleAccept => "Cycle completion accept key", + CycleCompletionStyle => "Toggle completion style (inline / popup)", PickBackgroundImage => "Choose background image...", CycleBackground => "Cycle background color", ResetAppearance => "Restore appearance defaults", diff --git a/nebula_app/src/display/content.rs b/nebula_app/src/display/content.rs index aa896fa..6d7a3c2 100644 --- a/nebula_app/src/display/content.rs +++ b/nebula_app/src/display/content.rs @@ -31,7 +31,7 @@ pub struct RenderableContent<'a> { terminal_content: TerminalContent<'a>, cursor: RenderableCursor, cursor_shape: CursorShape, - cursor_point: Point, + cursor_point: Option>, search: Option>, hint: Option>, config: &'a UiConfig, @@ -56,7 +56,8 @@ impl<'a> RenderableContent<'a> { ) -> Self { let search = search_state.dfas().map(|dfas| HintMatches::visible_regex_matches(term, dfas)); let focused_match = search_state.focused_match(); - let terminal_content = term.renderable_content(); + let terminal_content = + term.renderable_content_with_viewport(size.screen_lines(), size.columns()); let theme_is_light = display.nebula_theme.palette().is_light; let theme_foreground = display.colors[NamedColor::Foreground]; let theme_background = display.colors[NamedColor::Background]; @@ -87,8 +88,8 @@ impl<'a> RenderableContent<'a> { // Convert terminal cursor point to viewport position. let cursor_point = terminal_content.cursor.point; - let display_offset = terminal_content.display_offset; - let cursor_point = term::point_to_viewport(display_offset, cursor_point).unwrap(); + let cursor_point = term::point_to_viewport_from(terminal_content.viewport_origin, cursor_point) + .filter(|point| point.line < size.screen_lines() && point.column.0 < size.columns()); let hint = if display.hint_state.active() { display.hint_state.update_matches(term); @@ -122,6 +123,20 @@ impl<'a> RenderableContent<'a> { self.terminal_content.display_offset } + /// First grid row rendered at viewport line zero. + #[inline] + pub fn viewport_origin(&self) -> Line { + self.terminal_content.viewport_origin + } + + /// Convert a grid point to this content's clipped viewport coordinates. + #[inline] + pub fn point_to_viewport(&self, point: Point) -> Option> { + term::point_to_viewport_from(self.viewport_origin(), point).filter(|point| { + point.line < self.size.screen_lines() && point.column.0 < self.size.columns() + }) + } + /// Get the terminal cursor. pub fn cursor(mut self) -> RenderableCursor { // Assure this function is only called after the iterator has been drained. @@ -176,7 +191,9 @@ impl<'a> RenderableContent<'a> { RenderableCursor { width, shape: self.cursor_shape, - point: self.cursor_point, + // Only reached from `next()` when this cell IS the cursor cell, + // so the cell's own viewport point is the cursor point. + point: cell.point, cursor_color, text_color, opacity, @@ -222,7 +239,7 @@ impl Iterator for RenderableContent<'_> { let cell = self.terminal_content.display_iter.next()?; let mut cell = RenderableCell::new(self, cell); - if self.cursor_point == cell.point { + if self.cursor_point == Some(cell.point) { // Store the cursor which should be rendered. self.cursor = self.renderable_cursor(&cell); if self.cursor.shape == CursorShape::Block { @@ -330,8 +347,7 @@ impl RenderableCell { ) }); - let display_offset = content.terminal_content.display_offset; - let viewport_start = Point::new(Line(-(display_offset as i32)), Column(0)); + let viewport_start = Point::new(content.viewport_origin(), Column(0)); let colors = &content.config.colors; let mut character = cell.c; let mut flags = cell.flags; @@ -395,7 +411,9 @@ impl RenderableCell { // Convert cell point to viewport position. let cell_point = cell.point; - let point = term::point_to_viewport(display_offset, cell_point).unwrap(); + let point = content + .point_to_viewport(cell_point) + .expect("render iterator only yields points inside its viewport"); let underline = cell.underline_color().map_or(fg, |underline| { let (underline_rgb, fixed_underline) = Self::compute_fg_rgb(content, underline, flags); diff --git a/nebula_app/src/display/mod.rs b/nebula_app/src/display/mod.rs index b1c5147..887baa7 100644 --- a/nebula_app/src/display/mod.rs +++ b/nebula_app/src/display/mod.rs @@ -104,8 +104,8 @@ pub use context_menu::{ContextMenuAction, ContextMenuHit, ContextMenuTarget}; pub use i18n::{LanguagePreference, UiLanguage}; pub use size_info::SizeInfo; pub use state::{ - AcceptKey, NebulaConfirm, NebulaInlineImage, NebulaPaneState, NebulaShell, SplitDirection, - SplitNav, + AcceptKey, AiSessionIdentity, CompletionStyle, NebulaCompletionItem, NebulaCompletionKind, + NebulaConfirm, NebulaInlineImage, NebulaPaneState, NebulaShell, SplitDirection, SplitNav, }; pub use toast::ToastKind; @@ -140,7 +140,9 @@ enum BackupOperation { pub(crate) fn caret_blink_on() -> bool { ui::caret::is_on() } -pub use settings::{NebulaSettingsSection, SettingsDropdown, SettingsHit, settings_hit}; +pub use settings::{ + BgPickerPart, NebulaSettingsSection, SettingsDropdown, SettingsHit, settings_hit, +}; pub(crate) use settings::{CellWidthMode, NewTabPosition, SettingsOpacityTarget}; /// 按显示列宽贪心断行(确认框正文等 UI 段落用):CJK 逐字可断,行首空 @@ -653,8 +655,14 @@ fn merge_ssh_hosts(saved: &[String], pinned: &[String], hidden: &[String]) -> Ve /// Remove one destination while recording exactly enough list state for Undo. /// Kept independent from rendering and Credential Manager so the destructive /// state transition can be regression-tested without touching real secrets. +/// +/// Only config-sourced aliases go to the hidden list: Nebula never edits +/// `~/.ssh/config`, so hiding is the strongest "delete" available for them. +/// Nebula-managed hosts are removed outright — parking them in the hidden +/// section made deletion read as a rename to "hidden". fn remove_ssh_host_from_lists( host: &str, + from_config: bool, saved: &mut Vec, pinned: &mut Vec, hidden: &mut Vec, @@ -664,7 +672,7 @@ fn remove_ssh_host_from_lists( let was_hidden = hidden.iter().any(|entry| entry == host); saved.retain(|entry| entry != host); pinned.retain(|entry| entry != host); - if !was_hidden { + if from_config && !was_hidden { hidden.push(host.to_owned()); } (saved_index, pinned_index, was_hidden) @@ -1218,6 +1226,56 @@ fn nebula_command_hint<'a>(commands: &'a [String], prefix: &str) -> Option<&'a s }) } +/// Multi-candidate variant of [`nebula_command_hint`] for the popup list: up +/// to `limit` full command names extending `prefix`. Unlike the ghost variant +/// an exact match does NOT suppress the longer neighbors — listing `claude` +/// alongside `claude-agent-acp` is exactly what a chooser is for. +fn nebula_command_hints<'a>(commands: &'a [String], prefix: &str, limit: usize) -> Vec<&'a str> { + if prefix.is_empty() || limit == 0 { + return Vec::new(); + } + let mut out: Vec<&'a str> = Vec::new(); + for command in commands { + if command.len() <= prefix.len() || !command.is_char_boundary(prefix.len()) { + continue; + } + let head = &command[..prefix.len()]; + #[cfg(windows)] + let matches = head.eq_ignore_ascii_case(prefix); + #[cfg(not(windows))] + let matches = head == prefix; + if matches && !out.iter().any(|seen| *seen == command.as_str()) { + out.push(command); + if out.len() == limit { + break; + } + } + } + out +} + +/// Truncate/pad `text` to exactly `width` display cells (wide chars count 2; +/// a wide char that would straddle the boundary is dropped and padded over). +fn nebula_pad_to_cells(text: &str, width: usize) -> String { + let mut out = String::with_capacity(width); + let mut used = 0usize; + for c in text.chars() { + let w = c.width().unwrap_or(0); + if w == 0 { + continue; + } + if used + w > width { + break; + } + out.push(c); + used += w; + } + for _ in used..width { + out.push(' '); + } + out +} + fn nebula_is_command_position(line: &str) -> bool { !line.contains([' ', '\t']) && !line.contains(['/', '\\']) @@ -1498,6 +1556,11 @@ pub struct Display { /// Active settings opacity drag: target plus the screen-space track used /// for pointer-to-value mapping. Values persist only when the drag ends. pub nebula_settings_opacity_drag: Option<(settings::SettingsOpacityTarget, f32, f32)>, + /// 背景色调色盘的草稿 HSV。打开浮层时从生效色初始化;拖动期间它是唯一 + /// 权威——灰/黑/白点的色相经 RGB 往返会坍缩成 0,这里保住用户拨到的值。 + nebula_bg_picker_hsv: (f32, f32, f32), + /// 进行中的调色盘拖拽(SV 面或色相条),值实时应用、松手落盘。 + pub nebula_bg_picker_drag: Option, /// Unified native right-click menu shared by tab and SSH rows. The menu /// owns its short open/close animation so no input path needs timers. nebula_context_menu: Option, @@ -1592,6 +1655,8 @@ pub struct Display { pub nebula_ghost_enabled: bool, /// Which key accepts a ghost suggestion. pub nebula_accept: AcceptKey, + /// How completions surface: inline ghost remainder or a popup list. + pub nebula_completion_style: CompletionStyle, /// Default executor used by new sessions when no explicit shell is configured. pub nebula_shell: NebulaShell, /// Raw default-shell id when the user picked a detected shell the 2-value @@ -1613,6 +1678,9 @@ pub struct Display { /// 启动时回放 `session.json`(正常关窗与崩溃恢复共用这一条路)。关掉 /// 只是不回放——快照照写,导出工作区与崩溃诊断仍然可用。 pub nebula_restore_session: bool, + /// 冷恢复时自动接续各 pane 的 AI 对话(claude/codex resume,T1-2)。 + /// 消费方在 `window_context::resume_agent_sessions`。 + pub nebula_resume_ai: bool, /// Runtime window opacity controlled from Nebula settings. pub nebula_window_opacity: f32, /// Which settings combobox (floating option list) is expanded, if any. @@ -2268,6 +2336,8 @@ impl Display { nebula_settings_hover: SettingsHit::None, nebula_settings_pressed: SettingsHit::None, nebula_settings_opacity_drag: None, + nebula_bg_picker_hsv: (220.0, 0.0, 0.0), + nebula_bg_picker_drag: None, nebula_context_menu: None, nebula_settings_dropdown: None, nebula_cursor_shape: settings_init.cursor_shape, @@ -2350,6 +2420,7 @@ impl Display { nebula_pty_resize_pending: false, nebula_ghost_enabled: settings_init.ghost, nebula_accept: settings_init.accept, + nebula_completion_style: settings_init.completion_style, nebula_shell: settings_init.shell, nebula_shell_id: settings_init.shell_id.clone(), nebula_startup_directory: settings_init.startup_directory, @@ -2358,6 +2429,7 @@ impl Display { nebula_blur: settings_init.blur, nebula_keep_session: settings_init.keep_session, nebula_restore_session: settings_init.restore_session, + nebula_resume_ai: settings_init.resume_ai, nebula_window_opacity: settings_init.opacity, nebula_background: if settings_init.follow_system_theme { Some(nebula_theme.palette().term_bg) @@ -2509,8 +2581,14 @@ impl Display { self.terminal_card_rect() } + /// Rows in the default-shell dropdown. MUST agree with the list the + /// settings view renders (`SettingsView::shells` = detected shells + + /// imported quick-launch profiles): the hit test sizes the popup from this + /// count, and undercounting made every imported profile's row — drawn and + /// hovered — unclickable (导入终端后选不中最后一项). pub fn shell_picker_count(&self) -> usize { self.nebula_detected_shells.as_ref().map_or(0, Vec::len) + + self.nebula_profiles.iter().filter(|profile| profile.settings_id().is_some()).count() } /// 字体族行 + 两个固定尾行:「显示全部 / 仅等宽」过滤切换,以及「导入字体…」。 @@ -2576,6 +2654,7 @@ impl Display { let (saved_index, pinned_index, was_hidden) = remove_ssh_host_from_lists( host, + from_config, &mut self.nebula_saved_hosts, &mut self.nebula_pinned_hosts, &mut self.nebula_hidden_hosts, @@ -3438,6 +3517,7 @@ impl Display { follow_system_theme: self.nebula_follow_system_theme, ghost: self.nebula_ghost_enabled, accept: self.nebula_accept, + completion_style: self.nebula_completion_style, shell_label: { // Rich picked id (cmd/pwsh/nu/wsl:X) wins; else the 2-value // enum label. Icon comes from the same table the dropdown @@ -3527,6 +3607,7 @@ impl Display { blur: self.nebula_blur, keep_session: self.nebula_keep_session, restore_session: self.nebula_restore_session, + resume_ai: self.nebula_resume_ai, opacity: self.nebula_window_opacity, dragging_opacity: self.nebula_settings_opacity_drag.map(|(target, _, _)| target), cursor_shape: self.nebula_cursor_shape, @@ -3548,6 +3629,7 @@ impl Display { background: self.nebula_background, bg_hex_input: self.nebula_bg_hex_input.clone(), bg_hex_active: self.nebula_bg_hex_active, + bg_picker_hsv: self.nebula_bg_picker_hsv, background_image: self.nebula_background_image.clone(), background_image_opacity: self.nebula_background_image_opacity, background_image_fit: self.nebula_background_image_fit, @@ -3943,6 +4025,24 @@ impl Display { self.pending_update.dirty = true; } + /// Flip between inline ghost and popup-list completion (palette / + /// keybinding path; the settings page goes through + /// [`Self::set_completion_style_option`]). + pub fn cycle_completion_style(&mut self) { + self.nebula_completion_style = self.nebula_completion_style.cycle(); + self.persist_nebula_settings(); + self.pending_update.dirty = true; + } + + pub fn set_completion_style_option(&mut self, index: usize) { + if let Some(style) = settings::COMPLETION_STYLE_OPTIONS.get(index) { + self.nebula_completion_style = *style; + self.persist_nebula_settings(); + } + self.nebula_settings_dropdown = None; + self.pending_update.dirty = true; + } + /// Open the "默认 Shell" picker (the settings row click): the same /// Toggle the inline shell picker in settings (expand/collapse the list). /// Toggle a settings combobox. All dropdowns share one field so opening @@ -4843,11 +4943,19 @@ impl Display { self.pending_update.dirty = true; } - /// 打开/关闭背景色浮层(色板 + 16 进制输入),草稿预填当前生效色。 + /// 打开/关闭背景色浮层(调色盘 + 色板 + 16 进制输入),草稿预填当前 + /// 生效色。灰/黑/白的色相在 RGB 里是缺失的:那时保留上一次的色相, + /// 用户把明度拨回来时不会发现色相被重置到红色。 pub fn open_background_color_picker(&mut self) { let current = self.nebula_background.unwrap_or(self.colors[NamedColor::Background]); self.nebula_bg_hex_input = format!("#{:02X}{:02X}{:02X}", current.r, current.g, current.b); self.nebula_bg_hex_active = false; + let (h, s, v) = settings::rgb_to_hsv(current); + if s > f32::EPSILON && v > f32::EPSILON { + self.nebula_bg_picker_hsv = (h, s, v); + } else { + self.nebula_bg_picker_hsv = (self.nebula_bg_picker_hsv.0, s, v); + } self.toggle_settings_dropdown(settings::SettingsDropdown::BackgroundColor); } @@ -4856,12 +4964,61 @@ impl Display { if let Some(color) = settings::BACKGROUND_SWATCHES.get(index) { self.nebula_bg_palette_index = index; self.nebula_background = Some(*color); + self.nebula_bg_picker_hsv = settings::rgb_to_hsv(*color); self.persist_nebula_settings(); } self.close_settings_dropdown(); self.pending_update.dirty = true; } + /// 调色盘按下:记录拖拽目标并立即按指针位置取一次色。 + pub fn begin_bg_picker_drag(&mut self, part: settings::BgPickerPart, x: f32, y: f32) { + self.nebula_bg_picker_drag = Some(part); + self.update_bg_picker_drag(x, y); + } + + /// 调色盘拖拽中:指针 → HSV → 实时应用为背景色(不落盘)。 + /// 预览卡、终端和 hex 草稿同步跟随,松手才写设置文件。 + pub fn update_bg_picker_drag(&mut self, x: f32, y: f32) -> bool { + let Some(part) = self.nebula_bg_picker_drag else { + return false; + }; + let (sv, hue) = settings::background_color_picker_rects( + &self.ui_size_info(), + self.window.scale_factor as f32, + self.terminal_card_rect(), + self.nebula_settings_scroll, + self.nebula_density, + ); + let (h, s, v) = &mut self.nebula_bg_picker_hsv; + match part { + settings::BgPickerPart::Sv => { + *s = ((x - sv.0) / sv.2.max(1.0)).clamp(0.0, 1.0); + *v = (1.0 - (y - sv.1) / sv.3.max(1.0)).clamp(0.0, 1.0); + }, + settings::BgPickerPart::Hue => { + *h = ((x - hue.0) / hue.2.max(1.0)).clamp(0.0, 1.0) * 360.0; + }, + } + let color = settings::hsv_to_rgb(*h, *s, *v); + self.nebula_background = Some(color); + self.nebula_bg_hex_input = format!("#{:02X}{:02X}{:02X}", color.r, color.g, color.b); + self.nebula_bg_hex_active = false; + self.pending_update.dirty = true; + self.window.request_redraw(); + true + } + + /// 调色盘松手:集中落盘(拖动过程只刷新画面,避免连续写设置文件)。 + pub fn finish_bg_picker_drag(&mut self) -> bool { + if self.nebula_bg_picker_drag.take().is_none() { + return false; + } + self.persist_nebula_settings(); + self.pending_update.dirty = true; + true + } + pub fn focus_bg_hex_input(&mut self) { self.nebula_bg_hex_active = true; self.pending_update.dirty = true; @@ -4888,6 +5045,7 @@ impl Display { return false; }; self.nebula_background = Some(color); + self.nebula_bg_picker_hsv = settings::rgb_to_hsv(color); self.persist_nebula_settings(); self.close_settings_dropdown(); self.pending_update.dirty = true; @@ -6898,9 +7056,17 @@ impl Display { self.nebula_background_image_cover_chrome, provider.is_some_and(|provider| provider.codex_goals), provider.is_some_and(|provider| provider.codex_remote_compaction), + self.nebula_resume_ai, ] } + /// 高级·会话:「恢复时接续 AI 对话」开关。 + pub fn toggle_resume_ai(&mut self) { + self.nebula_resume_ai = !self.nebula_resume_ai; + self.persist_nebula_settings(); + self.pending_update.dirty = true; + } + pub fn step_chrome_anims(&mut self) { let toggle_targets = self.settings_toggle_targets(); self.nebula_ui_anims.step( @@ -7334,6 +7500,7 @@ impl Display { language: self.nebula_language_preference, ghost: self.nebula_ghost_enabled, accept: self.nebula_accept, + completion_style: self.nebula_completion_style, shell: self.nebula_shell, shell_id: self.nebula_shell_id.clone(), startup_directory: self.nebula_startup_directory.clone(), @@ -7343,6 +7510,7 @@ impl Display { blur: self.nebula_blur, keep_session: self.nebula_keep_session, restore_session: self.nebula_restore_session, + resume_ai: self.nebula_resume_ai, opacity: self.nebula_window_opacity, background: self.nebula_background, background_image: self.nebula_background_image.clone(), @@ -7419,6 +7587,7 @@ impl Display { } self.nebula_ghost_enabled = settings.ghost; self.nebula_accept = settings.accept; + self.nebula_completion_style = settings.completion_style; self.nebula_shell = settings.shell; self.nebula_shell_id = settings.shell_id; self.nebula_startup_directory = settings.startup_directory; @@ -7440,6 +7609,7 @@ impl Display { self.nebula_blur = settings.blur; self.nebula_keep_session = settings.keep_session; self.nebula_restore_session = settings.restore_session; + self.nebula_resume_ai = settings.resume_ai; self.nebula_panel_resize = settings.panel_resize; // 手改文件把宽度调了的话,和拖拽一样要触发一次 reflow。 let panel_dims_changed = (self.nebula_sidebar_w - settings.sidebar_w).abs() > 0.5 @@ -7809,7 +7979,10 @@ impl Display { /// Process update events. pub fn handle_update( &mut self, - terminal: &mut Term, + // Grid resizes are committed together with the PTY by the window + // context (leading edge / settle); the handle stays in the signature + // so the call sites don't churn if an immediate path returns. + _terminal: &mut Term, // PTY resizes are deferred to the settle timer (see // `nebula_pty_resize_pending`); the handle stays in the signature so // the call sites don't churn if an immediate path returns. @@ -7902,7 +8075,7 @@ impl Display { self.window.set_resize_increments(increments); } - // Resize when terminal when its dimensions have changed. + // Update the visible terminal viewport when its dimensions have changed. if self.size_info.screen_lines() != new_size.screen_lines || self.size_info.columns() != new_size.columns() { @@ -7910,13 +8083,12 @@ impl Display { // per tick: the in-box ConPTY repaints its entire viewport on // every resize, so drag-resizing would flood the scrollback with // dozens of shredded repaints (and TUIs like Claude Code redraw - // storms). The grid resizes live below, so rendering stays exact; - // only the child's SIGWINCH-equivalent waits for the drag to end. + // storms). The window context commits the grid and ConPTY + // together at the leading/trailing edges; until then rendering is + // clipped to the last committed grid, so both sides retain the + // same reflow history. self.nebula_pty_resize_pending = true; - // Resize terminal. - terminal.resize(new_size); - // Resize damage tracking. self.damage_tracker.resize(new_size.screen_lines(), new_size.columns()); @@ -7928,7 +8100,7 @@ impl Display { } self.nebula_resize_hud_armed = true; nebula_link_log(format!( - "grid_resize {}x{} px={width}x{height} pad_x={} pad_r={} pad_y={} \ + "viewport_resize {}x{} px={width}x{height} pad_x={} pad_r={} pad_y={} \ cell={cell_width}x{cell_height} drawer={drawer} sidebar={sidebar} \ reserved={}", new_size.columns(), @@ -8051,6 +8223,7 @@ impl Display { let background_color = custom_background.unwrap_or_else(|| content.color(NamedColor::Background as usize)); let display_offset = content.display_offset(); + let viewport_origin = content.viewport_origin(); let cursor = content.cursor(); let cursor_point = terminal.grid().cursor.point; @@ -8092,7 +8265,8 @@ impl Display { && search_state.regex().is_none() && selection_range.is_none() { - let visible_cursor = term::point_to_viewport(display_offset, cursor_point); + let visible_cursor = term::point_to_viewport_from(viewport_origin, cursor_point) + .filter(|point| point.line < view.screen_lines() && point.column.0 < view.columns()); terminal_math::scan_visible( &mut pane_state.terminal_math, &terminal, @@ -8146,7 +8320,7 @@ impl Display { // against the focused pane, so a background pane's output (build log, // `top`) must not tear down the foreground's highlight. if force_focus != Some(false) { - self.validate_hint_highlights(display_offset, term_damage_full, &term_damage_lines); + self.validate_hint_highlights(viewport_origin, term_damage_full, &term_damage_lines); } // OSC 1337 inline images: prune rows that scrolled out of history for @@ -8158,7 +8332,7 @@ impl Display { let rows = (img.height / cell_h).ceil().max(1.0) as usize; img.abs_line + rows >= grid_scrolled_out }); - let top_abs = (image_anchor - display_offset) as f32; + let top_abs = (image_anchor as i64 + viewport_origin.0 as i64) as f32; for img in &pane_state.inline_images { let y = view.padding_y() + (img.abs_line as f32 - top_abs) * cell_h; // Cull images entirely outside this pane's band. @@ -8181,8 +8355,7 @@ impl Display { // `line_buf` is used. Only on the primary screen, never during vi/search // overlays. if alt_screen || vi_mode || search_state.regex().is_some() { - pane_state.suggestion.clear(); - pane_state.suggestion_key.clear(); + pane_state.clear_completion_hints(); } else { #[cfg(windows)] { @@ -8208,8 +8381,7 @@ impl Display { }, None => { pane_state.screen_line.clear(); - pane_state.suggestion.clear(); - pane_state.suggestion_key.clear(); + pane_state.clear_completion_hints(); }, } } @@ -8227,8 +8399,10 @@ impl Display { self.damage_tracker.frame().mark_fully_damaged(); self.damage_tracker.next_frame().mark_fully_damaged(); - let vi_cursor_viewport_point = - vi_cursor_point.and_then(|cursor| term::point_to_viewport(display_offset, cursor)); + let vi_cursor_viewport_point = vi_cursor_point.and_then(|cursor| { + term::point_to_viewport_from(viewport_origin, cursor) + .filter(|point| point.line < size_info.screen_lines() && point.column.0 < size_info.columns()) + }); self.damage_tracker.damage_vi_cursor(vi_cursor_viewport_point); self.damage_tracker.damage_selection(selection_range, display_offset); @@ -8307,7 +8481,7 @@ impl Display { _ => (), } - let point = term::viewport_to_point(display_offset, source_point); + let point = term::viewport_to_point_from(viewport_origin, source_point); while clickable_matches .get(clickable_index) .is_some_and(|bounds| bounds.end() < &point) @@ -8430,8 +8604,8 @@ impl Display { None => { let num_lines = size_info.screen_lines(); match vi_cursor_viewport_point { - None => term::point_to_viewport(display_offset, cursor_point) - .filter(|point| point.line < num_lines), + None => term::point_to_viewport_from(viewport_origin, cursor_point) + .filter(|point| point.line < num_lines && point.column.0 < size_info.columns()), point => point, } }, @@ -8528,7 +8702,7 @@ impl Display { // stays clearly weaker than the near-black real input instead of // colliding with it. if !pane_state.suggestion.is_empty() && self.ime.preedit().is_none() { - if let Some(point) = term::point_to_viewport(display_offset, cursor_point) + if let Some(point) = term::point_to_viewport_from(viewport_origin, cursor_point) .filter(|p| p.line < size_info.screen_lines() && p.column.0 < size_info.columns()) { let avail = size_info.columns() - point.column.0; @@ -8546,12 +8720,29 @@ impl Display { } } + // Popup-style completion list (弹窗补齐): candidate rows anchored to + // the prompt cursor, keyboard-selected. Mutually exclusive with the + // ghost above by construction; same IME suppression. + if !pane_state.completion_items.is_empty() && self.ime.preedit().is_none() { + if let Some(anchor) = term::point_to_viewport_from(viewport_origin, cursor_point) + .filter(|p| p.line < size_info.screen_lines() && p.column.0 < size_info.columns()) + { + self.draw_completion_popup( + &pane_state.completion_items, + pane_state.completion_selected, + anchor, + &size_info, + background_color, + ); + } + } + self.draw_render_timer(config); // Draw hyperlink uri preview. if has_highlighted_hint { let cursor_point = vi_cursor_point.or(Some(cursor_point)); - self.draw_hyperlink_preview(config, cursor_point, display_offset); + self.draw_hyperlink_preview(config, cursor_point, viewport_origin); } // Overlay scrollbar on the right edge while scrolled into history. @@ -9124,17 +9315,25 @@ impl Display { // Keycaps: 图8 键帽规范(2026-07-29)——与 Ctrl+K/设置页共用 // `keycap::push_chip` 配方(hairline 圈 + panel/surface 叠底), - // 不再按按钮墨色自造描边圈。chip 底是中性 panel,所以放在 accent - // 主按钮上天然成为浅色小块(图3 mockup 的 Enter 键帽即此形态)。 - // Returns its quads instead of pushing: a closure capturing `quads` - // mutably would lock it for the rest of the batch. + // 不再按按钮墨色自造描边圈。该配方只用于中性底的取消键;中性 + // panel 在深色主题里近黑,放到 accent 主按钮上会读成一块突兀的 + // 深色(issue #35),主键帽因此改走 `push_chip_on_fill`:底与 + // 底边从按钮自己的墨(`on_primary`)派生,深浅主题都成立。 let cap = |x: f32, key: &str| -> Vec { let mut out = Vec::new(); ui::keycap::push_chip(&mut out, &sk, x, cap_y, cap_w(key), cap_h, scale); out }; quads.extend(cap(cancel_cap_x, cancel_key)); - quads.extend(cap(primary_cap_x, primary_key)); + ui::keycap::push_chip_on_fill( + &mut quads, + on_primary, + primary_cap_x, + cap_y, + cap_w(primary_key), + cap_h, + scale, + ); self.renderer.draw_ui(&size, &quads); // Text: free-pixel chrome text (no opaque cell backgrounds), left @@ -9199,13 +9398,14 @@ impl Display { primary_label, glyph_cache, ); - // 键帽文字统一走 chip 自己的墨(sk.ink):chip 底是中性 panel, - // 不随按钮填充翻转,主按钮上的键名也保持可读。 + // 主键帽文字随键帽底走按钮墨(`on_primary`):键帽底就是这支墨的 + // 低透明度洗色,满强度的同一支墨在其上必然可读;取消键帽仍是中性 + // chip + sk.ink。 self.renderer.draw_chrome_text( &size, primary_cap_x + cap_pad, btn_text_y, - txt, + on_primary, primary_key, glyph_cache, ); @@ -9842,6 +10042,8 @@ impl Display { state.touched = true; state.suggestion.clear(); state.suggestion_key.clear(); + state.completion_items.clear(); + state.completion_selected = 0; nebula_debug_log(format!("input_char c={c:?} line_buf={:?}", state.line_buf)); } @@ -9849,8 +10051,7 @@ impl Display { pub fn nebula_input_backspace(state: &mut NebulaPaneState) { state.line_buf.pop(); state.touched = true; - state.suggestion.clear(); - state.suggestion_key.clear(); + state.clear_completion_hints(); nebula_debug_log(format!("input_backspace line_buf={:?}", state.line_buf)); } @@ -9864,8 +10065,7 @@ impl Display { while state.line_buf.chars().last().is_some_and(|c| !c.is_whitespace()) { state.line_buf.pop(); } - state.suggestion.clear(); - state.suggestion_key.clear(); + state.clear_completion_hints(); nebula_debug_log(format!("input_delete_word line_buf={:?}", state.line_buf)); } @@ -9882,8 +10082,7 @@ impl Display { state.line_buf.push_str(text); state.touched = true; - state.suggestion.clear(); - state.suggestion_key.clear(); + state.clear_completion_hints(); nebula_debug_log(format!("input_text text={text:?} line_buf={:?}", state.line_buf)); } @@ -9940,8 +10139,7 @@ impl Display { } state.line_buf.clear(); state.screen_line.clear(); - state.suggestion.clear(); - state.suggestion_key.clear(); + state.clear_completion_hints(); } /// Read the real, echoed input off the cursor's grid row on Windows. @@ -10066,8 +10264,7 @@ impl Display { ) { let line = line_override.unwrap_or_else(|| state.line_buf.clone()); if !self.nebula_ghost_enabled || line.is_empty() { - state.suggestion.clear(); - state.suggestion_key.clear(); + state.clear_completion_hints(); nebula_debug_log(format!( "suggest_skip enabled={} cwd={:?} line={:?} line_buf={:?}", self.nebula_ghost_enabled, state.cwd, line, state.line_buf @@ -10077,16 +10274,28 @@ impl Display { let key = format!("{}\u{0}{line}", state.cwd); if key == state.suggestion_key { + // Cache hit also protects an Esc-dismissed popup: dismissal clears + // the items but keeps the key, so nothing reopens until the line + // actually changes. return; } state.suggestion_key = key; state.suggestion.clear(); + state.completion_items.clear(); + state.completion_selected = 0; nebula_debug_log(format!( "suggest_begin cwd={:?} line={:?} line_buf={:?}", state.cwd, line, state.line_buf )); + // Popup style computes a multi-candidate list instead of the single + // ghost remainder; the two are mutually exclusive per pane. + if self.nebula_completion_style == CompletionStyle::Popup { + self.nebula_collect_completions(state, &line); + return; + } + // History first: newest command that extends the whole line (indexed // prefix lookup — scales with matches, not history size). if let Some(rem) = self.nebula_history.hint(&line) { @@ -10192,6 +10401,237 @@ impl Display { rem.chars().take(NEBULA_GHOST_MAX).collect() } + /// Elide long popup labels from the LEFT (paths keep their informative + /// tail; the head the user already typed is the expendable part). + fn nebula_elide_left(text: &str, max_chars: usize) -> String { + let count = text.chars().count(); + if count <= max_chars { + return text.to_owned(); + } + let tail: String = text.chars().skip(count + 1 - max_chars).collect(); + format!("…{tail}") + } + + /// Fill `state.completion_items` for the popup style: the same sources as + /// the ghost hint (history → directory history → PATH commands → file + /// system), but keeping several candidates each instead of the first hit. + fn nebula_collect_completions(&mut self, state: &mut NebulaPaneState, line: &str) { + const POPUP_MAX: usize = 8; + const LABEL_MAX: usize = 44; + + let mut items: Vec = Vec::new(); + let push = |items: &mut Vec, item: NebulaCompletionItem| { + if item.insert.is_empty() { + return; + } + if items.iter().any(|seen| seen.insert == item.insert && seen.kind == item.kind) { + return; + } + if items.len() < POPUP_MAX { + items.push(item); + } + }; + + // Whole-line history matches, newest first. + for (full, rem) in self.nebula_history.hints(line, 3) { + push(&mut items, NebulaCompletionItem { + label: Self::nebula_elide_left(full, LABEL_MAX), + insert: rem.to_owned(), + kind: NebulaCompletionKind::History, + }); + } + + let token = line.rsplit([' ', '\t']).next().unwrap_or(""); + + // Frecency-ranked directory completion for cd-like commands. + if let Some(rem) = self.directory_history.hint(line, &state.cwd) { + push(&mut items, NebulaCompletionItem { + label: Self::nebula_elide_left(&format!("{token}{rem}"), LABEL_MAX), + insert: rem, + kind: NebulaCompletionKind::Dir, + }); + } + + // PATH executables while the first token is being typed. + if nebula_is_command_position(line) { + if let Ok(commands) = self.nebula_commands.lock() { + for command in nebula_command_hints(commands.as_slice(), line, POPUP_MAX) { + push(&mut items, NebulaCompletionItem { + label: Self::nebula_elide_left(command, LABEL_MAX), + insert: command[line.len()..].to_owned(), + kind: NebulaCompletionKind::Command, + }); + } + } + } + + // Filesystem candidates for the final token (same gating as the ghost + // path: absolute tokens work without a cwd, relative ones need one). + if !token.is_empty() { + let absolute = token.starts_with(['/', '\\', '~']) + || token.as_bytes().get(1) == Some(&b':'); + if absolute || !state.cwd.is_empty() { + let options = + CompletionOptions { case_sensitive: false, ..CompletionOptions::default() }; + let want_dir = nebula_path_wants_directory(line); + let span = Span::new(0, token.len()); + let cwd = state.cwd.clone(); + let cwd_slot = [cwd.as_str()]; + let cwds: &[&str] = if cwd.is_empty() { &[] } else { &cwd_slot }; + let matches = complete_item(want_dir, span, token, cwds, &options, false, None); + let matches = if want_dir { + self.directory_history.rank_file_suggestions(matches, &state.cwd) + } else { + matches + }; + for candidate in matches.iter().take(POPUP_MAX) { + let path = candidate.display_override.as_deref().unwrap_or(&candidate.path); + if path.len() <= token.len() || !path.is_char_boundary(token.len()) { + continue; + } + let (head, rem) = path.split_at(token.len()); + if !head.eq_ignore_ascii_case(token) { + continue; + } + // One segment at a time, like the ghost: a deep match + // drills the tree one directory per acceptance. + let (insert, cut_at_dir) = match rem.find(['/', '\\']) { + Some(i) => (&rem[..=i], true), + None => (rem, false), + }; + let kind = if cut_at_dir || candidate.is_dir { + NebulaCompletionKind::Dir + } else { + NebulaCompletionKind::File + }; + push(&mut items, NebulaCompletionItem { + label: Self::nebula_elide_left(&format!("{token}{insert}"), LABEL_MAX), + insert: insert.to_owned(), + kind, + }); + } + } + } + + nebula_debug_log(format!( + "suggest_result kind=popup line={:?} items={}", + line, + items.len() + )); + state.completion_items = items; + state.completion_selected = 0; + } + + /// Render the popup completion list on the terminal cell grid: one padded + /// row per candidate directly below the cursor (above when the prompt sits + /// near the bottom), the selected row on the theme accent. Cell-grid + /// `draw_string` keeps this inside the pane projection — no chrome quads, + /// so splits and scrolled panes behave like the ghost text does. + fn draw_completion_popup( + &mut self, + items: &[NebulaCompletionItem], + selected: usize, + anchor: Point, + size_info: &SizeInfo, + term_bg: Rgb, + ) { + let columns = size_info.columns(); + let screen_lines = size_info.screen_lines(); + if columns < 12 || screen_lines < 2 { + return; + } + + // Rows: prefer the space below the cursor, else above; clamp count. + let below = screen_lines.saturating_sub(anchor.line + 1); + let above = anchor.line; + let want = items.len().min(8); + let (rows, start_line) = if below >= want || below >= above { + (want.min(below), anchor.line + 1) + } else { + (want.min(above), anchor.line - want.min(above)) + }; + if rows == 0 { + return; + } + // When the list is cut short keep the selected row visible. + let offset = if selected >= rows { selected + 1 - rows } else { 0 }; + + let language = self.nebula_language; + let tag = |kind: NebulaCompletionKind| -> &'static str { + match kind { + NebulaCompletionKind::History => language.pick("历史", "hist"), + NebulaCompletionKind::Command => language.pick("命令", "cmd"), + NebulaCompletionKind::Dir => language.pick("目录", "dir"), + NebulaCompletionKind::File => language.pick("文件", "file"), + } + }; + let cell_width = |text: &str| -> usize { + text.chars().map(|c| c.width().unwrap_or(0)).sum() + }; + + let visible = &items[offset..(offset + rows).min(items.len())]; + let tag_w = visible.iter().map(|item| cell_width(tag(item.kind))).max().unwrap_or(0); + let label_w_max = visible.iter().map(|item| cell_width(&item.label)).max().unwrap_or(0); + + // ` label tag ` — 1 cell padding each side, 2 cells between. + let mut start_col = anchor.column.0; + let mut avail = columns - start_col; + let full_w = label_w_max + tag_w + 4; + if full_w > avail { + // Slide left rather than shrink first; narrow panes then clamp. + let slide = (full_w - avail).min(start_col); + start_col -= slide; + avail += slide; + } + let width = full_w.min(avail); + let label_w = width.saturating_sub(tag_w + 4); + if label_w < 4 { + return; + } + + let sk = self.nebula_theme.skin(); + let opaque = |c: Rgb| Rgba::new(c.r, c.g, c.b, 255); + let rgb = |c: Rgba| Rgb::new(c.r, c.g, c.b); + let row_bg = rgb(ui::icons::blend_over(opaque(term_bg), sk.panel)); + let (sel_bg, sel_fg) = (sk.accent, sk.ink_on_accent); + + for (row, item) in visible.iter().enumerate() { + let line = start_line + row; + if line >= screen_lines { + break; + } + let is_selected = offset + row == selected; + let (bg, label_fg, tag_fg) = if is_selected { + (sel_bg, sel_fg, sel_fg) + } else { + (row_bg, sk.ink, sk.ink_faint) + }; + let label = format!(" {}", nebula_pad_to_cells(&item.label, label_w + 2)); + let tag_text = format!("{} ", nebula_pad_to_cells(tag(item.kind), tag_w)); + let glyph_cache = &mut self.glyph_cache; + self.renderer.draw_string( + Point::new(line, Column(start_col)), + label_fg, + bg, + label.chars(), + size_info, + glyph_cache, + ); + // Fits by construction (start_col + width <= columns); the + // renderer clips at the grid edge regardless. + let tag_col = start_col + 1 + label_w + 2; + let glyph_cache = &mut self.glyph_cache; + self.renderer.draw_string( + Point::new(line, Column(tag_col)), + tag_fg, + bg, + tag_text.chars(), + size_info, + glyph_cache, + ); + } + } + fn draw_powerline_icons(&mut self, icons: &[NebulaPowerlineIcon], view: SizeInfo) { if icons.is_empty() { return; @@ -10496,7 +10936,7 @@ impl Display { &mut self, config: &UiConfig, _cursor_point: Option, - display_offset: usize, + viewport_origin: Line, ) { let num_cols = self.size_info.columns(); @@ -10510,8 +10950,8 @@ impl Display { return; }; // Hint start scrolled out of the viewport → fall back to the mouse cell. - let anchor = term::point_to_viewport(display_offset, hint_start).or_else(|| { - self.hint_mouse_point.and_then(|p| term::point_to_viewport(display_offset, p)) + let anchor = term::point_to_viewport_from(viewport_origin, hint_start).or_else(|| { + self.hint_mouse_point.and_then(|p| term::point_to_viewport_from(viewport_origin, p)) }); let Some(anchor) = anchor else { return; @@ -10692,7 +11132,7 @@ impl Display { /// (`draw_pane` 在污染 tracker 之前捕获),语义回到上游本意。 fn validate_hint_highlights( &mut self, - display_offset: usize, + viewport_origin: Line, term_damage_full: bool, term_damage_lines: &[LineDamageBounds], ) { @@ -10715,10 +11155,10 @@ impl Display { } // Convert hint bounds to viewport coordinates. - let start = term::point_to_viewport(display_offset, start) + let start = term::point_to_viewport_from(viewport_origin, start) .filter(|point| point.line < num_lines) .unwrap_or_default(); - let end = term::point_to_viewport(display_offset, end) + let end = term::point_to_viewport_from(viewport_origin, end) .filter(|point| point.line < num_lines) .unwrap_or_else(|| Point::new(num_lines - 1, self.size_info.last_column())); @@ -10988,9 +11428,10 @@ mod nebula_ux_tests { use winit::window::Theme as WinitTheme; use super::{ - AI_LOGO_GROK_DARK_PNG, AI_LOGO_GROK_LIGHT_PNG, AiLogo, NebulaConfirm, SizeInfo, ai_logo, - alt_screen_vertical_padding_bands, compute_cell_size, extract_program, nebula_command_hint, - percent_decode_lossy, prepare_ai_logo_texture, program_icon, remove_ssh_host_from_lists, + AI_LOGO_GROK_DARK_PNG, AI_LOGO_GROK_LIGHT_PNG, AiLogo, Display, NebulaConfirm, SizeInfo, + ai_logo, alt_screen_vertical_padding_bands, compute_cell_size, extract_program, + nebula_command_hint, nebula_command_hints, nebula_pad_to_cells, percent_decode_lossy, + prepare_ai_logo_texture, program_icon, remove_ssh_host_from_lists, replays_untrusted_terminal_output, restore_ssh_host_to_lists, strip_file_scheme, system_theme_snapshot, }; @@ -11192,6 +11633,30 @@ mod nebula_ux_tests { assert_eq!(nebula_command_hint(&commands, "CLAUDE"), None); } + #[test] + fn popup_command_hints_keep_longer_neighbors_and_dedup() { + let commands = strings(&["claude", "claude-agent-acp", "claude", "cargo"]); + // 弹窗列表与 ghost 相反:完整命令也要把更长的邻居列出来。 + assert_eq!(nebula_command_hints(&commands, "claude", 8), vec!["claude-agent-acp"]); + assert_eq!(nebula_command_hints(&commands, "c", 2), vec!["claude", "claude-agent-acp"]); + assert!(nebula_command_hints(&commands, "", 8).is_empty()); + } + + #[test] + fn popup_pad_counts_display_cells_and_drops_straddling_wide_chars() { + assert_eq!(nebula_pad_to_cells("ab", 4), "ab "); + assert_eq!(nebula_pad_to_cells("目录", 4), "目录"); + // 第二个全宽字符放不进 3 格:丢弃并用空格补齐。 + assert_eq!(nebula_pad_to_cells("目录", 3), "目 "); + assert_eq!(nebula_pad_to_cells("abcd", 3), "abc"); + } + + #[test] + fn popup_label_elides_from_the_left() { + assert_eq!(Display::nebula_elide_left("short", 10), "short"); + assert_eq!(Display::nebula_elide_left("abcdefgh", 5), "…efgh"); + } + #[test] fn system_theme_snapshot_beats_a_stale_window_override() { assert_eq!( @@ -11207,11 +11672,14 @@ mod nebula_ux_tests { let mut pinned = strings(&["target", "alpha"]); let mut hidden = strings(&["already-hidden"]); - let snapshot = remove_ssh_host_from_lists("target", &mut saved, &mut pinned, &mut hidden); + let snapshot = + remove_ssh_host_from_lists("target", false, &mut saved, &mut pinned, &mut hidden); assert_eq!(snapshot, (Some(1), Some(0), false)); assert_eq!(saved, strings(&["alpha", "omega"])); assert_eq!(pinned, strings(&["alpha"])); - assert_eq!(hidden, strings(&["already-hidden", "target"])); + // A Nebula-managed host is deleted, not renamed to "hidden": nothing + // may linger in the hidden section for it. + assert_eq!(hidden, strings(&["already-hidden"])); restore_ssh_host_to_lists( "target", @@ -11234,7 +11702,7 @@ mod nebula_ux_tests { let mut hidden = Vec::new(); let snapshot = - remove_ssh_host_from_lists("config-alias", &mut saved, &mut pinned, &mut hidden); + remove_ssh_host_from_lists("config-alias", true, &mut saved, &mut pinned, &mut hidden); assert_eq!(snapshot, (None, None, false)); assert_eq!(hidden, strings(&["config-alias"])); @@ -11252,6 +11720,34 @@ mod nebula_ux_tests { assert!(hidden.is_empty()); } + /// A host that exists both as a saved entry and as a `~/.ssh/config` + /// alias must be hidden on top of the saved-list removal, otherwise the + /// config merge resurrects it on the next restart. + #[test] + fn ssh_delete_of_a_config_backed_saved_host_also_hides_the_alias() { + let mut saved = strings(&["dual"]); + let mut pinned = Vec::new(); + let mut hidden = Vec::new(); + + let snapshot = + remove_ssh_host_from_lists("dual", true, &mut saved, &mut pinned, &mut hidden); + assert_eq!(snapshot, (Some(0), None, false)); + assert!(saved.is_empty()); + assert_eq!(hidden, strings(&["dual"])); + + restore_ssh_host_to_lists( + "dual", + snapshot.0, + snapshot.1, + snapshot.2, + &mut saved, + &mut pinned, + &mut hidden, + ); + assert_eq!(saved, strings(&["dual"])); + assert!(hidden.is_empty()); + } + #[test] fn asymmetric_bottom_reserve_recovers_rows_hidden_by_top_chrome() { let size = SizeInfo::new_fully_asymmetric(1000.0, 1000.0, 10.0, 20.0, 0.0, 0.0, 64.0, 16.0); diff --git a/nebula_app/src/display/settings.rs b/nebula_app/src/display/settings.rs index a4d8dfc..2d7f3c9 100644 --- a/nebula_app/src/display/settings.rs +++ b/nebula_app/src/display/settings.rs @@ -28,7 +28,7 @@ use super::keymap; use super::ui::theme::Skin; use super::ui::{icons, os_icons, surface, text_field, tokens, widgets}; use super::{ - AcceptKey, LanguagePreference, NebulaShell, NebulaTheme, SizeInfo, UiLanguage, + AcceptKey, CompletionStyle, LanguagePreference, NebulaShell, NebulaTheme, SizeInfo, UiLanguage, chrome_settings_button_rect, contains_rect, nebula_data_dir, truncate_tab_label, }; @@ -124,6 +124,7 @@ pub enum SettingsDropdown { BackgroundAlignment, Language, Accept, + CompletionStyle, CursorShape, TabReveal, /// 代理:SSH 连接代理模式(关闭/系统/自定义)。 @@ -182,6 +183,9 @@ pub(super) const LANGUAGE_OPTIONS: [LanguagePreference; 3] = pub(super) const ACCEPT_OPTIONS: [AcceptKey; 3] = [AcceptKey::Both, AcceptKey::Tab, AcceptKey::Right]; +pub(super) const COMPLETION_STYLE_OPTIONS: [CompletionStyle; 2] = + [CompletionStyle::Inline, CompletionStyle::Popup]; + pub(super) const TAB_REVEAL_OPTIONS: [TabRevealMotion; 2] = [TabRevealMotion::Slide, TabRevealMotion::Instant]; @@ -488,6 +492,13 @@ fn accept_label(accept: AcceptKey, language: UiLanguage) -> &'static str { } } +fn completion_style_label(style: CompletionStyle, language: UiLanguage) -> &'static str { + match style { + CompletionStyle::Inline => language.pick("行内灰字", "Inline ghost"), + CompletionStyle::Popup => language.pick("弹窗列表", "Popup list"), + } +} + fn language_label(preference: LanguagePreference, language: UiLanguage) -> &'static str { match preference { LanguagePreference::System => language.pick("跟随系统", "Follow system"), @@ -508,6 +519,9 @@ pub enum SettingsHit { SystemThemeToggle, GhostToggle, AcceptCycle, + /// Completion style combobox trigger + its expanded option rows. + CompletionStyleCycle, + CompletionStyleOption(usize), ShellCycle, StartupDirectory, StartupDirectoryClear, @@ -555,8 +569,9 @@ pub enum SettingsHit { SshHostConnect(usize), /// SSH settings page: edit a saved destination. SshHostEdit(usize), - /// SSH settings page: hide a destination without editing ~/.ssh/config. - SshHostHide(usize), + /// SSH settings page: delete a saved destination (config aliases are + /// hidden instead — Nebula never edits ~/.ssh/config). + SshHostDelete(usize), /// SSH settings page: re-read ~/.ssh/config immediately. SshImportConfig, /// SSH settings page: open the existing SSH editor for a new host. @@ -577,6 +592,10 @@ pub enum SettingsHit { BlurToggle, OpacitySlider, BackgroundColor, + /// 背景色浮层:真调色盘的饱和度/明度面(按下开始拖拽取色)。 + BackgroundSvPlane, + /// 背景色浮层:色相横条。 + BackgroundHueBar, /// 背景色浮层:色板网格里的一格。 BackgroundSwatch(usize), /// 背景色浮层:16 进制输入框。 @@ -596,6 +615,8 @@ pub enum SettingsHit { KeepSessionToggle, /// 高级: 启动时恢复上次会话(也是崩溃恢复的总开关)。 RestoreSessionToggle, + /// 高级: 冷恢复时自动接续各 pane 里的 AI 对话(claude/codex resume)。 + ResumeAiToggle, /// 高级→同步: 输入框(0=url 1=用户名 2=WebDAV 密码 3=E2E 口令)。 SyncInput(usize), SyncAutoPullToggle, @@ -636,7 +657,7 @@ pub enum SettingsHit { /// Stable animation slots for the settings switches. Rendering and the shared /// motion clock both use this mapping, so one switch can never borrow another /// switch's thumb position while the pointer moves between rows. -pub(super) const SETTINGS_TOGGLE_COUNT: usize = 15; +pub(super) const SETTINGS_TOGGLE_COUNT: usize = 16; pub(super) fn settings_toggle_slot(hit: SettingsHit) -> Option { Some(match hit { @@ -655,6 +676,8 @@ pub(super) fn settings_toggle_slot(hit: SettingsHit) -> Option { SettingsHit::BackgroundImageCoverChrome => 12, SettingsHit::ProviderCodexGoalsToggle => 13, SettingsHit::ProviderCodexRemoteToggle => 14, + // 追加在尾部:槽位是稳定映射,重排会让开关借走彼此的动画状态。 + SettingsHit::ResumeAiToggle => 15, _ => return None, }) } @@ -665,6 +688,8 @@ pub(super) struct NebulaRuntimeSettings { pub(super) language: LanguagePreference, pub(super) ghost: bool, pub(super) accept: AcceptKey, + /// Inline ghost remainder vs popup candidate list. + pub(super) completion_style: CompletionStyle, pub(super) shell: NebulaShell, /// Raw default-shell id (`shell=`), when the user picked a detected /// shell the 2-value `shell` enum can't represent (cmd, pwsh, nushell, a @@ -699,6 +724,8 @@ pub(super) struct NebulaRuntimeSettings { pub(super) keep_session: bool, /// 高级·会话:启动时恢复上次的标签。 pub(super) restore_session: bool, + /// 高级·会话:冷恢复时自动接续各 pane 的 AI 对话(claude/codex resume)。 + pub(super) resume_ai: bool, /// 窗口背景模糊。Windows 11 上是 Mica(见 /// `display::window::apply_windows_backdrop`),macOS / Wayland 上走 /// winit 自己的实现。默认开:纯 alpha 会让背景的高频细节直接透上来压在 @@ -761,6 +788,7 @@ pub(super) fn nebula_settings_load(config: &UiConfig) -> NebulaRuntimeSettings { language: LanguagePreference::System, ghost: true, accept: AcceptKey::Both, + completion_style: CompletionStyle::Inline, shell: NebulaShell::PowerShell, shell_id: None, startup_directory: None, @@ -784,6 +812,9 @@ pub(super) fn nebula_settings_load(config: &UiConfig) -> NebulaRuntimeSettings { // which reads as "the app didn't really exit" — opt IN, not out. keep_session: false, restore_session: true, + // 默认开:恢复布局却丢下正聊到一半的对话,等于只恢复了一半现场。 + // 关掉它仍恢复标签/分屏/目录,只是不再敲 resume。 + resume_ai: true, // 2026-07-31 用户裁定:默认开。纯 alpha 下背景的高频细节压着文字, // 那正是"透明度调低就看不清"的物理来源;模糊把它拍成低频色块。想 // 真透出后面窗口内容的人可以关掉。 @@ -829,6 +860,11 @@ pub(super) fn nebula_settings_load(config: &UiConfig) -> NebulaRuntimeSettings { Some(("accept", "right")) => settings.accept = AcceptKey::Right, Some(("accept", "tab")) => settings.accept = AcceptKey::Tab, Some(("accept", "both")) => settings.accept = AcceptKey::Both, + Some(("completion_style", v)) => { + if let Some(style) = CompletionStyle::from_settings(v) { + settings.completion_style = style; + } + }, Some(("shell" | "executor", v)) => { let v = v.trim(); if let Some(shell) = NebulaShell::from_settings(v) { @@ -883,6 +919,7 @@ pub(super) fn nebula_settings_load(config: &UiConfig) -> NebulaRuntimeSettings { Some(("powerline", v)) => settings.powerline = parse_bool(v, true), Some(("keep_session", v)) => settings.keep_session = parse_bool(v, false), Some(("restore_session", v)) => settings.restore_session = parse_bool(v, true), + Some(("resume_ai", v)) => settings.resume_ai = parse_bool(v, true), Some(("panel_resize", v)) => settings.panel_resize = parse_bool(v, false), Some(("sidebar_w", v)) => { if let Ok(w) = v.trim().parse::() { @@ -1067,6 +1104,7 @@ pub(super) fn nebula_settings_write(settings: &NebulaRuntimeSettings) { AcceptKey::Tab => "tab", AcceptKey::Both => "both", }; + let completion_style = settings.completion_style.settings_value(); let background = settings.background.map(format_hex_rgb).unwrap_or_default(); let background_image = settings.background_image.as_deref().unwrap_or(""); // A picked detected-shell id (cmd/pwsh/nu/wsl:X) is written verbatim; the @@ -1095,8 +1133,8 @@ pub(super) fn nebula_settings_write(settings: &NebulaRuntimeSettings) { let _ = std::fs::write( path, format!( - "language={}\ntheme={theme}\nfollow_system_theme={}\nghost={}\naccept={accept}\nshell={shell}\nstartup_directory={startup_directory}\nfont_family={}\nfont_size={font_size}\ncursor_shape={}\ncursor_blink={}\ncopy_on_select={}\ncjk_bold_regular={} -tab_reveal={}\ndensity={}\nnew_tab_position={}\ncell_width_mode={}\nfetch={}\npowerline={}\nkeep_session={}\nrestore_session={}\nblur={}\nopacity={:.2}\nbackground={background}\nbackground_image={background_image}\nbackground_image_opacity={:.2}\nbackground_image_fit={}\nbackground_image_alignment={}\nbackground_image_cover_chrome={}\npanel_resize={}\nsidebar_w={:.0}\ndrawer_w={:.0}\nhosts_band={:.0}\npinned_hosts={pinned_hosts}\nsaved_hosts={saved_hosts}\nhidden_hosts={hidden_hosts}\nssh_proxy_mode={ssh_proxy_mode}\nssh_proxy_url={ssh_proxy_url}\nssh_proxy_no_proxy={ssh_proxy_no_proxy}\nquick_terminal_hotkey={quick_terminal_hotkey}\n{keybinds}", + "language={}\ntheme={theme}\nfollow_system_theme={}\nghost={}\naccept={accept}\ncompletion_style={completion_style}\nshell={shell}\nstartup_directory={startup_directory}\nfont_family={}\nfont_size={font_size}\ncursor_shape={}\ncursor_blink={}\ncopy_on_select={}\ncjk_bold_regular={} +tab_reveal={}\ndensity={}\nnew_tab_position={}\ncell_width_mode={}\nfetch={}\npowerline={}\nkeep_session={}\nrestore_session={}\nresume_ai={}\nblur={}\nopacity={:.2}\nbackground={background}\nbackground_image={background_image}\nbackground_image_opacity={:.2}\nbackground_image_fit={}\nbackground_image_alignment={}\nbackground_image_cover_chrome={}\npanel_resize={}\nsidebar_w={:.0}\ndrawer_w={:.0}\nhosts_band={:.0}\npinned_hosts={pinned_hosts}\nsaved_hosts={saved_hosts}\nhidden_hosts={hidden_hosts}\nssh_proxy_mode={ssh_proxy_mode}\nssh_proxy_url={ssh_proxy_url}\nssh_proxy_no_proxy={ssh_proxy_no_proxy}\nquick_terminal_hotkey={quick_terminal_hotkey}\n{keybinds}", settings.language.as_str(), settings.follow_system_theme as u8, settings.ghost as u8, @@ -1113,6 +1151,7 @@ tab_reveal={}\ndensity={}\nnew_tab_position={}\ncell_width_mode={}\nfetch={}\npo settings.powerline as u8, settings.keep_session as u8, settings.restore_session as u8, + settings.resume_ai as u8, settings.blur as u8, settings.opacity, settings.background_image_opacity, @@ -1158,6 +1197,7 @@ struct SettingsGeometry { powerline: (f32, f32, f32, f32), ghost: (f32, f32, f32, f32), accept: (f32, f32, f32, f32), + completion_style: (f32, f32, f32, f32), open_config_file: (f32, f32, f32, f32), terminal_import: (f32, f32, f32, f32), ssh_host_row0: (f32, f32, f32, f32), @@ -1236,6 +1276,8 @@ struct SettingsGeometry { keep_session: (f32, f32, f32, f32), /// 高级·会话:启动时恢复上次的标签(崩溃/强杀后同样走这条路)。 restore_session: (f32, f32, f32, f32), + /// 高级·会话:冷恢复自动接续 AI 对话。 + resume_ai: (f32, f32, f32, f32), /// 网络页:主模式仍是下拉框;指定代理分支按 HTML 原型排成扫描标题、 /// 一张连续列表卡、选中项展开、绕过列表与每主机覆盖。 ssh_proxy_mode: (f32, f32, f32, f32), @@ -1269,7 +1311,7 @@ fn settings_viewport_h(popup_h: f32, scale_factor: f32) -> f32 { } fn advanced_content_end(advanced_y0: f32, sync_y0: f32, row_h: f32) -> f32 { - if SHOW_WEBDAV_SYNC_SETTINGS { sync_y0 + 7.0 * row_h } else { advanced_y0 + 2.0 * row_h } + if SHOW_WEBDAV_SYNC_SETTINGS { sync_y0 + 7.0 * row_h } else { advanced_y0 + 3.0 * row_h } } /// Max scroll offset for `section` at the current window size. The input @@ -1479,7 +1521,7 @@ fn settings_geometry( let startup_directory_y0 = terminal_import_y0 + ROW_H; let font_y0 = startup_directory_y0 + ROW_H; let ghost_y0 = font_y0 + ROW_H + GROUP_ADVANCE; - let open_y0 = ghost_y0 + 2.0 * ROW_H + GROUP_ADVANCE; + let open_y0 = ghost_y0 + 3.0 * ROW_H + GROUP_ADVANCE; let profiles_h = s(open_y0 + ROW_H + 32.0 - 72.0); // AI providers intentionally use a denser list + editor flow inspired by @@ -1584,7 +1626,8 @@ fn settings_geometry( let advanced_y0 = 146.0; let keep_session = (row_x, at(advanced_y0), row_w, row_h); let restore_session = (row_x, at(advanced_y0 + ROW_H), row_w, row_h); - let sync_y0 = advanced_y0 + ROW_H * 2.0 + GROUP_ADVANCE; + let resume_ai = (row_x, at(advanced_y0 + ROW_H * 2.0), row_w, row_h); + let sync_y0 = advanced_y0 + ROW_H * 3.0 + GROUP_ADVANCE; let sync_row = |i: f32| (row_x, at(sync_y0 + i * ROW_H), row_w, row_h); let sync_rows = [sync_row(0.0), sync_row(1.0), sync_row(2.0), sync_row(3.0)]; let sync_auto_pull = sync_row(4.0); @@ -1728,6 +1771,7 @@ fn settings_geometry( powerline: (row_x, at(terminal_appearance_y0 + 3.0 * ROW_H), row_w, row_h), ghost: (row_x, at(ghost_y0), row_w, row_h), accept: (row_x, at(ghost_y0 + ROW_H), row_w, row_h), + completion_style: (row_x, at(ghost_y0 + 2.0 * ROW_H), row_w, row_h), open_config_file: (row_x, at(open_y0), row_w, row_h), terminal_import: (row_x, at(terminal_import_y0), row_w, row_h), ssh_host_row0, @@ -1779,6 +1823,7 @@ fn settings_geometry( advanced_h, keep_session, restore_session, + resume_ai, ssh_proxy_mode, ssh_proxy_scan_head, ssh_proxy_scan_button, @@ -1853,11 +1898,36 @@ pub(crate) fn opacity_from_pointer(pointer_x: f32, slider: (f32, f32, f32, f32)) ((pointer_x - slider.0) / slider.2.max(1.0)).clamp(0.0, 1.0) } +/// 调色盘拖拽要用的 SV 面与色相条矩形。与 `opacity_slider_rect` 同款: +/// 外观页几何不依赖 shell/font/proxy/keymap 状态,默认值重建即可,绘制、 +/// 命中与拖拽三方共用同一几何来源。 +pub(crate) fn background_color_picker_rects( + size_info: &SizeInfo, + scale_factor: f32, + area: (f32, f32, f32, f32), + scroll: f32, + density: super::ui::tokens::Density, +) -> ((f32, f32, f32, f32), (f32, f32, f32, f32)) { + let geometry = settings_geometry( + size_info, + scale_factor, + area, + scroll, + 0, + 0, + density, + ProxyPaneState::default(), + KeymapPaneState::default(), + ); + let popup = background_color_popup(&geometry, scale_factor); + (popup.sv, popup.hue) +} + /// 主机行右缘的动作槽。绘制与命中共用这一处推导,是"看得见的按钮点不中"的 /// 唯一防线。 /// /// 2026-08-11 对齐原型:**主动作「连接」是文字按钮**(白底+描边+"连接"二字), -/// 编辑/隐藏是方形图标槽。原型里唯一带底的就是主动作——一行五个等价图标会让 +/// 编辑/删除是方形图标槽。原型里唯一带底的就是主动作——一行五个等价图标会让 /// 人逐个悬停去猜哪个是"进去",文字消掉这次猜测。三个槽都只在 hover 时显形, /// 静态那一行只剩身份信息。命中区比墨迹宽,指针容差不被视觉尺寸绑死。 fn ssh_host_action_rect( @@ -1871,7 +1941,7 @@ fn ssh_host_action_rect( let right = x + w - s(14.0); let slot = s(26.0).min((h - s(10.0)).max(s(18.0))); let connect_w = s(52.0); - // 从右往左排:隐藏、编辑、连接。连接最宽,所以单独算。 + // 从右往左排:删除、编辑、连接。连接最宽,所以单独算。 match action { 2 => { let bx = right - slot; @@ -1937,10 +2007,15 @@ pub(super) fn appearance_preview_wallpaper_rects( /// The combobox anchor rect + option count for `dropdown`, IF it belongs to /// the active section. Hit-testing, popup quads and popup text all resolve /// the floating list through this one helper so the three can never disagree. -/// 背景色浮层的几何:面板矩形、12 个色板格、16 进制输入框。绘制与命中 -/// 测试共用这一个来源(组件化范式:几何同源,控件与点击区不漂移)。 +/// 背景色浮层的几何:真调色盘(SV 面 + 色相条)、12 个预设色板格、16 进制 +/// 输入框。绘制与命中测试共用这一个来源(组件化范式:几何同源,控件与 +/// 点击区不漂移)。 pub(super) struct BackgroundColorPopup { pub(super) rect: (f32, f32, f32, f32), + /// 饱和度(→右增)/ 明度(→下减)取色面,底色跟随当前色相。 + pub(super) sv: (f32, f32, f32, f32), + /// 色相横条(0–360°)。 + pub(super) hue: (f32, f32, f32, f32), pub(super) swatch: [(f32, f32, f32, f32); 12], pub(super) hex: (f32, f32, f32, f32), } @@ -1957,21 +2032,75 @@ pub(super) fn background_color_popup( let pad = s(12.0); let grid_w = COLS as f32 * cell + (COLS - 1) as f32 * gap; let grid_h = 2.0 * cell + gap; + let sv_h = s(128.0); + let hue_h = s(14.0); let hex_h = s(34.0); let w = (grid_w + 2.0 * pad).max(aw); - let h = pad + grid_h + gap + hex_h + pad; + let h = pad + sv_h + gap + hue_h + gap + grid_h + gap + hex_h + pad; // 与 combobox 浮层同规则:锚行右缘对齐,紧贴行下方展开。 let x = ax + aw - w; let y = ay + ah + s(6.0); + let sv = (x + pad, y + pad, w - 2.0 * pad, sv_h); + let hue = (x + pad, y + pad + sv_h + gap, w - 2.0 * pad, hue_h); + let grid_y = y + pad + sv_h + gap + hue_h + gap; let mut swatch = [(0.0, 0.0, 0.0, 0.0); 12]; for (i, rect) in swatch.iter_mut().enumerate() { let row = i / COLS; let col = i % COLS; *rect = - (x + pad + col as f32 * (cell + gap), y + pad + row as f32 * (cell + gap), cell, cell); + (x + pad + col as f32 * (cell + gap), grid_y + row as f32 * (cell + gap), cell, cell); } - let hex = (x + pad, y + pad + grid_h + gap, w - 2.0 * pad, hex_h); - BackgroundColorPopup { rect: (x, y, w, h), swatch, hex } + let hex = (x + pad, grid_y + grid_h + gap, w - 2.0 * pad, hex_h); + BackgroundColorPopup { rect: (x, y, w, h), sv, hue, swatch, hex } +} + +/// HSV → RGB。`h` 度(任意实数,内部归一到 [0,360)),`s`/`v` ∈ [0,1]。 +pub(crate) fn hsv_to_rgb(h: f32, s: f32, v: f32) -> Rgb { + let h = h.rem_euclid(360.0); + let s = s.clamp(0.0, 1.0); + let v = v.clamp(0.0, 1.0); + let c = v * s; + let x = c * (1.0 - ((h / 60.0) % 2.0 - 1.0).abs()); + let m = v - c; + let (r, g, b) = match h { + _ if h < 60.0 => (c, x, 0.0), + _ if h < 120.0 => (x, c, 0.0), + _ if h < 180.0 => (0.0, c, x), + _ if h < 240.0 => (0.0, x, c), + _ if h < 300.0 => (x, 0.0, c), + _ => (c, 0.0, x), + }; + let to = |f: f32| ((f + m) * 255.0).round().clamp(0.0, 255.0) as u8; + Rgb::new(to(r), to(g), to(b)) +} + +/// RGB → HSV。灰色(s = 0)时 h 报 0;调用方要在拖动中保住既有色相, +/// 别用本函数的返回覆盖它(见 `Display::open_background_color_picker`)。 +pub(crate) fn rgb_to_hsv(color: Rgb) -> (f32, f32, f32) { + let r = color.r as f32 / 255.0; + let g = color.g as f32 / 255.0; + let b = color.b as f32 / 255.0; + let max = r.max(g).max(b); + let min = r.min(g).min(b); + let delta = max - min; + let h = if delta <= f32::EPSILON { + 0.0 + } else if max == r { + 60.0 * (((g - b) / delta).rem_euclid(6.0)) + } else if max == g { + 60.0 * ((b - r) / delta + 2.0) + } else { + 60.0 * ((r - g) / delta + 4.0) + }; + let s = if max <= f32::EPSILON { 0.0 } else { delta / max }; + (h, s, max) +} + +/// 调色盘拖拽的目标部件。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BgPickerPart { + Sv, + Hue, } /// 字体弹层的总行数:候选行 + 顶部那个搜索框。 @@ -2033,6 +2162,9 @@ fn dropdown_anchor( (Section::Profiles, SettingsDropdown::Accept) => { Some((anchor(geometry.accept), ACCEPT_OPTIONS.len())) }, + (Section::Profiles, SettingsDropdown::CompletionStyle) => { + Some((anchor(geometry.completion_style), COMPLETION_STYLE_OPTIONS.len())) + }, (Section::Appearance, SettingsDropdown::CellWidthMode) => { Some((anchor(geometry.cell_width_mode), CELL_WIDTH_MODE_OPTIONS.len())) }, @@ -2316,6 +2448,12 @@ pub fn settings_hit( if dropdown == SettingsDropdown::BackgroundColor { if section == NebulaSettingsSection::Appearance { let popup = background_color_popup(&geometry, scale_factor); + if contains_rect(popup.sv, x, y) { + return SettingsHit::BackgroundSvPlane; + } + if contains_rect(popup.hue, x, y) { + return SettingsHit::BackgroundHueBar; + } for (index, rect) in popup.swatch.iter().enumerate() { if contains_rect(*rect, x, y) { return SettingsHit::BackgroundSwatch(index); @@ -2359,6 +2497,7 @@ pub fn settings_hit( SettingsDropdown::BackgroundAlignment => SettingsHit::AlignOption(index), SettingsDropdown::Language => SettingsHit::Language(LANGUAGE_OPTIONS[index]), SettingsDropdown::Accept => SettingsHit::AcceptOption(index), + SettingsDropdown::CompletionStyle => SettingsHit::CompletionStyleOption(index), SettingsDropdown::TabReveal => SettingsHit::TabRevealOption(index), SettingsDropdown::Density => SettingsHit::DensityOption(index), SettingsDropdown::NewTabPosition => SettingsHit::NewTabPositionOption(index), @@ -2521,6 +2660,13 @@ pub fn settings_hit( if contains_rect(widgets::combobox_rect(geometry.accept, scale_factor), x, y) { return SettingsHit::AcceptCycle; } + if contains_rect( + widgets::combobox_rect(geometry.completion_style, scale_factor), + x, + y, + ) { + return SettingsHit::CompletionStyleCycle; + } if contains_rect( row_action_rect(geometry.open_config_file, scale_factor, STANDARD_ROW_ACTION_W), x, @@ -2599,7 +2745,7 @@ pub fn settings_hit( return SettingsHit::SshHostEdit(index); } if contains_rect(ssh_host_action_rect(row, scale_factor, 2), x, y) { - return SettingsHit::SshHostHide(index); + return SettingsHit::SshHostDelete(index); } // 三枚图标之后才轮到行本体,顺序反了图标就永远拿不到命中。 if contains_rect(row, x, y) { @@ -2702,6 +2848,9 @@ pub fn settings_hit( { return SettingsHit::RestoreSessionToggle; } + if contains_rect(widgets::toggle_rect(geometry.resume_ai, scale_factor), x, y) { + return SettingsHit::ResumeAiToggle; + } if SHOW_WEBDAV_SYNC_SETTINGS { for (index, rect) in geometry.sync_rows.iter().enumerate() { // 命中整行都算输入框:行左侧是它的 label,点标签聚焦 @@ -2780,6 +2929,7 @@ pub(super) struct SettingsView { pub(super) follow_system_theme: bool, pub(super) ghost: bool, pub(super) accept: AcceptKey, + pub(super) completion_style: CompletionStyle, /// Pre-rendered "默认 Shell" value (icon + name) — resolved by `Display` /// from the rich `shell_id` when set, else the 2-value enum label. pub(super) shell_label: String, @@ -2824,6 +2974,8 @@ pub(super) struct SettingsView { pub(super) keep_session: bool, /// 高级·会话:启动时恢复上次的标签。 pub(super) restore_session: bool, + /// 高级·会话:冷恢复自动接续 AI 对话。 + pub(super) resume_ai: bool, pub(super) blur: bool, pub(super) opacity: f32, /// Which opacity slider is mid-drag, for thumb-dot grow feedback. @@ -2846,6 +2998,9 @@ pub(super) struct SettingsView { /// 背景色浮层的 16 进制草稿(形如 `#0A0C18`)与输入聚焦态。 pub(super) bg_hex_input: String, pub(super) bg_hex_active: bool, + /// 调色盘草稿 HSV(打开浮层时从生效色初始化;拖动期间是唯一权威, + /// 灰色/黑白下的色相不会因 RGB 往返而丢失)。 + pub(super) bg_picker_hsv: (f32, f32, f32), pub(super) background_image: Option, pub(super) background_image_opacity: f32, pub(super) background_image_fit: BackgroundImageFit, @@ -3245,6 +3400,9 @@ fn dropdown_selected_index(view: &SettingsView, dropdown: SettingsDropdown) -> O LANGUAGE_OPTIONS.iter().position(|preference| *preference == view.language_preference) }, SettingsDropdown::Accept => ACCEPT_OPTIONS.iter().position(|key| *key == view.accept), + SettingsDropdown::CompletionStyle => { + COMPLETION_STYLE_OPTIONS.iter().position(|style| *style == view.completion_style) + }, SettingsDropdown::TabReveal => { TAB_REVEAL_OPTIONS.iter().position(|motion| *motion == view.tab_reveal) }, @@ -3282,6 +3440,9 @@ fn dropdown_hover_index(hover: SettingsHit, dropdown: SettingsDropdown) -> Optio LANGUAGE_OPTIONS.iter().position(|option| *option == preference) }, (SettingsDropdown::Accept, SettingsHit::AcceptOption(index)) => Some(index), + (SettingsDropdown::CompletionStyle, SettingsHit::CompletionStyleOption(index)) => { + Some(index) + }, (SettingsDropdown::TabReveal, SettingsHit::TabRevealOption(index)) => Some(index), (SettingsDropdown::Density, SettingsHit::DensityOption(index)) => Some(index), (SettingsDropdown::NewTabPosition, SettingsHit::NewTabPositionOption(index)) => Some(index), @@ -3843,7 +4004,7 @@ pub(super) fn push_quads( group_frame(quads, geometry.shell, 2); group_frame(quads, geometry.startup_directory, 1); group_frame(quads, geometry.font, 1); - group_frame(quads, geometry.ghost, 2); + group_frame(quads, geometry.ghost, 3); group_frame(quads, geometry.open_config_file, 1); for (hit, rect) in [ (SettingsHit::ShellCycle, geometry.shell), @@ -3852,6 +4013,7 @@ pub(super) fn push_quads( (SettingsHit::FontCycle, geometry.font), (SettingsHit::GhostToggle, geometry.ghost), (SettingsHit::AcceptCycle, geometry.accept), + (SettingsHit::CompletionStyleCycle, geometry.completion_style), (SettingsHit::OpenConfigFile, geometry.open_config_file), ] { row_hover(quads, rect, view.hover == hit); @@ -3896,6 +4058,13 @@ pub(super) fn push_quads( view.hover == SettingsHit::AcceptCycle, view.dropdown == Some(SettingsDropdown::Accept), ); + combobox( + quads, + &mut staged, + geometry.completion_style, + view.hover == SettingsHit::CompletionStyleCycle, + view.dropdown == Some(SettingsDropdown::CompletionStyle), + ); // Boolean rows render a real switch instead of an "On/Off" string. for (rect, on) in [(geometry.ghost, view.ghost)] { toggle( @@ -4031,7 +4200,7 @@ pub(super) fn push_quads( SettingsHit::SshHostRow(i) | SettingsHit::SshHostConnect(i) | SettingsHit::SshHostEdit(i) - | SettingsHit::SshHostHide(i) + | SettingsHit::SshHostDelete(i) if i == index ); if row_hovered { @@ -4060,13 +4229,16 @@ pub(super) fn push_quads( } // 次级动作用图标,挖空色取图标脚下的实际底色——半透明的 // hover wash 叠起来若用错基色,眼睛的瞳孔周围会留一圈脏边。 - for (action, icon) in - [(1usize, icons::RowActionIcon::Edit), (2usize, icons::RowActionIcon::Hide)] - { + // 第三槽是真删除(带确认 + 撤销),不再伪装成"隐藏"眼睛: + // 用户找的是删除,config 别名的差异由确认弹窗的文案说明。 + for (action, icon) in [ + (1usize, icons::RowActionIcon::Edit), + (2usize, icons::RowActionIcon::Delete), + ] { let rect = ssh_host_action_rect(row, scale, action); let icon_hovered = match (action, view.hover) { (1, SettingsHit::SshHostEdit(i)) => i == index, - (2, SettingsHit::SshHostHide(i)) => i == index, + (2, SettingsHit::SshHostDelete(i)) => i == index, _ => false, }; let (ink, cutout) = if icon_hovered { @@ -4461,7 +4633,7 @@ pub(super) fn push_quads( } }, NebulaSettingsSection::Advanced => { - group_frame(quads, geometry.keep_session, 2); + group_frame(quads, geometry.keep_session, 3); row_hover(quads, geometry.keep_session, view.hover == SettingsHit::KeepSessionToggle); toggle( quads, @@ -4486,6 +4658,16 @@ pub(super) fn push_quads( view.hover == SettingsHit::RestoreSessionToggle, view.pressed == SettingsHit::RestoreSessionToggle, ); + row_hover(quads, geometry.resume_ai, view.hover == SettingsHit::ResumeAiToggle); + toggle( + quads, + &mut staged, + geometry.resume_ai, + SettingsHit::ResumeAiToggle, + view.resume_ai, + view.hover == SettingsHit::ResumeAiToggle, + view.pressed == SettingsHit::ResumeAiToggle, + ); if SHOW_WEBDAV_SYNC_SETTINGS { // ---- 同步(WebDAV,spec 003):4 输入行 + 自动拉取开关 ---- @@ -4785,6 +4967,102 @@ pub(super) fn push_popup_quads( quads.push(UiQuad::solid(px2, py2, pw2, ph2, s(10.0), plate)); quads.push(UiQuad::solid(px2, py2, pw2, ph2, s(10.0), sk.surface)); + // ---- 真调色盘:SV 取色面 + 色相条 ---- + // 连续渐变用小色块阵列近似:UiQuad 只有纯色,24×16 的格阵在 + // 128px 高的面上每格 ~8px,肉眼已无明显色带;总量 ~400 quad, + // 相对设置页整体的 quad 预算可忽略。 + let (h0, s0, v0) = view.bg_picker_hsv; + let (svx, svy, svw, svh) = popup.sv; + surface::push_stroke(quads, popup.sv, tokens::radius::CHIP * scale, scale, sk.hairline); + const SV_COLS: usize = 24; + const SV_ROWS: usize = 16; + let cell_w = svw / SV_COLS as f32; + let cell_h = svh / SV_ROWS as f32; + for row in 0..SV_ROWS { + for col in 0..SV_COLS { + // 格中心取样:边缘格也能到达 s/v 的 0 和 1 近旁。 + let sat = (col as f32 + 0.5) / SV_COLS as f32; + let val = 1.0 - (row as f32 + 0.5) / SV_ROWS as f32; + let c = hsv_to_rgb(h0, sat, val); + quads.push(UiQuad::solid( + svx + col as f32 * cell_w, + svy + row as f32 * cell_h, + cell_w + 0.5, + cell_h + 0.5, + 0.0, + Rgba::new(c.r, c.g, c.b, 255), + )); + } + } + // 当前取点:白环 + 黑环双圈,在亮暗底上都可见。 + let dot_x = svx + s0.clamp(0.0, 1.0) * svw; + let dot_y = svy + (1.0 - v0.clamp(0.0, 1.0)) * svh; + let dot_r = s(6.0); + quads.push(UiQuad::solid( + dot_x - dot_r, + dot_y - dot_r, + dot_r * 2.0, + dot_r * 2.0, + dot_r, + Rgba::new(255, 255, 255, 255), + )); + quads.push(UiQuad::solid( + dot_x - dot_r + s(1.5), + dot_y - dot_r + s(1.5), + (dot_r - s(1.5)) * 2.0, + (dot_r - s(1.5)) * 2.0, + dot_r - s(1.5), + Rgba::new(0, 0, 0, 200), + )); + let picked = hsv_to_rgb(h0, s0, v0); + quads.push(UiQuad::solid( + dot_x - dot_r + s(3.0), + dot_y - dot_r + s(3.0), + (dot_r - s(3.0)) * 2.0, + (dot_r - s(3.0)) * 2.0, + dot_r - s(3.0), + Rgba::new(picked.r, picked.g, picked.b, 255), + )); + + // 色相条:36 段近似 0–360°,游标为竖向双色线。 + let (hux, huy, huw, huh) = popup.hue; + surface::push_stroke(quads, popup.hue, tokens::radius::CHIP * scale, scale, sk.hairline); + const HUE_STEPS: usize = 36; + let hue_w = huw / HUE_STEPS as f32; + for step in 0..HUE_STEPS { + let hue = (step as f32 + 0.5) / HUE_STEPS as f32 * 360.0; + let c = hsv_to_rgb(hue, 1.0, 1.0); + quads.push(UiQuad::solid( + hux + step as f32 * hue_w, + huy, + hue_w + 0.5, + huh, + 0.0, + Rgba::new(c.r, c.g, c.b, 255), + )); + } + // 游标:白底黑芯的胶囊竖线,在任意色相段上都可见。圆角从自身宽度 + // 派生(胶囊),不是阶梯常量。 + let cursor_x = hux + (h0.rem_euclid(360.0) / 360.0) * huw; + let cursor_outer = s(4.0); + let cursor_inner = s(2.0); + quads.push(UiQuad::solid( + cursor_x - cursor_outer * 0.5, + huy - cursor_inner, + cursor_outer, + huh + cursor_inner * 2.0, + cursor_outer * 0.5, + Rgba::new(255, 255, 255, 255), + )); + quads.push(UiQuad::solid( + cursor_x - cursor_inner * 0.5, + huy - cursor_inner * 0.5, + cursor_inner, + huh + cursor_inner, + cursor_inner * 0.5, + Rgba::new(0, 0, 0, 180), + )); + let selected = dropdown_selected_index(view, dropdown); for (index, rect) in popup.swatch.iter().enumerate() { let (sx, sy, sw2, sh2) = *rect; @@ -5077,6 +5355,9 @@ pub(super) fn draw_popup_text( language_label(LANGUAGE_OPTIONS[index], language).to_owned() }, SettingsDropdown::Accept => accept_label(ACCEPT_OPTIONS[index], language).to_owned(), + SettingsDropdown::CompletionStyle => { + completion_style_label(COMPLETION_STYLE_OPTIONS[index], language).to_owned() + }, SettingsDropdown::TabReveal => { tab_reveal_label(TAB_REVEAL_OPTIONS[index], language).to_owned() }, @@ -6138,6 +6419,26 @@ pub(super) fn draw_text( sk.accent, ); } + if visible(geometry.completion_style.1, geometry.completion_style.3) { + row_label( + r, + gc, + size, + scale, + &sk, + geometry.completion_style, + language.pick("补全样式", "Completion style"), + "", + sk.ink, + ); + combobox_value( + r, + gc, + geometry.completion_style, + completion_style_label(view.completion_style, language), + sk.accent, + ); + } let (ocx, ocy, _ocw, och) = geometry.open_config_file; if visible(group_y(ocy), title_h) { @@ -6416,7 +6717,7 @@ pub(super) fn draw_text( if host.pinned { r.draw_chrome_text(size, row.0 + s(27.0), title_y, sk.accent, "\u{eab4}", gc); } - // 编辑 / 隐藏是纯图标(quad pass 画),主动作「连接」保留文字: + // 编辑 / 删除是纯图标(quad pass 画),主动作「连接」保留文字: // 一行全是等价图标会逼人逐个悬停去猜哪个是"进去"。整组同样只在 // hover 时显形。 let row_hovered = matches!( @@ -6424,7 +6725,7 @@ pub(super) fn draw_text( SettingsHit::SshHostRow(i) | SettingsHit::SshHostConnect(i) | SettingsHit::SshHostEdit(i) - | SettingsHit::SshHostHide(i) + | SettingsHit::SshHostDelete(i) if i == index ); if row_hovered { @@ -7026,6 +7327,26 @@ pub(super) fn draw_text( ); } } + { + let (_, ry, _, rh) = geometry.resume_ai; + if visible(ry, rh) { + // 关掉只是不敲 resume 命令:标签、分屏、目录照常恢复。 + row_label( + r, + gc, + size, + scale, + &sk, + geometry.resume_ai, + language.pick( + "恢复时自动接续 AI 对话(claude / codex 自动 resume)", + "Resume AI conversations on restore (claude / codex)", + ), + "", + sk.ink, + ); + } + } if SHOW_WEBDAV_SYNC_SETTINGS { // ---- 同步(WebDAV)---- @@ -7324,16 +7645,69 @@ mod tests { use super::{ CELL_WIDTH_MODE_OPTIONS, CellWidthMode, KeymapPaneState, ManualProxyProtocol, NEW_TAB_POSITION_OPTIONS, NebulaSettingsSection, NewTabPosition, ProxyChoice, - ProxyPaneState, SHOW_BACKUP_SETTINGS, SHOW_WEBDAV_SYNC_SETTINGS, STANDARD_ROW_ACTION_W, - SettingsHit, TabRevealMotion, UiLanguage, advanced_content_end, cell_width_mode_label, - font_popup_row_count, font_popup_slot, manual_proxy_parts, manual_proxy_value, - new_tab_position_label, opacity_from_pointer, proxy_section_title_y, row_action_rect, - settings_geometry, settings_hit, ssh_proxy_manual_controls, + ProxyPaneState, Rgb, SHOW_BACKUP_SETTINGS, SHOW_WEBDAV_SYNC_SETTINGS, + STANDARD_ROW_ACTION_W, SettingsHit, TabRevealMotion, UiLanguage, advanced_content_end, + background_color_popup, cell_width_mode_label, font_popup_row_count, font_popup_slot, + hsv_to_rgb, manual_proxy_parts, manual_proxy_value, new_tab_position_label, + opacity_from_pointer, proxy_section_title_y, rgb_to_hsv, row_action_rect, + settings_geometry, settings_hit, ssh_proxy_manual_controls, ssh_proxy_mode_control, + ssh_proxy_test_button, }; use crate::display::SizeInfo; use crate::display::ui::tokens::Density; use crate::display::ui::widgets; + #[test] + fn hsv_rgb_round_trip_and_gray_hue_convention() { + // 三原色与灰阶的锚点值。 + assert_eq!(hsv_to_rgb(0.0, 1.0, 1.0), Rgb::new(255, 0, 0)); + assert_eq!(hsv_to_rgb(120.0, 1.0, 1.0), Rgb::new(0, 255, 0)); + assert_eq!(hsv_to_rgb(240.0, 1.0, 1.0), Rgb::new(0, 0, 255)); + assert_eq!(hsv_to_rgb(0.0, 0.0, 1.0), Rgb::new(255, 255, 255)); + assert_eq!(hsv_to_rgb(123.0, 0.7, 0.0), Rgb::new(0, 0, 0)); + // 负角度与超圈角度归一(拖拽把手不夹角度)。 + assert_eq!(hsv_to_rgb(360.0, 1.0, 1.0), hsv_to_rgb(0.0, 1.0, 1.0)); + assert_eq!(hsv_to_rgb(-120.0, 1.0, 1.0), hsv_to_rgb(240.0, 1.0, 1.0)); + + // 往返:RGB → HSV → RGB 在 8bit 量化内闭合。 + for color in [Rgb::new(8, 10, 24), Rgb::new(253, 246, 227), Rgb::new(40, 42, 54)] { + let (h, s, v) = rgb_to_hsv(color); + let back = hsv_to_rgb(h, s, v); + assert!( + (back.r as i32 - color.r as i32).abs() <= 1 + && (back.g as i32 - color.g as i32).abs() <= 1 + && (back.b as i32 - color.b as i32).abs() <= 1, + "{color:?} -> ({h},{s},{v}) -> {back:?}" + ); + } + // 灰色的色相按约定报 0,饱和度 0。 + assert_eq!(rgb_to_hsv(Rgb::new(128, 128, 128)).0, 0.0); + assert_eq!(rgb_to_hsv(Rgb::new(128, 128, 128)).1, 0.0); + } + + #[test] + fn color_picker_popup_keeps_sv_hue_and_swatches_disjoint() { + // 三个交互区不重叠:SV 面、色相条、第一格色板按序垂直排布。 + let size = SizeInfo::new(1280.0, 900.0, 8.0, 16.0, 0.0, 0.0, false); + let geometry = settings_geometry( + &size, + 1.0, + (0.0, 0.0, 1280.0, 900.0), + 0.0, + 0, + 0, + Density::Standard, + ProxyPaneState::default(), + KeymapPaneState::default(), + ); + let popup = background_color_popup(&geometry, 1.0); + assert!(popup.sv.1 + popup.sv.3 <= popup.hue.1); + assert!(popup.hue.1 + popup.hue.3 <= popup.swatch[0].1); + assert!(popup.swatch[11].1 + popup.swatch[11].3 <= popup.hex.1); + // 全部落在面板内。 + assert!(popup.hex.1 + popup.hex.3 <= popup.rect.1 + popup.rect.3); + } + #[test] fn the_font_popup_reserves_its_first_row_for_the_search_field() { // 搜索框占掉第 0 行,选项整体下移一行。用「多算一行」而不是另开一套 @@ -7359,8 +7733,9 @@ mod tests { #[test] fn hidden_webdav_group_does_not_extend_advanced_content() { assert!(!SHOW_WEBDAV_SYNC_SETTINGS); - // SSH 已迁到独立页面;隐藏同步组不能继续把 Advanced 撑高。 - assert_eq!(advanced_content_end(146.0, 308.0, 44.0), 234.0); + // SSH 已迁到独立页面;隐藏同步组不能继续把 Advanced 撑高。会话组 + // 现为 3 行:保留会话 / 恢复会话 / 恢复时接续 AI 对话(resume_ai)。 + assert_eq!(advanced_content_end(146.0, 308.0, 44.0), 278.0); } #[test] @@ -7497,19 +7872,22 @@ mod tests { assert_eq!(hit, SettingsHit::ProviderRow(13)); } + /// 网络页收口成单张紧凑卡后的几何合同:只有**模式 = 自定义**才展开 + /// 地址行(撑高滚动区);旧扫描/发现列表/覆盖行已经全部收成零尺寸, + /// 绘制与命中都不得再消费它们——这条测试原来验证的是旧多组件布局, + /// 重设计时一并改写。 #[test] - fn custom_proxy_geometry_expands_selected_component_and_scroll_height() { + fn custom_proxy_mode_expands_the_card_and_legacy_scan_geometry_stays_zero() { let size = proxy_test_size(); let area = (0.0, 0.0, 1200.0, 900.0); - let manual = ProxyPaneState { + let custom = ProxyPaneState { mode: crate::ssh_proxy::ProxyMode::Custom, choice: ProxyChoice::Manual, found_count: 3, override_count: 2, ..Default::default() }; - let detected = ProxyPaneState { choice: ProxyChoice::Detected(0), ..manual }; - let manual_geometry = settings_geometry( + let custom_geometry = settings_geometry( &size, 1.0, area, @@ -7517,10 +7895,10 @@ mod tests { 0, 4, Density::Standard, - manual, + custom, KeymapPaneState::default(), ); - let detected_geometry = settings_geometry( + let off_geometry = settings_geometry( &size, 1.0, area, @@ -7528,13 +7906,24 @@ mod tests { 0, 4, Density::Standard, - detected, + ProxyPaneState::default(), KeymapPaneState::default(), ); - assert!(manual_geometry.ssh_proxy_list.3 > detected_geometry.ssh_proxy_list.3); - assert!(manual_geometry.proxy_h > detected_geometry.proxy_h); - assert!(manual_geometry.ssh_proxy_expand.1 > manual_geometry.ssh_proxy_other_rows[0].1); - assert!(manual_geometry.ssh_proxy_expand.1 < manual_geometry.ssh_proxy_other_rows[1].1); + // 自定义模式追加地址行,页面必须比关闭模式高。 + assert!(custom_geometry.proxy_h > off_geometry.proxy_h); + // 展开行紧跟模式行之后。 + assert!(custom_geometry.ssh_proxy_expand.1 > custom_geometry.ssh_proxy_mode.1); + // 旧结构零尺寸(即便 found/override 计数非零也不得复活)。 + for legacy in [ + custom_geometry.ssh_proxy_list, + custom_geometry.ssh_proxy_scan_button, + custom_geometry.ssh_proxy_scan_head, + custom_geometry.ssh_proxy_found_row0, + custom_geometry.ssh_proxy_override_row0, + custom_geometry.ssh_proxy_other_rows[0], + ] { + assert_eq!((legacy.2, legacy.3), (0.0, 0.0), "legacy proxy geometry must stay zero"); + } } #[test] @@ -7556,13 +7945,16 @@ mod tests { assert!(title_y + 26.0 <= geometry.ssh_proxy_test.1); } + /// 紧凑代理卡的命中合同:自定义模式下可点的只有模式下拉、协议下拉、 + /// 地址输入和出网测试按钮四个控件;旧扫描按钮/发现行是零尺寸幽灵几何, + /// 永远命不中。原测试断言旧布局的 Rescan/LinkPick,重设计时一并改写。 #[test] - fn custom_proxy_hit_test_tracks_found_rows_rescan_and_selected_input() { + fn custom_proxy_hit_test_exposes_mode_protocol_address_and_test_only() { let size = proxy_test_size(); let area = (0.0, 0.0, 1200.0, 900.0); let proxy = ProxyPaneState { mode: crate::ssh_proxy::ProxyMode::Custom, - choice: ProxyChoice::Command, + choice: ProxyChoice::Manual, found_count: 2, ..Default::default() }; @@ -7599,10 +7991,18 @@ mod tests { 6, ) }; - assert_eq!(hit(geometry.ssh_proxy_scan_button), SettingsHit::SshProxyRescan); - assert_eq!(hit(geometry.ssh_proxy_found_row0), SettingsHit::SshProxyLinkPick(0)); - assert_eq!(hit(geometry.ssh_proxy_other_rows[2]), SettingsHit::SshProxyLinkPick(4)); - assert_eq!(hit(geometry.ssh_proxy_expand), SettingsHit::SshProxyInput(2)); + // 命中矩形与绘制矩形同源:用 hit-test 内部同款控件几何函数取靶。 + assert_eq!( + hit(ssh_proxy_mode_control(geometry.ssh_proxy_mode, 1.0)), + SettingsHit::SshProxyModeDropdown + ); + let (protocol, address) = ssh_proxy_manual_controls(geometry.ssh_proxy_expand, 1.0); + assert_eq!(hit(protocol), SettingsHit::SshProxyProtocolDropdown); + assert_eq!(hit(address), SettingsHit::SshProxyInput(0)); + assert_eq!( + hit(ssh_proxy_test_button(geometry.ssh_proxy_test, 1.0)), + SettingsHit::SshProxyTest + ); } #[test] diff --git a/nebula_app/src/display/side_panel.rs b/nebula_app/src/display/side_panel.rs index f72cd99..96080b1 100644 --- a/nebula_app/src/display/side_panel.rs +++ b/nebula_app/src/display/side_panel.rs @@ -1081,9 +1081,17 @@ fn build_search_index(dir: &Path, depth: usize, index: &mut Vec, budget /// synchronously — callers throttle (see [`SidePanel::sync`]). fn read_git(root: &Path) -> Option { use std::process::Command; + // `safe.directory` scoped to this one invocation: repos owned by another + // user — most commonly a `\\wsl$\…` UNC root, where every file belongs to + // the WSL distro — make git bail with "dubious ownership" and the Git view + // silently blanked while `git status` in the user's own shell worked fine + // (个别情况 status 可用但面板不显示). Read-only status/diff on a directory + // the user is already browsing carries none of the write risks the global + // opt-in guards against. + let safe_directory = format!("safe.directory={}", root.display()); let run = |args: &[&str]| -> Option { let mut cmd = Command::new("git"); - cmd.arg("--no-optional-locks").args(args).current_dir(root); + cmd.args(["-c", &safe_directory, "--no-optional-locks"]).args(args).current_dir(root); // Suppress the console window that `Command` flashes on Windows GUI apps. #[cfg(windows)] { @@ -1091,7 +1099,15 @@ fn read_git(root: &Path) -> Option { cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW } let out = cmd.output().ok()?; - out.status.success().then(|| String::from_utf8_lossy(&out.stdout).into_owned()) + if !out.status.success() { + // Leave a trace instead of a silent blank panel: the first stderr + // line names the actual refusal (ownership, not-a-repo, …). + let stderr = String::from_utf8_lossy(&out.stderr); + let reason = stderr.lines().next().unwrap_or("unknown error"); + log::debug!("git {:?} failed in {}: {reason}", args.first(), root.display()); + return None; + } + Some(String::from_utf8_lossy(&out.stdout).into_owned()) }; // `-b --porcelain` yields `## branch...upstream [ahead N]` + one `XY path` diff --git a/nebula_app/src/display/state.rs b/nebula_app/src/display/state.rs index e5c4cdd..e31ab1b 100644 --- a/nebula_app/src/display/state.rs +++ b/nebula_app/src/display/state.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; use std::sync::Arc; -use nebula_terminal::index::{Point, Side}; +use nebula_terminal::index::{Line, Point, Side}; use super::terminal_math::TerminalMathState; @@ -16,6 +16,60 @@ pub enum AcceptKey { Both, } +/// How completion candidates surface while typing: as a single inline ghost +/// remainder after the cursor, or as a floating list the user picks from. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum CompletionStyle { + #[default] + Inline, + Popup, +} + +impl CompletionStyle { + pub(super) fn cycle(self) -> Self { + match self { + Self::Inline => Self::Popup, + Self::Popup => Self::Inline, + } + } + + pub(super) fn settings_value(self) -> &'static str { + match self { + Self::Inline => "inline", + Self::Popup => "popup", + } + } + + pub(super) fn from_settings(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "inline" | "ghost" => Some(Self::Inline), + "popup" | "menu" | "list" => Some(Self::Popup), + _ => None, + } + } +} + +/// Source of a popup completion candidate; drives the right-aligned tag. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NebulaCompletionKind { + History, + Command, + Dir, + File, +} + +/// One row of the popup completion list. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NebulaCompletionItem { + /// Text shown in the list (the token/command as it would read once + /// accepted, possibly elided for display). + pub label: String, + /// Characters typed into the PTY on acceptance (remainder past what the + /// user already typed). + pub insert: String, + pub kind: NebulaCompletionKind, +} + impl AcceptKey { pub(super) fn cycle(self) -> Self { match self { @@ -151,12 +205,22 @@ pub struct NebulaPaneState { pub branch: String, pub suggestion: String, pub(super) suggestion_key: String, + /// Popup-style completion candidates for the current line (empty = no + /// popup). Mutually exclusive with `suggestion`: which one fills depends + /// on the configured [`CompletionStyle`]. + pub completion_items: Vec, + /// Index of the highlighted popup row. + pub completion_selected: usize, pub line_buf: String, pub(crate) screen_line: String, pub touched: bool, pub inline_images: Vec, pub command_started: Option, pub running_program: Option, + /// hook 直报的 AI CLI 会话身份(claude `session_id` / codex `thread-id`)。 + /// 与 `running_program` 同生命周期:133;D 命令收尾时一起清除,快照据此 + /// 判断「关窗那一刻这个 pane 里还开着哪个对话」,冷恢复接续它。 + pub ai_session: Option, pub last_committed: String, pub awaiting_input: bool, pub finished_unseen: bool, @@ -180,14 +244,32 @@ pub struct NebulaPaneState { pub(super) terminal_math: TerminalMathState, } +/// 一个 AI CLI 对话的身份:哪家 CLI + 它自己上报的会话 id。来源必须和 +/// id 绑在一起存——`running_program` 可能在 id 记录之后被另一个程序覆盖, +/// 快照时两者对得上才算「这个对话还活着」。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AiSessionIdentity { + pub source: String, + pub session_id: String, +} + impl NebulaPaneState { + /// Drop every completion hint (ghost remainder AND popup list) plus the + /// recompute cache, so the next frame re-derives them from the new line. + pub(crate) fn clear_completion_hints(&mut self) { + self.suggestion.clear(); + self.suggestion_key.clear(); + self.completion_items.clear(); + self.completion_selected = 0; + } + pub(crate) fn terminal_math_source_point( &self, point: Point, side: Side, - display_offset: usize, + viewport_origin: Line, ) -> (Point, Side) { - self.terminal_math.source_point(point, side, display_offset) + self.terminal_math.source_point(point, side, viewport_origin) } } diff --git a/nebula_app/src/display/terminal_math.rs b/nebula_app/src/display/terminal_math.rs index 7df77ba..5e3a2d1 100644 --- a/nebula_app/src/display/terminal_math.rs +++ b/nebula_app/src/display/terminal_math.rs @@ -133,17 +133,21 @@ impl TerminalMathState { /// Convert the visual mouse cell back to the immutable terminal-grid cell. /// Formula spans are atoms: their left/right halves select the corresponding /// source boundary instead of inventing cursor positions inside TeX syntax. + /// + /// `viewport_origin` is the renderer's viewport top row: projection spans + /// live in rendered-viewport coordinates, which can be cropped relative to + /// the grid while a resize commit is pending. pub(super) fn source_point( &self, point: Point, side: Side, - display_offset: usize, + viewport_origin: Line, ) -> (Point, Side) { - let Some(viewport_point) = term::point_to_viewport(display_offset, point) else { + let Some(viewport_point) = term::point_to_viewport_from(viewport_origin, point) else { return (point, side); }; let (source, source_side) = self.projection.source_from_visual(viewport_point, side); - (term::viewport_to_point(display_offset, source), source_side) + (term::viewport_to_point_from(viewport_origin, source), source_side) } fn synchronize_grid(&mut self, grid: &TextGrid) { @@ -721,25 +725,29 @@ impl TextGrid { requested_lookback: usize, ) -> Self { let grid = terminal.grid(); - // PTY resize is debounced during a live window drag, so display geometry - // can lead the grid by one frame. Scan only their shared rectangle. + // Grid and PTY resizes are committed together while display geometry + // updates every drag tick, so the visual viewport can differ from the + // grid. Anchor the scan to the renderer's viewport origin and only + // cover their shared rectangle, keeping overlay coordinates in the + // same space as the rendered cells they are matched against. + let origin = terminal.viewport_origin_for(size.screen_lines()); let columns = size.columns().min(grid.columns()); - let screen_lines = size.screen_lines().min(grid.screen_lines()); - let display_offset = grid.display_offset(); + let screen_lines = size + .screen_lines() + .min(((grid.bottommost_line() - origin).0.max(0) as usize).saturating_add(1)); let scrolled_out = grid.scrolled_out(); let history_size = grid.history_size(); - let available_lookback = history_size.saturating_sub(display_offset); + let available_lookback = (history_size as i64 + origin.0 as i64).max(0) as usize; let lookback = requested_lookback.min(available_lookback); - let absolute_top = scrolled_out - .saturating_add(history_size) - .saturating_sub(display_offset) - .saturating_sub(lookback); + let absolute_top = (scrolled_out as i64 + history_size as i64 + origin.0 as i64 + - lookback as i64) + .max(0) as usize; let row_count = screen_lines.saturating_add(lookback); let mut rows = Vec::with_capacity(row_count); let mut wrapped = Vec::with_capacity(row_count); for row in 0..row_count { - let line = Line(row as i32 - display_offset as i32 - lookback as i32); + let line = origin + (row as i32 - lookback as i32); let mut cells = Vec::with_capacity(columns); for column in 0..columns { let cell = &grid[line][Column(column)]; diff --git a/nebula_app/src/display/ui/icons.rs b/nebula_app/src/display/ui/icons.rs index 6f4c817..95bb1ab 100644 --- a/nebula_app/src/display/ui/icons.rs +++ b/nebula_app/src/display/ui/icons.rs @@ -345,8 +345,8 @@ pub(crate) fn push_check( pub(crate) enum RowActionIcon { /// 编辑:斜置笔杆 + 笔尖。 Edit, - /// 隐藏:眼睛加一道斜杠。 - Hide, + /// 删除:盖沿 + 提手 + 挖空桶身的垃圾桶。 + Delete, } pub(crate) fn push_row_action_icon( @@ -367,7 +367,7 @@ pub(crate) fn push_row_action_icon( match icon { RowActionIcon::Edit => { // 笔杆:左下到右上一道斜线;笔尖再补一小段加粗,让它读成"笔" - // 而不是"斜杠"(斜杠已经被 Hide 占用,两枚不能撞形)。 + // 而不是"斜杠",避免与删除图标的轮廓混淆。 let tip = (cx - 4.4 * scale, cy + 4.4 * scale); let tail = (cx + 4.4 * scale, cy - 4.4 * scale); push_segment(quads, tip, tail, stroke, ink); @@ -379,35 +379,35 @@ pub(crate) fn push_row_action_icon( ink, ); }, - RowActionIcon::Hide => { - // 眼睛:圆角胶囊挖空当外圈轮廓 + 中心瞳孔,再压一道斜杠。 - let w = 11.5 * scale; - let h = 7.0 * scale; - let ex = (cx - w * 0.5).round(); - let ey = (cy - h * 0.5).round(); - quads.push(UiQuad::solid(ex, ey, w, h, h * 0.5, ink)); + RowActionIcon::Delete => { + // 盖沿横线略宽于桶身,桶身用挖空矩形读作"容器"而不是方框; + // 提手是盖沿上方居中的一小段,三件套一起才读成垃圾桶。 + let lid_y = (cy - 4.6 * scale).round(); + let lid_half = 5.4 * scale; + push_segment(quads, (cx - lid_half, lid_y), (cx + lid_half, lid_y), stroke, ink); + let handle_half = 1.9 * scale; + let handle_y = lid_y - 1.6 * scale; + push_segment( + quads, + (cx - handle_half, handle_y), + (cx + handle_half, handle_y), + stroke, + ink, + ); + let body_w = 8.2 * scale; + let body_x = (cx - body_w * 0.5).round(); + let body_top = lid_y + 1.4 * scale; + let body_h = (cy + 5.2 * scale) - body_top; + let radius = 1.6 * scale; + quads.push(UiQuad::solid(body_x, body_top, body_w, body_h, radius, ink)); quads.push(UiQuad::solid( - ex + stroke, - ey + stroke, - w - stroke * 2.0, - h - stroke * 2.0, - (h * 0.5 - stroke).max(0.0), + body_x + stroke, + body_top + stroke, + body_w - stroke * 2.0, + body_h - stroke * 2.0, + (radius - stroke).max(0.0), cutout, )); - let pupil = 2.8 * scale; - quads.push(UiQuad::solid( - cx - pupil * 0.5, - cy - pupil * 0.5, - pupil, - pupil, - pupil * 0.5, - ink, - )); - // 斜杠先垫一条底色的更宽段,避免与眼睛墨迹糊成一团黑。 - let a = (cx - 5.6 * scale, cy + 5.6 * scale); - let b = (cx + 5.6 * scale, cy - 5.6 * scale); - push_segment(quads, a, b, stroke * 2.8, cutout); - push_segment(quads, a, b, stroke, ink); }, } } diff --git a/nebula_app/src/display/ui/keycap.rs b/nebula_app/src/display/ui/keycap.rs index a956c64..b88a133 100644 --- a/nebula_app/src/display/ui/keycap.rs +++ b/nebula_app/src/display/ui/keycap.rs @@ -11,7 +11,8 @@ use unicode_width::UnicodeWidthChar; use super::theme::Skin; use super::tokens::radius; -use crate::renderer::ui::UiQuad; +use crate::display::color::Rgb; +use crate::renderer::ui::{Rgba, UiQuad}; /// Chip height in logical px(与 palette 底栏既有键帽一致)。 pub const KEY_H: f32 = 20.0; @@ -115,6 +116,33 @@ pub fn push_chip_toned( quads.push(UiQuad::solid(x, y + h - lip, w, lip, lip * 0.4, sk.hairline)); } +/// 实心按钮(accent/danger 填充)上的键帽。中性 `panel` 底在深色主题里 +/// 近黑,叠在亮色主按钮上会读成一块突兀的深色(issue #35 的 Enter 键帽)。 +/// 这里改从按钮自身的文字墨派生:低透明度墨作底、同墨加重作底边,文字侧 +/// 配满强度的同一支墨——该墨在这块填充上本就保证可读,因此深浅主题 × +/// accent/danger 四种组合全部成立。几何与 [`push_chip`] 完全一致。 +pub fn push_chip_on_fill( + quads: &mut Vec, + ink: Rgb, + x: f32, + y: f32, + w: f32, + h: f32, + scale: f32, +) { + let corner = radius::CHIP * scale; + quads.push(UiQuad::solid(x, y, w, h, corner, Rgba::new(ink.r, ink.g, ink.b, 38))); + let lip = (1.5 * scale).max(1.0); + quads.push(UiQuad::solid( + x, + y + h - lip, + w, + lip, + lip * 0.4, + Rgba::new(ink.r, ink.g, ink.b, 110), + )); +} + /// Push every chip of a laid-out combo. pub fn push_combo(quads: &mut Vec, sk: &Skin, combo: &ComboChips, scale: f32) { push_combo_with_hover(quads, sk, combo, scale, false); diff --git a/nebula_app/src/event.rs b/nebula_app/src/event.rs index 9ebba1e..d0f47d2 100644 --- a/nebula_app/src/event.rs +++ b/nebula_app/src/event.rs @@ -371,6 +371,25 @@ impl Processor { let id = window_context.id(); self.windows.insert(id, window_context); + + // Arm the 1 Hz chrome clock / render-gate watchdog right now, before + // the first frame. `draw()` also (re)schedules it, but `draw()` only + // runs on a `RedrawRequested`, and a redraw request is gated behind + // `has_frame && !occluded`. If a startup occlusion misreport or a lost + // frame callback closes one of those gates before the first draw ever + // lands, the very watchdog that exists to reopen them + // (`unstick_render_gates_if_visible`) would never be armed — the window + // stays visible but frozen, repainting only after a manual + // minimize/restore (issues #21 and #32). Scheduling here breaks that + // bootstrap deadlock so recovery always happens within one tick. The + // interval matches the idle chrome-clock cadence, so the first `draw()` + // finds the timer already in place and leaves it untouched. + let clock_timer = TimerId::new(Topic::NebulaClock, id); + if !self.scheduler.scheduled(clock_timer) { + let tick = Event::new(EventType::NebulaTick, id); + self.scheduler.schedule(tick, Duration::from_secs(1), true, clock_timer); + } + Ok(id) } @@ -517,13 +536,16 @@ impl Processor { "pane_id": pane_id })) }, - RuntimeCommand::NewTab { window_id } => { + RuntimeCommand::NewTab { window_id, cwd } => { let id = self.runtime_target_window(*window_id, None)?; - let pane_id = self - .windows - .get_mut(&id) - .expect("resolved runtime window exists") - .runtime_new_tab()?; + let window = self.windows.get_mut(&id).expect("resolved runtime window exists"); + let pane_id = window.runtime_new_tab(cwd.clone())?; + // 带目录的 tab.new 来自一次用户手势(Explorer 右键并入): + // 把窗口带到前台,不然标签开在了别人身后。无目录的编程调用 + // (agent/CLI)保持原来的不抢焦点语义。 + if cwd.is_some() { + window.runtime_focus(None)?; + } self.runtime_result(serde_json::json!({ "window_id": u64::from(id), "pane_id": pane_id @@ -1446,10 +1468,12 @@ impl<'a, N: Notify + 'a, T: EventListener> input::ActionContext for ActionCon } fn terminal_math_source_point(&self, point: Point, side: Side) -> (Point, Side) { + // Formula projection spans live in rendered-viewport coordinates, + // which can be a crop of the grid while a resize commit is pending. self.nebula_state.terminal_math_source_point( point, side, - self.terminal.grid().display_offset(), + self.terminal.viewport_origin_for(self.size_info().screen_lines()), ) } @@ -1474,8 +1498,7 @@ impl<'a, N: Notify + 'a, T: EventListener> input::ActionContext for ActionCon } else if self.mouse.left_button_state == ElementState::Pressed || self.mouse.right_button_state == ElementState::Pressed { - let display_offset = self.terminal.grid().display_offset(); - let point = self.mouse.point(&self.size_info(), display_offset); + let point = self.mouse.point(&self.size_info(), &*self.terminal); let (point, side) = self.terminal_math_source_point(point, self.mouse.cell_side); self.update_selection(point, side); } @@ -1660,6 +1683,46 @@ impl<'a, N: Notify + 'a, T: EventListener> input::ActionContext for ActionCon mem::take(&mut self.nebula_state.suggestion) } + #[inline] + fn nebula_completion_popup_active(&self) -> bool { + !self.nebula_state.completion_items.is_empty() + } + + fn nebula_completion_popup_move(&mut self, delta: isize) { + let len = self.nebula_state.completion_items.len(); + if len == 0 { + return; + } + let current = self.nebula_state.completion_selected as isize; + self.nebula_state.completion_selected = + (current + delta).rem_euclid(len as isize) as usize; + *self.dirty = true; + } + + fn nebula_completion_popup_take(&mut self) -> Option { + let state = &mut self.nebula_state; + let insert = + state.completion_items.get(state.completion_selected).map(|item| item.insert.clone()); + state.completion_items.clear(); + state.completion_selected = 0; + *self.dirty = true; + insert + } + + fn nebula_completion_popup_dismiss(&mut self) -> bool { + let state = &mut self.nebula_state; + if state.completion_items.is_empty() { + return false; + } + // Items go, the recompute key stays: the cache guard in + // `nebula_update_suggestion` then keeps the list closed until the + // line itself changes. + state.completion_items.clear(); + state.completion_selected = 0; + *self.dirty = true; + true + } + fn nebula_take_ai_fix(&mut self) -> Option { use crate::ai_assistant::AiFixState; match self.nebula_state.ai_fix.take() { @@ -2319,8 +2382,7 @@ impl<'a, N: Notify + 'a, T: EventListener> input::ActionContext for ActionCon }; // Load mouse point, treating message bar and padding as the closest cell. - let display_offset = self.terminal().grid().display_offset(); - let point = self.mouse().point(&self.size_info(), display_offset); + let point = self.mouse().point(&self.size_info(), self.terminal()); let (point, cell_side) = self.terminal_math_source_point(point, self.mouse().cell_side); let selection = match &mut self.terminal_mut().selection { @@ -2396,13 +2458,50 @@ impl<'a, N: Notify + 'a, T: EventListener> input::ActionContext for ActionCon } } + /// 剪贴板截图转路径粘贴(无文本时的回退)。本地 pane 同步落盘临时 PNG + /// 直接粘;SSH pane 交给 SFTP 后台上传,路径经 runtime Prompt 通道回粘 + /// ——期间不阻塞任何输入。 + fn paste_clipboard_image(&mut self) -> bool { + let Some(png) = crate::clipboard::clipboard_image_png() else { + return false; + }; + if let Some(destination) = self.ssh_destination { + crate::ssh_sftp::upload_clipboard_image( + destination.to_owned(), + png, + self.pane_id, + self.event_proxy.clone(), + ); + return true; + } + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_millis()) + .unwrap_or(0); + let path = std::env::temp_dir().join(format!("nebula-paste-{stamp}.png")); + if std::fs::write(&path, &png).is_err() { + return false; + } + self.paste(&path.display().to_string(), true); + true + } + /// Paste a text into the terminal. fn paste(&mut self, text: &str, bracketed: bool) { - // Multi-line paste confirmation (#18): anything with a newline would - // start executing in most shells the moment it lands. Search and - // pending-char inputs are exempt (they consume text locally). + // Multi-line paste confirmation (#18): a newline heading to a bare + // shell starts executing the moment it lands. But an app in + // bracketed-paste mode — codex, vim, a REPL, modern PSReadLine — + // receives the whole paste as one chunk (wrapped below in + // `\x1b[200~`…`\x1b[201~`) and decides what to do with the newlines + // itself, so it is *not* executed line by line and the warning both + // misleads and gets in the way (#35). Confirm only for the genuinely + // dangerous case: newlines going to a shell that is not bracketing. + // Search and pending-char inputs are exempt (they consume text + // locally). let goes_to_pty = !self.search_active() && !self.inline_search_state.char_pending; + let bracketing = bracketed && self.terminal().mode().contains(TermMode::BRACKETED_PASTE); if goes_to_pty + && !bracketing && self.display.nebula_confirm.is_none() && (text.contains('\n') || text.contains('\r')) { @@ -3040,6 +3139,9 @@ impl input::Processor> { // names it, and reading the field after the reset used // to hand the toast a permanent `None`. let program = self.ctx.nebula_state.running_program.take(); + // CLI 退回提示符,对话不再是这个 pane 的前台事实; + // 留着它,快照会把一个已经退出的会话当活的接续。 + self.ctx.nebula_state.ai_session = None; let pending_ssh = self.ctx.nebula_state.pending_ssh_host.take(); self.ctx.nebula_state.awaiting_input = false; // 助手错误恢复(spec 001):Nebula 集成上报的退出码 @@ -3143,6 +3245,17 @@ impl input::Processor> { // Ring visual bell. self.ctx.display.visual_bell.ring(); + // Audible bell. AI CLIs ring BEL when a turn ends or + // they need input, so this is the "needs you" sound + // even with Nebula focused on another tab — the toast + // above only fires when the window is unfocused, and + // the visual bell is invisible from another tab. + // Plays regardless of focus; `platform::beep` throttles + // so a looping BEL cannot machine-gun it. + if self.ctx.config.bell.audible { + crate::platform::beep(); + } + // Execute bell command. if let Some(bell_command) = &self.ctx.config.bell.command { if self diff --git a/nebula_app/src/event/input_state.rs b/nebula_app/src/event/input_state.rs index 1a377bc..9b4e658 100644 --- a/nebula_app/src/event/input_state.rs +++ b/nebula_app/src/event/input_state.rs @@ -9,7 +9,7 @@ use winit::event::{ElementState, MouseButton, Touch as TouchEvent}; use nebula_terminal::grid::Dimensions; use nebula_terminal::index::{Column, Point, Side}; -use nebula_terminal::term; +use nebula_terminal::term::Term; use crate::display::SizeInfo; use crate::input::FONT_SIZE_STEP; @@ -115,13 +115,19 @@ impl Default for Mouse { } impl Mouse { + /// Terminal grid cell under the pointer. + /// + /// The pixel position maps into the *visual* viewport (`size`), which the + /// renderer may crop out of a larger committed grid while a resize is + /// pending; `term` supplies that viewport origin and clamps the result to + /// cells the grid actually has. #[inline] - pub fn point(&self, size: &SizeInfo, display_offset: usize) -> Point { + pub fn point(&self, size: &SizeInfo, term: &Term) -> Point { let col = self.x.saturating_sub(size.padding_x() as usize) / size.cell_width() as usize; let col = min(Column(col), size.last_column()); let line = self.y.saturating_sub(size.padding_y() as usize) / size.cell_height() as usize; let line = min(line, size.bottommost_line().0 as usize); - term::viewport_to_point(display_offset, Point::new(line, col)) + term.visual_viewport_to_point(size.screen_lines(), Point::new(line, col)) } } diff --git a/nebula_app/src/input/action.rs b/nebula_app/src/input/action.rs index a590309..fdafc96 100644 --- a/nebula_app/src/input/action.rs +++ b/nebula_app/src/input/action.rs @@ -201,6 +201,11 @@ impl Execute for Action { Action::ClearSelection => ctx.clear_selection(), Action::Paste => { let text = ctx.clipboard_mut().load(ClipboardType::Clipboard); + // 截图粘贴(Win+Shift+S 之后剪贴板只有位图没有文本):转成 + // 文件路径粘给 codex/claude 这类吃图的 CLI;有文本时文本优先。 + if text.is_empty() && ctx.paste_clipboard_image() { + return; + } ctx.paste(&text, true); }, Action::PasteSelection => { diff --git a/nebula_app/src/input/chrome.rs b/nebula_app/src/input/chrome.rs index 9e4fcd0..b51f566 100644 --- a/nebula_app/src/input/chrome.rs +++ b/nebula_app/src/input/chrome.rs @@ -922,6 +922,11 @@ impl> Processor { self.ctx.mark_dirty(); return; }, + crate::display::SettingsHit::ResumeAiToggle => { + self.ctx.display().toggle_resume_ai(); + self.ctx.mark_dirty(); + return; + }, crate::display::SettingsHit::AcceptCycle => { self.ctx .display() @@ -934,6 +939,18 @@ impl> Processor { self.ctx.mark_dirty(); return; }, + crate::display::SettingsHit::CompletionStyleCycle => { + self.ctx.display().toggle_settings_dropdown( + crate::display::SettingsDropdown::CompletionStyle, + ); + self.ctx.mark_dirty(); + return; + }, + crate::display::SettingsHit::CompletionStyleOption(index) => { + self.ctx.display().set_completion_style_option(index); + self.ctx.mark_dirty(); + return; + }, crate::display::SettingsHit::CursorShapeDropdown => { self.ctx.display().toggle_settings_dropdown( crate::display::SettingsDropdown::CursorShape, @@ -1099,7 +1116,7 @@ impl> Processor { self.ctx.mark_dirty(); return; }, - crate::display::SettingsHit::SshHostHide(index) => { + crate::display::SettingsHit::SshHostDelete(index) => { self.ctx.display().request_delete_ssh_host(index); self.ctx.mark_dirty(); return; @@ -1297,6 +1314,20 @@ impl> Processor { self.ctx.mark_dirty(); return; }, + crate::display::SettingsHit::BackgroundSvPlane => { + self.ctx + .display() + .begin_bg_picker_drag(crate::display::BgPickerPart::Sv, x, y); + self.ctx.mark_dirty(); + return; + }, + crate::display::SettingsHit::BackgroundHueBar => { + self.ctx + .display() + .begin_bg_picker_drag(crate::display::BgPickerPart::Hue, x, y); + self.ctx.mark_dirty(); + return; + }, crate::display::SettingsHit::BackgroundSwatch(index) => { self.ctx.display().set_background_color_option(index); self.ctx.mark_dirty(); @@ -1596,8 +1627,7 @@ impl> Processor { } else { // Multi-click state was advanced once at the top of this function. // Load mouse point, treating message bar and padding as the closest cell. - let display_offset = self.ctx.terminal().grid().display_offset(); - let point = self.ctx.mouse().point(&self.ctx.size_info(), display_offset); + let point = self.ctx.mouse().point(&self.ctx.size_info(), self.ctx.terminal()); let (point, side) = self.ctx.terminal_math_source_point(point, self.ctx.mouse().cell_side); self.ctx.mouse_mut().cell_side = side; @@ -1630,6 +1660,8 @@ fn settings_dropdown_keeps_open(hit: crate::display::SettingsHit) -> bool { | Hit::Language(_) | Hit::AcceptCycle | Hit::AcceptOption(_) + | Hit::CompletionStyleCycle + | Hit::CompletionStyleOption(_) | Hit::TabRevealDropdown | Hit::TabRevealOption(_) | Hit::DensityDropdown @@ -1648,6 +1680,8 @@ fn settings_dropdown_keeps_open(hit: crate::display::SettingsHit) -> bool { | Hit::SshJumpHostDropdown | Hit::SshJumpHostOption(_) | Hit::BackgroundColor + | Hit::BackgroundSvPlane + | Hit::BackgroundHueBar | Hit::BackgroundSwatch(_) | Hit::BackgroundHexInput | Hit::BackgroundPopupPanel diff --git a/nebula_app/src/input/keyboard.rs b/nebula_app/src/input/keyboard.rs index 6ea8943..0af36aa 100644 --- a/nebula_app/src/input/keyboard.rs +++ b/nebula_app/src/input/keyboard.rs @@ -951,11 +951,52 @@ impl> Processor { // `nebula_settings.txt` `keybind=` overrides + TOML remaps all funnel // through `process_key_bindings` below (spec 002). + // 弹窗补齐:列表可见时接管上下键(选行)、接受键(按 accept 配置的 + // Tab/Right 键入选中候选的余量)和 Esc(关闭)。其余按键穿透——字符 + // 照常进 shell 并触发重算,Enter 照常执行当前行。列表关闭后按键立即 + // 恢复原义(上下键回到 shell 历史)。 + let accept = self.ctx.nebula_accept(); + if mods.is_empty() + && self.ctx.nebula_completion_popup_active() + && !self.ctx.display().hint_state.active() + && !self.ctx.search_active() + { + let popup_accept = match &key.logical_key { + Key::Named(NamedKey::ArrowDown) => { + self.ctx.nebula_completion_popup_move(1); + return; + }, + Key::Named(NamedKey::ArrowUp) => { + self.ctx.nebula_completion_popup_move(-1); + return; + }, + Key::Named(NamedKey::Escape) => { + if self.ctx.nebula_completion_popup_dismiss() { + return; + } + false + }, + Key::Named(NamedKey::Tab) => accept.accepts_tab(), + Key::Named(NamedKey::ArrowRight) => accept.accepts_right(), + _ => false, + }; + if popup_accept { + if let Some(insert) = self.ctx.nebula_completion_popup_take() { + if !insert.is_empty() { + for c in insert.chars() { + self.ctx.nebula_input_char(c); + } + self.ctx.write_to_pty(insert.into_bytes()); + return; + } + } + } + } + // Accept the Nebula ghost-text suggestion with the configured key // (Right/Tab/both): write the remaining text so the shell echoes it, // as if typed. Tab only accepts when a suggestion exists; otherwise it // falls through to the shell's own completion below. - let accept = self.ctx.nebula_accept(); let is_accept = mods.is_empty() && matches!(&key.logical_key, Key::Named(NamedKey::ArrowRight) if accept.accepts_right()) @@ -1143,6 +1184,7 @@ impl> Processor { OpenSettingsFile => self.ctx.display().open_user_config_file(), ToggleGhost => self.ctx.display().toggle_ghost(), CycleAccept => self.ctx.display().cycle_accept(), + CycleCompletionStyle => self.ctx.display().cycle_completion_style(), PickBackgroundImage => self.ctx.display().pick_background_image(), CycleBackground => self.ctx.display().cycle_background_color(), ResetAppearance => self.ctx.display().reset_appearance_settings(), diff --git a/nebula_app/src/input/mod.rs b/nebula_app/src/input/mod.rs index 6222279..52253be 100644 --- a/nebula_app/src/input/mod.rs +++ b/nebula_app/src/input/mod.rs @@ -99,6 +99,20 @@ pub trait ActionContext { fn nebula_take_suggestion(&mut self) -> String { String::new() } + /// 弹窗补齐:列表是否正显示(决定方向键/接受键是否被弹窗接管)。 + fn nebula_completion_popup_active(&self) -> bool { + false + } + /// 弹窗补齐:高亮行上下移动(循环)。 + fn nebula_completion_popup_move(&mut self, _delta: isize) {} + /// 弹窗补齐:取走选中候选要输入的余量并关闭列表。 + fn nebula_completion_popup_take(&mut self) -> Option { + None + } + /// 弹窗补齐:Esc 关闭列表;返回是否真的关闭了(决定按键是否吞掉)。 + fn nebula_completion_popup_dismiss(&mut self) -> bool { + false + } /// 助手建议条(spec 001):取走 Ready 状态里的命令(Ctrl+. 贴入用)。 /// Pending 不受影响——分析中的请求不因误按 Ctrl+. 而丢。 fn nebula_take_ai_fix(&mut self) -> Option { @@ -199,6 +213,12 @@ pub trait ActionContext { /// Paste without the multi-line confirmation gate (used by the confirm /// modal's Enter handler once the user approved). fn paste_now(&mut self, _text: &str, _bracketed: bool) {} + /// 剪贴板没有文本但有截图时的粘贴回退:本地 pane 存临时 PNG 后粘路径, + /// SSH pane 经 SFTP 上传到远端 `/tmp` 后回粘远端路径(codex/claude 这类 + /// 接受图片路径的 CLI 因此在两侧都能"粘图")。返回是否接管了这次粘贴。 + fn paste_clipboard_image(&mut self) -> bool { + false + } fn spawn_daemon(&self, _program: &str, _args: I) where I: IntoIterator + Debug + Copy, diff --git a/nebula_app/src/input/mouse.rs b/nebula_app/src/input/mouse.rs index dfce5cc..18814f5 100644 --- a/nebula_app/src/input/mouse.rs +++ b/nebula_app/src/input/mouse.rs @@ -43,12 +43,18 @@ impl> Processor { let lmb_pressed = self.ctx.mouse().left_button_state == ElementState::Pressed; let rmb_pressed = self.ctx.mouse().right_button_state == ElementState::Pressed; - if !self.ctx.selection_is_empty() && (lmb_pressed || rmb_pressed) { + // Auto-scroll only for drags that actually extend the selection (same + // guard as the update below): inside a mouse-mode TUI a plain drag is + // reported to the application, and a stale selection from an earlier + // Shift+drag must not arm the repeating scroll timer. + if !self.ctx.selection_is_empty() + && (lmb_pressed || rmb_pressed) + && (self.ctx.modifiers().state().shift_key() || !self.ctx.mouse_mode()) + { self.update_selection_scrolling(y); } - let display_offset = self.ctx.terminal().grid().display_offset(); - let old_point = self.ctx.mouse().point(&size_info, display_offset); + let old_point = self.ctx.mouse().point(&size_info, self.ctx.terminal()); let (old_point, _) = self.ctx.terminal_math_source_point(old_point, self.ctx.mouse().cell_side); @@ -70,6 +76,19 @@ impl> Processor { return; } + // 背景色调色盘拖拽:SV 面 / 色相条按住即连续取色,与不透明度滑条 + // 同一套"拖动实时应用、松手落盘"节奏。 + if self.ctx.display().nebula_bg_picker_drag.is_some() { + if lmb_pressed { + self.ctx.display().update_bg_picker_drag(x as f32, y as f32); + } else { + self.ctx.display().finish_bg_picker_drag(); + } + self.ctx.window().set_mouse_cursor(CursorIcon::Crosshair); + self.ctx.mark_dirty(); + return; + } + if self.ctx.display().sidebar_scrollbar_dragging() { if lmb_pressed { if self.ctx.display().sidebar_scrollbar_drag_to(y as f32) { @@ -400,6 +419,8 @@ impl> Processor { | crate::display::SettingsHit::SystemThemeToggle | crate::display::SettingsHit::GhostToggle | crate::display::SettingsHit::AcceptCycle + | crate::display::SettingsHit::CompletionStyleCycle + | crate::display::SettingsHit::CompletionStyleOption(_) | crate::display::SettingsHit::ShellCycle | crate::display::SettingsHit::ShellPickerRow(_) | crate::display::SettingsHit::StartupDirectory @@ -409,7 +430,7 @@ impl> Processor { | crate::display::SettingsHit::RestoreHiddenSsh(_) | crate::display::SettingsHit::SshHostConnect(_) | crate::display::SettingsHit::SshHostEdit(_) - | crate::display::SettingsHit::SshHostHide(_) + | crate::display::SettingsHit::SshHostDelete(_) | crate::display::SettingsHit::SshImportConfig | crate::display::SettingsHit::SshAddHost | crate::display::SettingsHit::ProviderAdd @@ -426,7 +447,10 @@ impl> Processor { | crate::display::SettingsHit::BlurToggle | crate::display::SettingsHit::KeepSessionToggle | crate::display::SettingsHit::RestoreSessionToggle + | crate::display::SettingsHit::ResumeAiToggle | crate::display::SettingsHit::BackgroundColor + | crate::display::SettingsHit::BackgroundSvPlane + | crate::display::SettingsHit::BackgroundHueBar | crate::display::SettingsHit::BackgroundImage | crate::display::SettingsHit::BackgroundImageClear | crate::display::SettingsHit::BackgroundImageFit @@ -648,7 +672,7 @@ impl> Processor { } } - let point = self.ctx.mouse().point(&size_info, display_offset); + let point = self.ctx.mouse().point(&size_info, self.ctx.terminal()); let (point, cell_side) = self.ctx.terminal_math_source_point(point, visual_cell_side); let cell_changed = old_point != point; @@ -754,8 +778,7 @@ impl> Processor { } pub(super) fn mouse_report(&mut self, button: u8, state: ElementState) { - let display_offset = self.ctx.terminal().grid().display_offset(); - let point = self.ctx.mouse().point(&self.ctx.size_info(), display_offset); + let point = self.ctx.mouse().point(&self.ctx.size_info(), self.ctx.terminal()); let (point, _) = self.ctx.terminal_math_source_point(point, self.ctx.mouse().cell_side); // Assure the mouse point is not in the scrollback. @@ -859,8 +882,7 @@ impl> Processor { // the wrong row and miss links the hover clearly marked. let hint_point = { let view = self.ctx.display().pane_view(); - let display_offset = self.ctx.terminal().grid().display_offset(); - let point = self.ctx.mouse().point(&view, display_offset); + let point = self.ctx.mouse().point(&view, self.ctx.terminal()); self.ctx.terminal_math_source_point(point, self.ctx.mouse().cell_side).0 }; if let Some(hint) = crate::display::hint::highlighted_at( @@ -934,6 +956,18 @@ impl> Processor { } fn on_mouse_release(&mut self, button: MouseButton) { + // A release ALWAYS ends selection-drag auto-scrolling, before any of + // the early returns below. The repeating 15 ms timer is armed by any + // selection drag that crosses the text-area edge (the top band covers + // the whole chrome, so it is easy to hit). If the release is then + // consumed early — most commonly Shift+drag-select inside a mouse-mode + // TUI (claude/codex/cursor CLI) with Shift let go first, which lands + // in the mouse_report branch — the timer used to survive and kept + // scrolling the viewport forever: the "view teleports through a long + // session / tab looks frozen until a keystroke snaps it back" bug. + let selection_scrolling = TimerId::new(Topic::SelectionScrolling, self.ctx.window().id()); + self.ctx.scheduler_mut().unschedule(selection_scrolling); + // Nebula: finish an in-progress tab-bar reorder drag first, so it works // even while a TUI has grabbed the mouse. A plain click (never dragged) // returns `None` here and falls through to normal release handling. @@ -947,6 +981,10 @@ impl> Processor { self.ctx.mark_dirty(); return; } + if self.ctx.display().finish_bg_picker_drag() { + self.ctx.mark_dirty(); + return; + } if self.ctx.display().end_sidebar_scrollbar_drag() { self.ctx.mark_dirty(); return; @@ -1062,9 +1100,6 @@ impl> Processor { } self.ctx.display().highlighted_hint = hint; - let timer_id = TimerId::new(Topic::SelectionScrolling, self.ctx.window().id()); - self.ctx.scheduler_mut().unschedule(timer_id); - if let MouseButton::Left | MouseButton::Right = button { // Copy selection on release, to prevent flooding the display server. self.ctx.copy_selection(ClipboardType::Selection); @@ -1523,8 +1558,7 @@ impl> Processor { /// Icon state of the cursor. fn cursor_state(&mut self) -> CursorIcon { self.update_message_close_hover(); - let display_offset = self.ctx.terminal().grid().display_offset(); - let mut point = self.ctx.mouse().point(&self.ctx.size_info(), display_offset); + let mut point = self.ctx.mouse().point(&self.ctx.size_info(), self.ctx.terminal()); point = self.ctx.terminal_math_source_point(point, self.ctx.mouse().cell_side).0; // `point` is clamped to `size_info`, but we're about to index the grid, // whose column/line count can trail `size_info` by one during a resize diff --git a/nebula_app/src/logging.rs b/nebula_app/src/logging.rs index 82d921c..4d7a2f9 100644 --- a/nebula_app/src/logging.rs +++ b/nebula_app/src/logging.rs @@ -104,10 +104,13 @@ impl Logger { Err(_) => return, }; + // Show the variable in the reader's own shell syntax. On Windows the + // default shell is PowerShell, where cmd-style `%NEBULA_LOG%` does not + // expand (issue #36); use `$env:NEBULA_LOG` so a copy-paste resolves. #[cfg(not(windows))] let env_var = format!("${NEBULA_LOG_ENV}"); #[cfg(windows)] - let env_var = format!("%{}%", NEBULA_LOG_ENV); + let env_var = format!("$env:{NEBULA_LOG_ENV}"); let message = format!( "[{}] {}\nSee log at {} ({})", diff --git a/nebula_app/src/main.rs b/nebula_app/src/main.rs index 7b22096..b58fd32 100644 --- a/nebula_app/src/main.rs +++ b/nebula_app/src/main.rs @@ -239,15 +239,31 @@ fn nebula(mut options: Options) -> Result<(), Box> { // Mux hand-over: a plain re-launch of Nebula does not start a second // terminal — the resident instance re-attaches its detached tabs (their // PTYs never stopped) or focuses its window. Explicit intent (-e, - // --working-directory, --daemon) always starts a real instance. + // --daemon) always starts a real instance. #[cfg(windows)] { + let has_command = options.window_options.terminal_options.command().is_some(); let plain_launch = !options.daemon && options.window_options.terminal_options.working_directory.is_none() - && options.window_options.terminal_options.command().is_none(); + && !has_command; if plain_launch && runtime_api::try_attach_existing() { return Ok(()); } + // Explorer 右键「在 Nebula 中打开」带着 --working-directory 走到这里。 + // 此前它被一律判成"显式意图 → 独立实例",绕过 mux 交接:驻留进程里 + // detached 的标签接不回来,表现就是"之前的标签没了 + 多出一个独立 + // 窗口"。带目录、无 -e 命令的启动现在优先并入驻留实例——ATTACH 恢复 + // 窗口,再在其中打开定目录标签;没有驻留实例时照旧独立启动。 + let dir_launch = !options.daemon && !has_command; + if dir_launch { + if let Some(dir) = + options.window_options.terminal_options.resolved_working_directory() + { + if dir.is_dir() && runtime_api::try_open_directory_existing(&dir) { + return Ok(()); + } + } + } } boot_trace("mux probe done"); diff --git a/nebula_app/src/nebula_history.rs b/nebula_app/src/nebula_history.rs index 9d44d40..39a6f9b 100644 --- a/nebula_app/src/nebula_history.rs +++ b/nebula_app/src/nebula_history.rs @@ -79,6 +79,31 @@ impl NebulaHistory { best.map(|(_, rem)| rem) } + /// Up to `limit` commands beginning with `prefix` (strict extensions + /// only), newest first, as `(full_command, remainder)` pairs. Same indexed + /// range scan as [`Self::hint`]; feeds the popup completion list. + pub fn hints(&self, prefix: &str, limit: usize) -> Vec<(&str, &str)> { + if prefix.is_empty() || limit == 0 { + return Vec::new(); + } + let mut matches: Vec<(usize, &str)> = Vec::new(); + for (cmd, &pos) in self.index.range(prefix.to_owned()..) { + if !cmd.starts_with(prefix) { + break; + } + if cmd.len() == prefix.len() { + continue; // exact match — nothing to complete + } + matches.push((pos, cmd.as_str())); + } + matches.sort_unstable_by(|a, b| b.0.cmp(&a.0)); + matches + .into_iter() + .take(limit) + .map(|(_, cmd)| (cmd, &cmd[prefix.len()..])) + .collect() + } + /// Insert a command, deduplicating and rebuilding the index when an old /// copy is displaced, and trimming to [`HISTORY_MAX`]. fn insert(&mut self, cmd: String) { @@ -216,4 +241,19 @@ mod tests { // "git " must not match "gitk" (no space). assert_eq!(h.hint("git "), Some("push")); } + + #[test] + fn hints_lists_matches_newest_first_and_caps_at_limit() { + let h = hist(&["git pull", "git push", "git status", "ls"]); + assert_eq!(h.hints("git ", 2), vec![("git status", "status"), ("git push", "push")]); + assert_eq!(h.hints("git ", 8).len(), 3); + assert!(h.hints("npm ", 8).is_empty()); + assert!(h.hints("", 8).is_empty()); + } + + #[test] + fn hints_skips_the_exact_match_like_hint_does() { + let h = hist(&["cargo build", "cargo b"]); + assert_eq!(h.hints("cargo b", 8), vec![("cargo build", "uild")]); + } } diff --git a/nebula_app/src/platform/mod.rs b/nebula_app/src/platform/mod.rs index f6cafbf..4aa4a77 100644 --- a/nebula_app/src/platform/mod.rs +++ b/nebula_app/src/platform/mod.rs @@ -11,6 +11,56 @@ pub mod dirs; +/// Play the system's default notification sound. +/// +/// Used for the audible terminal bell (BEL / `\a`), which is the primary +/// "an AI CLI turn finished / needs you" cue when Nebula is focused on a +/// different tab. Throttled internally so a bell-happy program (a build +/// looping BEL, PSReadLine ringing on every ambiguous completion) cannot +/// machine-gun the sound; distinct events a fraction of a second apart still +/// each get one beep. +/// +/// The actual playback is handed to a throwaway thread so a wedged audio +/// service can never stall the winit event loop — the same discipline the +/// toast path in [`crate::notify`] follows. No-op off Windows for now. +pub fn beep() { + use std::sync::Mutex; + use std::time::{Duration, Instant}; + + /// Minimum spacing between beeps. Long enough to coalesce a tight BEL + /// loop into a steady tick rather than a screech, short enough that two + /// separate turns finishing back to back are still both heard. + const COOLDOWN: Duration = Duration::from_millis(200); + static LAST: Mutex> = Mutex::new(None); + + { + // A poisoned lock only means a prior beep thread panicked mid-check; + // the state is a plain Option, safe to keep using. + let mut last = LAST.lock().unwrap_or_else(|e| e.into_inner()); + match *last { + Some(at) if at.elapsed() < COOLDOWN => return, + _ => *last = Some(Instant::now()), + } + } + + #[cfg(windows)] + { + // MessageBeep(MB_OK) plays the user's configured "Default Beep" + // sound and returns before it finishes, but we still isolate it on a + // named worker thread: best-effort by contract, a failure or stall + // costs the sound, never the terminal. + let _ = std::thread::Builder::new().name("nebula-beep".into()).spawn(|| { + // SAFETY: MessageBeep takes a plain sound-type flag and has no + // pointer arguments or shared state. + unsafe { + windows_sys::Win32::System::Diagnostics::Debug::MessageBeep( + windows_sys::Win32::UI::WindowsAndMessaging::MB_OK, + ); + } + }); + } +} + /// 运行平台。与「配置平台」分离:前者是我真正跑在哪,后者是 /// 该套用哪份键位/修饰键默认值——Mac 用户在 Windows 上可以选 ⌘ 语义。 /// 配置平台待 L5 键位分文件时落地。 diff --git a/nebula_app/src/runtime_api.rs b/nebula_app/src/runtime_api.rs index 13364d0..e075fc1 100644 --- a/nebula_app/src/runtime_api.rs +++ b/nebula_app/src/runtime_api.rs @@ -272,7 +272,7 @@ pub enum RuntimeCommand { Snapshot, NewWindow, Focus { window_id: Option, pane_id: Option }, - NewTab { window_id: Option }, + NewTab { window_id: Option, cwd: Option }, Split { window_id: Option, direction: RuntimeSplitDirection }, Prompt { window_id: Option, pane_id: u64, text: String, submit: bool }, } @@ -400,6 +400,9 @@ struct TargetParams { struct WindowParams { #[serde(default)] window_id: Option, + /// `tab.new` 可选:新标签的工作目录(Explorer 右键并入驻留实例时携带)。 + #[serde(default)] + cwd: Option, } #[derive(Debug, Deserialize)] @@ -471,7 +474,7 @@ impl RuntimeCommand { }, "tab.new" => { let params: WindowParams = parse_params(&request.params)?; - Ok(Self::NewTab { window_id: params.window_id }) + Ok(Self::NewTab { window_id: params.window_id, cwd: params.cwd }) }, "pane.split" => { let params: SplitParams = parse_params(&request.params)?; @@ -586,6 +589,33 @@ pub fn try_attach_existing() -> bool { legacy_request("ATTACH").is_some() } +/// 后台任务把一行文本作为输入敲进某个 pane(不回车)。复用 runtime Prompt +/// 的窗口定位与写入路径(`window_id=None` 按 pane 自动定位);响应通道即弃 +/// ——调用方不关心结果,失败在事件线程侧留日志即可。SFTP 图片粘贴用它回粘 +/// 远端路径。 +pub fn dispatch_prompt(proxy: &EventLoopProxy, pane_id: u64, text: String) { + let (dispatch, _receiver) = + RuntimeDispatch::new(RuntimeCommand::Prompt { window_id: None, pane_id, text, submit: false }); + if proxy.send_event(Event::new(EventType::RuntimeControl(dispatch), None)).is_err() { + warn!("prompt dispatch failed: event loop is gone"); + } +} + +/// Explorer 右键「在 Nebula 中打开」/ CLI 带 `--working-directory` 的启动并入 +/// 驻留实例:先经 ATTACH 恢复或聚焦既有窗口(与平启动同一套交接,detached +/// 的标签因此先回来),再请求在该窗口打开定目录标签。任一步失败都返回 +/// false,调用方回落为独立启动——绝不吞掉用户的手势。 +pub fn try_open_directory_existing(dir: &std::path::Path) -> bool { + if legacy_request("ATTACH").is_none() { + return false; + } + // ATTACH 与 tab.new 都落到同一条 winit 事件队列上,先后有序:窗口先 + // 恢复,新标签随后落在恢复出来的窗口里。 + request_once("tab.new", json!({ "cwd": dir }), IO_TIMEOUT) + .map(|response| response.ok) + .unwrap_or(false) +} + fn legacy_request(verb: &str) -> Option<()> { read_endpoint() .and_then(|endpoint| legacy_request_to(verb, &endpoint)) diff --git a/nebula_app/src/session.rs b/nebula_app/src/session.rs index dd4035a..f9efd53 100644 --- a/nebula_app/src/session.rs +++ b/nebula_app/src/session.rs @@ -65,6 +65,50 @@ pub enum SplitAxis { TopBottom, } +/// 快照瞬间仍在一个 pane 前台运行的 AI CLI 对话。冷恢复用它接续会话: +/// 身份来自 hook 直报(claude `session_id` / codex `thread-id`),缺 id 的 +/// claude 退化成 `--continue`(按 cwd 找最近对话,恰好匹配恢复语义)。 +/// +/// 只存「安全启动描述」——来源名 + id,不存正文、不存启动参数原文。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AgentSession { + /// CLI identity as the hook reports it (`claude` / `codex`). + pub source: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_id: Option, +} + +impl AgentSession { + /// 恢复时敲进 shell 的命令行;无法安全构造(未知来源、异形 id、codex + /// 缺 id)时返回 `None`,宁可只恢复布局也不上屏可疑字节。命令形状与 + /// `ai_sessions::AiSession::resume_command`(手动恢复面板)保持一致。 + pub fn resume_command(&self) -> Option { + let id = match self.session_id.as_deref() { + Some(id) if valid_session_id(id) => Some(id), + // 有 id 但形状可疑:绝不把它敲进终端。 + Some(_) => return None, + None => None, + }; + match (self.source.as_str(), id) { + ("claude", Some(id)) => Some(format!("claude --resume {id}")), + // claude 无 id(hook 未装、OSC 认出的启动):`--continue` 恢复 + // 当前目录最近一次对话——pane 的 cwd 已经先被恢复,语义正好。 + ("claude", None) => Some("claude --continue".to_owned()), + ("codex", Some(id)) => Some(format!("codex resume {id}")), + // codex 没有按目录 continue 的形式,缺 id 只能放弃接续。 + _ => None, + } + } +} + +/// 会话 id 只可能是 uuid 一族的字符集;其余一律拒绝,这行字符串会被敲进 +/// 用户的 shell,必须按不可信输入对待。 +fn valid_session_id(id: &str) -> bool { + !id.is_empty() + && id.len() <= 64 + && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') +} + /// A tab's pane tree. Leaves carry each pane's working directory; splits carry /// the axis and the first child's share in permille — an integer, so autosave /// change detection and file diffs never trip on f32 serialization noise. @@ -73,6 +117,10 @@ pub enum SplitAxis { pub enum LayoutSession { Pane { cwd: String, + /// v4 的追加可选字段(老文件缺省、老版本忽略,无需升版):快照时 + /// 该 pane 前台的 AI CLI 对话,冷恢复据此自动接续。 + #[serde(default, skip_serializing_if = "Option::is_none")] + agent: Option, }, Split { axis: SplitAxis, @@ -95,10 +143,27 @@ impl LayoutSession { /// tab's first pane adopts. pub fn first_cwd(&self) -> &str { match self { - Self::Pane { cwd } => cwd, + Self::Pane { cwd, .. } => cwd, Self::Split { first, .. } => first.first_cwd(), } } + + /// Depth-first leaves — the SAME order `rebuild_layout` spawns panes in, + /// so index i here pairs with the i-th live leaf after a restore. + pub fn leaves(&self) -> Vec<&LayoutSession> { + fn walk<'tree>(node: &'tree LayoutSession, out: &mut Vec<&'tree LayoutSession>) { + match node { + LayoutSession::Pane { .. } => out.push(node), + LayoutSession::Split { first, second, .. } => { + walk(first, out); + walk(second, out); + }, + } + } + let mut out = Vec::new(); + walk(self, &mut out); + out + } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -358,12 +423,18 @@ mod tests { tab.layout = Some(LayoutSession::Split { axis: SplitAxis::LeftRight, ratio_permille: 618, - first: Box::new(LayoutSession::Pane { cwd: "D:/work".into() }), + first: Box::new(LayoutSession::Pane { + cwd: "D:/work".into(), + agent: Some(AgentSession { + source: "claude".into(), + session_id: Some("0199a213-c2a4-7cf5-8f6b-d746fbb6e86c".into()), + }), + }), second: Box::new(LayoutSession::Split { axis: SplitAxis::TopBottom, ratio_permille: 500, - first: Box::new(LayoutSession::Pane { cwd: "D:/logs".into() }), - second: Box::new(LayoutSession::Pane { cwd: String::new() }), + first: Box::new(LayoutSession::Pane { cwd: "D:/logs".into(), agent: None }), + second: Box::new(LayoutSession::Pane { cwd: String::new(), agent: None }), }), }); tab.active_pane = 2; @@ -375,6 +446,79 @@ mod tests { assert_eq!(restored.tabs[0].layout.as_ref().unwrap().pane_count(), 3); } + /// agent 是 v4 的追加可选字段:带 agent 的树往返不丢;没有这个字段的 + /// 旧 v4 文件照常解析(缺省 None),两个方向都不需要升版。 + #[test] + fn pane_agent_field_is_additive_on_v4() { + let json = r#"{"version":4,"boot_attempts":0,"active_tab":0,"tabs":[{"cwd":"D:/w","layout":{"kind":"pane","cwd":"D:/w"}}]}"#; + let session = parse(json).expect("v4 without agent must parse"); + assert_eq!( + session.tabs[0].layout, + Some(LayoutSession::Pane { cwd: "D:/w".into(), agent: None }) + ); + + let with_agent = LayoutSession::Pane { + cwd: "D:/w".into(), + agent: Some(AgentSession { source: "codex".into(), session_id: Some("abc-1".into()) }), + }; + let json = serde_json::to_string(&with_agent).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), with_agent); + } + + /// resume 命令是要敲进用户 shell 的字节:形状必须与手动恢复面板一致, + /// 异形 id 一律拒绝,未知来源与缺 id 的 codex 放弃接续。 + #[test] + fn agent_resume_commands_are_exact_and_injection_safe() { + let agent = |source: &str, id: Option<&str>| AgentSession { + source: source.into(), + session_id: id.map(str::to_owned), + }; + assert_eq!( + agent("claude", Some("abc-123")).resume_command().as_deref(), + Some("claude --resume abc-123") + ); + assert_eq!( + agent("claude", None).resume_command().as_deref(), + Some("claude --continue") + ); + assert_eq!( + agent("codex", Some("b5f6c1c2-1111-2222-3333-444455556666")).resume_command().as_deref(), + Some("codex resume b5f6c1c2-1111-2222-3333-444455556666") + ); + assert_eq!(agent("codex", None).resume_command(), None); + assert_eq!(agent("gemini", Some("abc")).resume_command(), None, "无 resume 语法的来源"); + // shell 元字符、空串、超长——全都不许上屏。 + assert_eq!(agent("claude", Some("abc; rm -rf /")).resume_command(), None); + assert_eq!(agent("claude", Some("")).resume_command(), None); + assert_eq!(agent("claude", Some(&"a".repeat(65))).resume_command(), None); + } + + /// leaves 的 DFS 序必须与 pane_count/重建顺序一致——恢复注入靠索引配对。 + #[test] + fn leaves_walk_depth_first_matching_rebuild_order() { + let tree = LayoutSession::Split { + axis: SplitAxis::LeftRight, + ratio_permille: 500, + first: Box::new(LayoutSession::Pane { cwd: "a".into(), agent: None }), + second: Box::new(LayoutSession::Split { + axis: SplitAxis::TopBottom, + ratio_permille: 500, + first: Box::new(LayoutSession::Pane { cwd: "b".into(), agent: None }), + second: Box::new(LayoutSession::Pane { cwd: "c".into(), agent: None }), + }), + }; + let leaves = tree.leaves(); + assert_eq!(leaves.len(), tree.pane_count()); + let cwds: Vec<_> = leaves + .iter() + .map(|leaf| match leaf { + LayoutSession::Pane { cwd, .. } => cwd.as_str(), + LayoutSession::Split { .. } => unreachable!(), + }) + .collect(); + assert_eq!(cwds, ["a", "b", "c"]); + } + #[test] fn workspace_files_round_trip_through_save_to_and_load_from() { let dir = std::env::temp_dir().join("nebula-session-test"); diff --git a/nebula_app/src/ssh_sftp.rs b/nebula_app/src/ssh_sftp.rs index 65686c9..e4302e6 100644 --- a/nebula_app/src/ssh_sftp.rs +++ b/nebula_app/src/ssh_sftp.rs @@ -540,6 +540,47 @@ async fn upload_local_paths( Ok(()) } +/// 剪贴板截图 → 远端 `/tmp` 的一次性 PNG 上传(SSH pane 的图片粘贴, +/// Netcatty 同款交互)。成功后经 runtime Prompt 通道把远端路径作为输入敲进 +/// 目标 pane——上传在 SSH runtime 上异步进行,UI 线程零阻塞;失败只留日志, +/// 绝不把错误文本粘进终端。 +pub fn upload_clipboard_image( + destination: String, + png: Vec, + pane_id: u64, + proxy: EventLoopProxy, +) { + let Ok(runtime) = crate::ssh_session::runtime() else { + return; + }; + runtime.spawn(async move { + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_millis()) + .unwrap_or(0); + // `/tmp` 而不是远端 cwd:绝对路径对 codex/claude 一样可读,且不往 + // 用户的项目目录里排泄粘贴产物;`~` 需要展开,这里没有 shell。 + let remote = format!("/tmp/nebula-paste-{stamp}.png"); + let result = async { + let sftp = crate::ssh_session::open_sftp(&destination).await?; + let mut file = sftp + .open_with_flags( + remote.clone(), + OpenFlags::CREATE | OpenFlags::TRUNCATE | OpenFlags::WRITE, + ) + .await?; + file.write_all(&png).await?; + file.shutdown().await?; + Ok::<_, SftpError>(()) + } + .await; + match result { + Ok(()) => crate::runtime_api::dispatch_prompt(&proxy, pane_id, remote), + Err(err) => log::warn!("剪贴板图片上传失败({destination}): {err}"), + } + }); +} + async fn upload_file_atomic( sftp: &SftpSession, local: &Path, diff --git a/nebula_app/src/window_context.rs b/nebula_app/src/window_context.rs index 0549ca3..373b32d 100644 --- a/nebula_app/src/window_context.rs +++ b/nebula_app/src/window_context.rs @@ -1522,9 +1522,49 @@ impl WindowContext { entry.custom_name = tab.custom_name.clone(); } entry.custom_color = tab.color; + self.resume_agent_sessions(tab); true } + /// 冷恢复接续 AI 对话(T1-2):把保存的叶子与重建后的活动布局按同一 + /// DFS 序配对,给记录了前台对话的 pane 敲入 resume 命令。 + /// + /// 注入走 ConPTY 输入队列(fastfetch intro 同款机制):字节安静排队, + /// shell 就绪后读到第一行输入才执行,不需要等提示符出现。首叶继承 tab + /// 的 launch 身份,只有确定它是裸 shell(Default / Shell)才注入—— + /// Profile 可能直接启动任意程序(甚至就是 claude,命令会变成发给新 + /// 对话的假消息),SSH 连接期还有密码/口令交互,字节会落进错误的 + /// 输入框;其余叶子由 `rebuild_layout` 统一以默认 shell 重建,安全。 + fn resume_agent_sessions(&mut self, tab: &session::TabSession) { + if !self.display.nebula_resume_ai { + return; + } + let Some(saved) = tab.layout.as_ref() else { return }; + let mut live = Vec::new(); + self.tabs[self.active_tab].layout.leaves(&mut live); + let launch = tab.launch.as_ref().unwrap_or(&session::LaunchSession::Default); + let seed_is_plain_shell = matches!( + launch, + session::LaunchSession::Default | session::LaunchSession::Shell { .. } + ); + // 某个叶子 spawn 失败时 rebuild_layout 会折叠父节点,后续配对右移 + // 错位——zip 在较短一侧截止。错位注入最多把 resume 敲进错的兄弟 + // pane,claude/codex 自己会报「找不到会话」,不会破坏任何数据。 + for (index, (leaf, pane_id)) in saved.leaves().iter().zip(live).enumerate() { + let session::LayoutSession::Pane { agent: Some(agent), .. } = leaf else { + continue; + }; + if index == 0 && !seed_is_plain_shell { + continue; + } + let Some(command) = agent.resume_command() else { continue }; + let Some(i) = self.pane_index(pane_id) else { continue }; + let pane = &mut self.panes[i]; + pane.notifier.notify(command.into_bytes()); + pane.notifier.notify(vec![b'\r']); + } + } + /// Spawn a default-shell tab in a saved tab's first-leaf directory — /// both the `Default` launch path and the cross-platform fallback when a /// saved program does not exist on this machine. @@ -1568,7 +1608,7 @@ impl WindowContext { seed: &mut Option, ) -> Option { match node { - session::LayoutSession::Pane { cwd } => { + session::LayoutSession::Pane { cwd, .. } => { if let Some(id) = seed.take() { return Some(Layout::Leaf(id)); } @@ -1701,6 +1741,26 @@ impl WindowContext { let pane_cwd = |id: PaneId| { self.pane(id).map(|p| p.nebula_state.cwd.trim().to_owned()).unwrap_or_default() }; + // 每个叶子除 cwd 外还记录「此刻前台的 AI 对话」:running_program 由 + // hook/OSC 设置、133;D 收尾清除,是「快照瞬间它还开着」的存活判据; + // 会话 id 必须与它同源(id 记录后前台可能换了别的程序)。只有 hook + // 能报 id 的 claude/codex 走精确 resume,OSC 认出的裸 claude 退化 + // `--continue`,其余来源不接续。 + let pane_agent = |id: PaneId| -> Option { + let state = &self.pane(id)?.nebula_state; + let program = state.running_program.as_deref()?; + match &state.ai_session { + Some(identity) if identity.source == program => Some(session::AgentSession { + source: identity.source.clone(), + session_id: Some(identity.session_id.clone()), + }), + _ if matches!(program, "claude" | "codex") => Some(session::AgentSession { + source: program.to_owned(), + session_id: None, + }), + _ => None, + } + }; let mut leaves = Vec::new(); tab.layout.leaves(&mut leaves); session::TabSession { @@ -1708,7 +1768,7 @@ impl WindowContext { custom_name: tab.custom_name.clone(), color: tab.custom_color, launch: Some(Self::launch_session(&tab.launch)), - layout: Some(Self::layout_session(&tab.layout, &pane_cwd)), + layout: Some(Self::layout_session(&tab.layout, &pane_cwd, &pane_agent)), active_pane: leaves.iter().position(|id| *id == tab.active_pane).unwrap_or(0), } } @@ -1738,13 +1798,17 @@ impl WindowContext { } } - /// Serialize a layout tree, resolving each leaf to its pane's cwd. + /// Serialize a layout tree, resolving each leaf to its pane's cwd plus the + /// AI conversation running in it (if any). fn layout_session( layout: &Layout, pane_cwd: &impl Fn(PaneId) -> String, + pane_agent: &impl Fn(PaneId) -> Option, ) -> session::LayoutSession { match layout { - Layout::Leaf(id) => session::LayoutSession::Pane { cwd: pane_cwd(*id) }, + Layout::Leaf(id) => { + session::LayoutSession::Pane { cwd: pane_cwd(*id), agent: pane_agent(*id) } + }, Layout::Split { direction, ratio, first, second, .. } => { session::LayoutSession::Split { axis: match direction { @@ -1752,8 +1816,8 @@ impl WindowContext { crate::display::SplitDirection::TopBottom => session::SplitAxis::TopBottom, }, ratio_permille: (ratio.clamp(0.0, 1.0) * 1000.0).round() as u16, - first: Box::new(Self::layout_session(first, pane_cwd)), - second: Box::new(Self::layout_session(second, pane_cwd)), + first: Box::new(Self::layout_session(first, pane_cwd, pane_agent)), + second: Box::new(Self::layout_session(second, pane_cwd, pane_agent)), } }, } @@ -2049,6 +2113,14 @@ impl WindowContext { { let state = &mut self.panes[idx].nebula_state; state.running_program = Some(ev.source.clone()); + // 会话身份跟着事件走:同一个 pane 里 /clear、重开会话都会带来 + // 新 id,最后一次上报永远是权威。133;D(CLI 退回提示符)清除。 + if let Some(id) = ev.session_id.as_deref() { + state.ai_session = Some(crate::display::AiSessionIdentity { + source: ev.source.clone(), + session_id: id.to_owned(), + }); + } } match ev.kind { @@ -3049,28 +3121,27 @@ impl WindowContext { Event::new(EventType::NebulaResizeSettled, self.display.window.id()); scheduler.schedule(event, Duration::from_millis(150), false, timer); } else { - // Leading edge: flush now. `resize_active_layout_ptys` - // only touches notifiers/intro (no terminal lock), so the - // focused pane's guard held above stays safe. + // Leading edge: commit every grid before its PTY. This is + // the first size in the drag sequence, so committing it + // immediately preserves startup/single-resize latency + // while every following tick can remain visual-only. self.display.nebula_pty_resize_pending = false; self.last_pty_resize = Some(now); - self.resize_active_layout_ptys(); + drop(terminal); + self.resize_active_layout(); + terminal = terminal_arc.lock(); } } - // A window resize rebuilt `self.display.size_info`; re-derive every - // pane's grid so a split tracks the new dimensions. PTY-side - // notification waits for the settle timer above. - if !matches!(self.active_layout(), Layout::Leaf(_)) { - drop(terminal); - self.resize_active_layout_grids(); - return; - } + // During a drag the renderer uses each pane's new visual viewport, + // while its grid deliberately remains at the last ConPTY-committed + // size. Do not reflow split grids here: that would recreate the + // width-history divergence this debounce exists to prevent. } if self.dirty || self.mouse.hint_highlight_dirty { - let visual_point = - self.mouse.point(&self.display.pane_view(), terminal.grid().display_offset()); + let view = self.display.pane_view(); + let visual_point = self.mouse.point(&view, &*terminal); let pane = match focused { Some(index) => &self.panes[index], None => &self.doc_pane, @@ -3080,7 +3151,7 @@ impl WindowContext { .terminal_math_source_point( visual_point, self.mouse.cell_side, - terminal.grid().display_offset(), + terminal.viewport_origin_for(view.screen_lines()), ) .0; self.dirty |= self.display.update_highlighted_hints( @@ -3149,7 +3220,10 @@ impl WindowContext { return; } self.last_pty_resize = Some(Instant::now()); - self.resize_active_layout_ptys(); + // Commit the final geometry in the same ordering as the leading edge: + // output parsed after the PTY resize now sees the exact grid reflow + // history used by ConPTY, without paying for per-tick resize storms. + self.resize_active_layout(); } /// Submit the pending changes to the `Display`. diff --git a/nebula_app/src/window_context/runtime.rs b/nebula_app/src/window_context/runtime.rs index 4085a64..85652e3 100644 --- a/nebula_app/src/window_context/runtime.rs +++ b/nebula_app/src/window_context/runtime.rs @@ -103,9 +103,16 @@ impl WindowContext { Ok(()) } - pub(crate) fn runtime_new_tab(&mut self) -> Result { + pub(crate) fn runtime_new_tab( + &mut self, + cwd: Option, + ) -> Result { let before = self.tabs.len(); - self.handle_tab_request(TabRequest::New); + let request = match cwd { + Some(dir) => TabRequest::NewAtDirectory(dir), + None => TabRequest::New, + }; + self.handle_tab_request(request); if self.tabs.len() == before { return Err(ApiError::new( "action_failed", diff --git a/nebula_gpui/Cargo.toml b/nebula_gpui/Cargo.toml index edc81ad..1e20dac 100644 --- a/nebula_gpui/Cargo.toml +++ b/nebula_gpui/Cargo.toml @@ -18,6 +18,10 @@ name = "nebula-gpui" path = "src/main.rs" [dependencies] +# gpui 与 gpui-component 必须成对锁定:0.5.1 编译在 crates.io 的 gpui 0.2.2 +# 上。两者已经由工作区根 Cargo.toml 的 [patch.crates-io] 重定向到我们的 +# fork(Kuddev/zed、Kuddev/gpui-component 的 nebula 分支);版本号语义不变, +# 加补丁/升级流程见根清单的手册注释。 gpui = "=0.2.2" gpui-component = "=0.5.1" gpui-component-assets = "=0.5.1"