From 8564334d667fd5a4ce9074804a4203a370cb6cca Mon Sep 17 00:00:00 2001 From: kuddev Date: Mon, 21 Sep 2026 19:25:35 +0800 Subject: [PATCH] Integrate complete 1.9.0 desktop changes and Windows session recovery Include cloud settings, panel sizing, command groups, Agent integration settings and runtime input fixes from main. Bind native Codex recovery to the live pane process and preserve verified IDs; add CMD recovery choices and an explicit exit path when identity discovery fails. --- CHANGELOG.md | 12 + THIRD-PARTY-NOTICES | 7 + ...6-09-20-settings-installation-ownership.md | 77 ++++ ...09-21-runtime-codex-submission-boundary.md | 65 +++ .../2026-09-21-native-resume-identity.md | 10 +- ...026-09-21-windows-open-rollout-and-exit.md | 101 ++++ docs/release-notes/v1.9.0.md | 12 + docs/runtime-control-api.md | 14 + extra/completions/windows/_pebrel | 2 +- extra/completions/windows/pebrel.fish | 2 +- extra/logo/README.md | 15 + nebula_app/Cargo.toml | 1 + .../assets/icons/nebula-agent-copilot.svg | 1 + .../assets/icons/nebula-agent-cursor.svg | 1 + nebula_app/i18n/en-US.json | 42 +- nebula_app/i18n/zh-CN.json | 42 +- nebula_app/i18n/zh-TW.json | 42 +- nebula_app/res/hooks/pi.ts | 14 +- nebula_app/src/ai_agents.rs | 3 +- nebula_app/src/ai_hook.rs | 49 +- nebula_app/src/ai_hook/event.rs | 12 +- nebula_app/src/ai_hook/installation.rs | 2 +- nebula_app/src/ai_hook/installation/notify.rs | 57 ++- nebula_app/src/ai_hook/lifecycle.rs | 4 +- nebula_app/src/ai_hook/native_events.rs | 147 ++++++ nebula_app/src/ai_hook/protocol.rs | 17 +- nebula_app/src/ai_hook/win.rs | 326 ++++++------- nebula_app/src/ai_hook/win/codex_hooks.rs | 103 ++++- nebula_app/src/ai_hook/win/codex_notify.rs | 58 ++- nebula_app/src/ai_hook/win/config_guard.rs | 157 +++---- nebula_app/src/ai_hook/win/cursor.rs | 193 ++++++++ nebula_app/src/ai_hook/win/extended.rs | 161 +++++++ nebula_app/src/ai_hook/win/kimi.rs | 49 +- nebula_app/src/ai_hook/win/managed_files.rs | 22 + nebula_app/src/ai_hook/win/settings.rs | 325 +++++++++++++ nebula_app/src/cli.rs | 5 +- nebula_app/src/display/side_panel/mod.rs | 5 + nebula_app/src/gpui_shell/assets.rs | 6 +- nebula_app/src/gpui_shell/settings_pane.rs | 3 + .../src/gpui_shell/settings_pane/agents.rs | 362 +++++++++++++++ .../gpui_shell/settings_pane/agents/tests.rs | 191 ++++++++ .../gpui_shell/settings_pane/backup/tests.rs | 4 + .../gpui_shell/settings_pane/backup/view.rs | 88 ++-- .../settings_pane/initialization.rs | 1 + .../gpui_shell/settings_pane/navigation.rs | 14 +- .../src/gpui_shell/settings_pane/segmented.rs | 59 +-- .../src/gpui_shell/settings_pane/tests.rs | 8 +- .../src/gpui_shell/ssh_settings/library.rs | 50 +- nebula_app/src/gpui_shell/terminal/view.rs | 27 -- .../terminal/view/agent_activity.rs | 20 +- .../gpui_shell/terminal/view/output_tests.rs | 32 ++ .../src/gpui_shell/terminal/view/runtime.rs | 65 ++- .../terminal/view/startup_command.rs | 69 ++- .../gpui_shell/terminal/view/startup_tests.rs | 177 +++++++ nebula_app/src/gpui_shell/workspace.rs | 32 +- .../src/gpui_shell/workspace/closing.rs | 101 +++- .../gpui_shell/workspace/command_manager.rs | 434 +++++++----------- .../workspace/command_manager/group_tests.rs | 129 +++++- .../workspace/command_manager/groups.rs | 204 ++++++-- .../workspace/command_manager/rows.rs | 211 +++++++++ .../src/gpui_shell/workspace/details_panel.rs | 25 +- .../workspace/details_panel_tests.rs | 80 +++- .../src/gpui_shell/workspace/file_tree.rs | 62 ++- .../workspace/file_tree/path_bar.rs | 285 ++++++++++++ .../workspace/settings_navigation.rs | 10 + .../src/gpui_shell/workspace/tab_menu.rs | 15 + .../workspace/windowing/shutdown.rs | 21 +- nebula_app/src/input/terminal_input.rs | 61 +++ nebula_app/src/main.rs | 3 + nebula_app/src/platform/agent_integrations.rs | 210 +++++++++ .../src/platform/ai_session_identity.rs | 41 +- .../platform/ai_session_identity/windows.rs | 259 +++++++++++ .../ai_session_identity/windows/tests.rs | 186 ++++++++ nebula_app/src/process_tree.rs | 9 +- nebula_app/src/runtime_api/cli.rs | 88 +++- nebula_app/src/runtime_api/shortcuts.rs | 100 ++-- nebula_app/src/saved_commands.rs | 13 + nebula_app/src/saved_commands/groups.rs | 20 +- nebula_app/src/window_context/runtime.rs | 17 +- nebula_hook/src/main.rs | 63 ++- nebula_settings/src/agent_hooks.rs | 117 +++++ nebula_settings/src/lib.rs | 21 +- nebula_settings/src/reset.rs | 10 + scripts/tests/pi-notification-bridge.test.mjs | 2 +- scripts/tests/test_pi_bridge.mjs | 22 +- 85 files changed, 5211 insertions(+), 971 deletions(-) create mode 100644 architecture/notes/nebula_app/ai_hook/2026-09-20-settings-installation-ownership.md create mode 100644 architecture/notes/nebula_app/input/2026-09-21-runtime-codex-submission-boundary.md create mode 100644 architecture/notes/nebula_app/session/2026-09-21-windows-open-rollout-and-exit.md create mode 100644 nebula_app/assets/icons/nebula-agent-copilot.svg create mode 100644 nebula_app/assets/icons/nebula-agent-cursor.svg create mode 100644 nebula_app/src/ai_hook/native_events.rs create mode 100644 nebula_app/src/ai_hook/win/cursor.rs create mode 100644 nebula_app/src/ai_hook/win/extended.rs create mode 100644 nebula_app/src/ai_hook/win/settings.rs create mode 100644 nebula_app/src/gpui_shell/settings_pane/agents.rs create mode 100644 nebula_app/src/gpui_shell/settings_pane/agents/tests.rs create mode 100644 nebula_app/src/gpui_shell/workspace/command_manager/rows.rs create mode 100644 nebula_app/src/gpui_shell/workspace/file_tree/path_bar.rs create mode 100644 nebula_app/src/platform/agent_integrations.rs create mode 100644 nebula_app/src/platform/ai_session_identity/windows.rs create mode 100644 nebula_app/src/platform/ai_session_identity/windows/tests.rs create mode 100644 nebula_settings/src/agent_hooks.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 17a5c49..57cb33d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ Every release entry is provided in English and Simplified Chinese. #### Fixed - Fixed Codex cold restoration using a Hook group identifier instead of the native conversation identity. Saved sessions with an older or unrecognized transcript filename retain their existing valid conversation ID. +- Windows local Codex sessions can recover their exact conversation ID and native path from the running process, including when a Hook identity is missing. Later lifecycle-only events retain the verified identity. +- When Codex reports a missing saved conversation, recovery opens its native chooser, including from CMD. Failed restores also offer an explicit conversation choice in the tab menu. +- Closing with unavailable AI session identities now offers saving known state and exiting; save failures and changed editor drafts still keep the application open. - Fixed file-editor selections and search matches blending into light and dark backgrounds. Ordinary text/code files now reveal the configured workspace background image, and the caret row follows the active theme. - Fixed copying the terminal working directory: menu and command-palette actions copy the directory reported by the shell, including guest paths, while normal selection copying retains the original text. - Fixed AI failure and cancellation events being presented as successful completion; Pi retry-aware outcomes and Claude failure events use the shared notification result handling. @@ -35,6 +38,9 @@ Every release entry is provided in English and Simplified Chinese. - Made notification duration configurable and preserved the selected duration when replacing a pane's result. - Reduced repeated Git/SVN drawer work by reusing repository snapshots and rendering visible rows. - Improved settings search, SSH connection cards and terminal file-link opening. +- Made cloud-storage configuration compact, bounded provider and credential fields, and aligned file-panel controls. +- Updated command-group navigation, ordering and add actions. +- Added settings for enabling each supported Agent integration while retaining other managed and user-owned hook entries. Windows packages include an installer and portable ZIP. Linux and macOS packages retain their Preview designation. This candidate is intended for installation testing; visual acceptance and live cold restoration with every external CLI remain separate from automated checks. @@ -50,6 +56,9 @@ Windows packages include an installer and portable ZIP. Linux and macOS packages #### 修复 - 修复 Codex 冷恢复误用 Hook 分组标识而非原生对话身份的问题。旧快照中的会话文件名无法识别时,保留原有有效对话 ID。 +- Windows 本地 Codex 会话可从正在运行的进程找回准确的对话 ID 和原生文件路径,补足 Hook 身份缺失的情况;后续仅包含生命周期信息的事件会保留已核验身份。 +- Codex 报告保存的对话不存在时,包括 CMD 在内的恢复流程可打开原生会话选择器;恢复失败后,也可从标签页菜单主动选择对话。 +- AI 会话身份暂时无法取得时,关闭操作提供保存已知状态并退出的选择;保存失败或编辑器草稿发生变化时仍保持应用打开。 - 修复文件编辑器选区与查找命中融入浅色、深色背景的问题;普通文本和代码文件可透出已配置的工作区背景图,光标所在行跟随当前主题。 - 修复终端工作目录复制:菜单和命令面板复制 Shell 上报的目录,包括访客环境路径;普通选区复制保留原始文字。 - 修复 AI 失败和取消事件被显示为成功完成的问题;Pi 重试后的结果及 Claude 失败事件统一进入通知结果处理。 @@ -67,6 +76,9 @@ Windows packages include an installer and portable ZIP. Linux and macOS packages - 支持配置通知停留时长,并在替换窗格结果时保留选定时长。 - 复用仓库快照、仅渲染可见行,减少 Git/SVN 抽屉的重复工作。 - 改进设置搜索、SSH 连接卡片和终端文件链接打开。 +- 收紧云存储配置布局,限制服务选择器和凭据输入宽度,并统一文件面板控件尺寸。 +- 更新命令分组导航、排序和新增操作的布局。 +- 新增各受支持 Agent 的集成开关,安装与移除时保留其他受管理及用户已有 Hook 项。 Windows 提供安装器和 ZIP 便携包,Linux 和 macOS 包继续标记为 Preview。本候选用于安装测试;视觉验收和所有外部 CLI 的实际关窗恢复仍需单独验证。 diff --git a/THIRD-PARTY-NOTICES b/THIRD-PARTY-NOTICES index c310511..7780b30 100644 --- a/THIRD-PARTY-NOTICES +++ b/THIRD-PARTY-NOTICES @@ -31,6 +31,13 @@ The Windows installed-shell detection (registry lookup order and the shell brand icon artwork) derives from Tabby (github.com/Eugeny/tabby), distributed under the MIT License, whose text is reproduced below. +The Cursor and GitHub Copilot settings icons are adapted from LobeHub's +@lobehub/icons-static-svg 1.95.0 (github.com/lobehub/lobe-icons), +distributed under the MIT License, whose text is reproduced below. +Copyright (c) 2023 LobeHub. Unused SVG metadata was removed; path geometry +is unchanged. Product names and trademarks belong to their respective +owners and identify the tools without implying endorsement. + The bundled conpty.dll and OpenConsole.exe come from the Windows Terminal project (github.com/microsoft/terminal), distributed under the MIT License. diff --git a/architecture/notes/nebula_app/ai_hook/2026-09-20-settings-installation-ownership.md b/architecture/notes/nebula_app/ai_hook/2026-09-20-settings-installation-ownership.md new file mode 100644 index 0000000..0168928 --- /dev/null +++ b/architecture/notes/nebula_app/ai_hook/2026-09-20-settings-installation-ownership.md @@ -0,0 +1,77 @@ +# Hook 设置与安装所有权 + +## Status + +Implemented; integration validation in progress. + +## Context + +Agents 菜单需要安装、关闭多种本地 Hook。仅新增 UI 开关会与启动守护和卸载器 +冲突:用户关闭后,另一实例仍可能按旧授权安装回来。反复开启还必须保留 +Codex 原 notifier,不能每次再套一层包装。 + +## Evidence + +- `nebula_settings/src/agent_hooks.rs` 保存逐 Agent 的持久选择;缺失键兼容旧的 + `ai_hooks=0`,默认只开启 Claude Code 与 Codex。 +- `nebula_app/src/ai_hook/win/settings.rs` 统一操作锁、写入和实际配置读回。 +- `win/codex_hooks.rs` 的 ownership marker 与 `installation::merge_groups` + 共同限制删除范围;`win/codex_notify.rs` 保留原 notify 的命令参数。 +- Claude [command hook 合同](https://code.claude.com/docs/en/hooks#exec-form-and-shell-form) + 明确 `args` 存在时直接 spawn;路径与参数维持分离,不回退到 shell 拼接。 + 旧的 helper 名称子串识别会误认 `echo pebrel-hook.exe`,修改/删除改认完整调用。 +- Cursor [Hook 文档](https://cursor.com/docs/agent/hooks) 规定提交前响应及 + completed/aborted/error 结果,Grok [Hook 文档](https://docs.x.ai/build/features/hooks) + 说明它也读取 Claude 和 Cursor 配置,不能将借用入口的事件误识别为另一工具。 +- Oh My Pi 的官方 ExtensionAPI 提供 `agent_end`,没有 Pi 的 `agent_settled`; + 两者的包版本不可作为同一个事件合同。 + +## Decision + +- UI 只表达操作和结果,不实现安装合并或生命周期规则。 +- 可执行文件发现与本机配置操作归平台适配器所有,保留 `ai_hook::integrations` + 入口及安装器的私有访问边界。UI 与交互测试查询共享能力表,协议与生命周期 + 仍由 AI 模块负责,不在控件中散布操作系统判断。 +- 菜单、CLI、守护共用跨进程操作锁;先持久化选择再修改 Hook。守护拿锁后重读, + 不沿用操作前的设置快照。卸载先关闭全部接入,再尽力清理每一项。 +- 检测区分“存在”“完整”“错误”。成功反馈必须来自读回校验,不能把安装函数 + 返回“未修改”解释成配置已完整;原生 Codex 与 legacy notify 一并检查。 +- 共享配置只修改本工具可认领的条目,独立文件复用现有 hash/marker 归属规则。 + 无法读取、格式异常、用户编辑或其他操作占锁时保留原文并报告错误。 + Codex 已知 `--previous-notify` JSON 包装沿用有界解析,卸载保留外层 notifier, + 仅还原其中本工具包装的原 argv;未知编码不以名称片段作为删除依据。 +- 默认安装仅含 Claude Code 和 Codex。其他接入在设置中开启;普通设置重置 + 保留 Hook 授权,避免重置外观等偏好时重新授权。 +- 同一设置视图的写操作在途时禁用重复提交,完成后刷新真实配置。已提交写入 + 不依赖视图继续存活;弱实体与序号拒绝过期回调。 +- 新增适配只转换 provider 事实,共享 pane lifecycle 保持状态权威。OMP 复用 + bridge 的传输及身份逻辑,但明确走自身 `agent_end` 合同。 +- Pi/OMP 的 helper 异步启动错误被消费;POSIX 保持 controlling tty,Windows + 隐藏 helper 窗口。缺失 helper 不能终止 Agent,也不能阻止 Cursor 提交。 + +## Rejected alternatives + +- 恢复旧分支的整个安装器:会回退新版 Codex 原生 Hook 和 Kimi 配置合同。 +- 只在 UI 里保存布尔值:后台守护、CLI 和卸载不能看到同一授权边界。 + +## Consequences + +本地设置只管理本机 Hook;SSH/WSL 内的配置仍由现有远端安装入口管理。 +已运行 Agent 是否重载配置由其自身决定,菜单提示重启会话。 +不新增运行时依赖、常驻 provider 状态机或事件缓存。守护将配置变动合并成 +单个有界信号,定时扫描仅用于新目录和 watcher 不可用的情况。 + +## Validation + +对应回归在 settings、Windows 安装器、native event、helper 和 Agents 控件的 +现有测试模块中,覆盖格式/编辑保护、原生能力、重复安装移除、忙状态、失败 +反馈和视图销毁。产物与真实 GUI 的验证另行记录,不由编译或单测代替。 + +## Supersedes + +None. + +## Revisit when + +Provider 更改配置发现、事件、结果或响应合同;增加非 Windows 本地安装; +或者需要把逐 Agent 授权应用到已运行的远端会话。 diff --git a/architecture/notes/nebula_app/input/2026-09-21-runtime-codex-submission-boundary.md b/architecture/notes/nebula_app/input/2026-09-21-runtime-codex-submission-boundary.md new file mode 100644 index 0000000..ee6e9a7 --- /dev/null +++ b/architecture/notes/nebula_app/input/2026-09-21-runtime-codex-submission-boundary.md @@ -0,0 +1,65 @@ +# Codex runtime 提交边界与不确定回执 + +## Status + +Implemented locally; pending maintainer review. + +## Context + +运行时 CLI 需要可靠地向 Codex 提交文本,并区分确定拒绝与不确定投递。 +Codex 会把快速输入收集为 paste burst,过早到达的 Enter 可能成为正文换行。 +原有 runtime prompt/paste 在首次屏幕变化后补 Enter;屏幕变化不能证明输入已处理完。 +此外,部分 CLI 在投递后才校验 wait timeout,旧 ctl 缺少基线时会降级成无基线等待。 + +## Evidence + +- [`terminal_input.rs`](../../../../nebula_app/src/input/terminal_input.rs) 的 runtime 文本路径 + 保留 ConPTY 原生字符输入;非字符按键通过协商的 VT/扩展键盘/Win32 编码器发送。 +- [`runtime.rs`](../../../../nebula_app/src/gpui_shell/terminal/view/runtime.rs) 原有屏幕回显 + 屏障只比较前后屏幕,无法建立 Codex paste-burst 的输入边界。 +- Codex 的输入编辑器会先缓冲快速输入;非字符输入会形成提交前的输入边界。 +- [`AgentActivity`](../../../../nebula_app/src/ai_hook/lifecycle.rs) 已有 pending-submit + 守卫:未观察到 Working/Blocked 或完成 hook 时,旧 idle 不能制造新回合完成。 + +## Decision + +仅对已识别 Codex 的提交使用共享 `build_runtime_codex_submission`:协商的粘贴文本、 +Right、Enter 形成同一次有序输入。非空文本才附加 Right;空输入只发 Enter。 +GPUI 和 legacy adapter 复用同一实现;普通 shell 仍使用原有回显屏障。 +多行 paste 仍须通过已有校验与 bracketed-paste/目标边界,不开放新的字节注入入口。 + +等待参数在输入写入前校验。所有提交后等待路径要求有效非零基线。 +CLI 将输入请求的传输/解析错误标记为 `submission_outcome_unknown`,提示先读取目标; +服务端明确返回的拒绝仍保留原错误。没有增加自动重发或新的任务状态机。 + +## Rejected alternatives + +- 固定睡眠后按 Enter:ConPTY 分块和程序调度仍使延时成为猜测。 +- 只加 bracketed paste:Windows 原生输入读取不一定生成 Paste 事件。 +- 所有 shell 一律加 Right:会影响 PSReadLine、历史输入和原有交互语义。 +- 投递失败后再自动补一次 Enter/重发文本:回执丢失时可能重复触发任务。 +- 重写 Agent 生命周期:已存在相应 pending-submit 守卫,应保留唯一权威。 + +## Consequences + +Codex 提交不再依赖终端重绘,也没有新增 timer/线程/持久化依赖。 +这是输入顺序与边界保证,不承诺用户同时编辑草稿时的原子隔离、模型服务可用性或任务成功。 +新错误码是 CLI 层的保守分类;依赖旧泛化传输错误的调用者可改为先读后判断。 +通用 tab 任务回执与异步回传仍需独立的任务关联、超时和投递生命周期设计。 + +## Validation + +回归覆盖 VT/application-cursor/Win32 输入顺序、Unicode/大块/多行文本、空输入、 +GPUI 无重绘提交、后续 Wakeup 不重复提交、no-submit、无效等待参数先于粘贴源读取、 +缺失/零基线拒绝,以及输入传输失败与只读错误的区分。 +原生 CI 验证产品和输入适配器;回合提交与 CLI 接受输入分开报告,不把本地 +输入单测解释为真实模型服务调用成功。 + +## Supersedes + +None. + +## Revisit when + +Codex 提供可确认提交的稳定 API,或其输入 burst 语义变化时,复核并替换对应边界。 +Runtime 提供绑定会话与任务的回执后,CLI 可直接等待任务状态,减少基线适配。 diff --git a/architecture/notes/nebula_app/session/2026-09-21-native-resume-identity.md b/architecture/notes/nebula_app/session/2026-09-21-native-resume-identity.md index e81ac73..e2470e1 100644 --- a/architecture/notes/nebula_app/session/2026-09-21-native-resume-identity.md +++ b/architecture/notes/nebula_app/session/2026-09-21-native-resume-identity.md @@ -2,7 +2,8 @@ ## Status -Implemented; validation results are recorded with the change. +Implemented; validation results are recorded with the change. Windows discovery +and exit behavior are extended by [the open-rollout decision](2026-09-21-windows-open-rollout-and-exit.md). ## Context @@ -44,6 +45,13 @@ acknowledgement may omit the path but cannot conflict with a saved path or ID; merging it retains the existing path. Incomplete targets remain available for retry and emit the existing localized failure notification. +When a submitted Codex cold resume exits unsuccessfully and explicitly reports +that its exact saved ID does not exist, open the native `codex resume` chooser +once. The old target remains durable until the provider acknowledges the user's +choice. This intentional choice can replace the ID within the same provider; +ordinary recovery acknowledgements still reject conflicting identities. Other +errors do not open the chooser, and a failed chooser cannot start a retry loop. + ## Rejected alternatives - Pick the newest conversation in the same directory: several panes may share diff --git a/architecture/notes/nebula_app/session/2026-09-21-windows-open-rollout-and-exit.md b/architecture/notes/nebula_app/session/2026-09-21-windows-open-rollout-and-exit.md new file mode 100644 index 0000000..a515889 --- /dev/null +++ b/architecture/notes/nebula_app/session/2026-09-21-windows-open-rollout-and-exit.md @@ -0,0 +1,101 @@ +# Windows open-rollout identity and bounded shutdown + +## Status + +Implemented; native Windows adapter tests passed. Product and CI validation are +recorded with the integrating change. + +## Context + +The same persisted Codex thread ID can resume the same conversation across CLI +restarts. The defect is losing or overwriting that ID in the host application's +snapshot, not a requirement to select a different conversation each time. +Windows previously depended on Hook facts while Linux/WSL could inspect the +Codex process's open rollout. Waiting indefinitely for missing identity also +made closing the window impossible when no usable Hook arrived. + +## Evidence + +- [The shared probe](../../../../nebula_app/src/platform/ai_session_identity.rs) + verifies rollout filename and first-record metadata, rejects explicit + subagents and refuses multiple main conversations. +- [The Windows adapter](../../../../nebula_app/src/platform/ai_session_identity/windows.rs) + reads per-process handle snapshots for the pane shell's verified descendants. +- [Command completion](../../../../nebula_app/src/gpui_shell/terminal/view/runtime.rs) + has a native CMD route with no exit status; missing-ID recovery previously + required a nonzero status. +- [Window close](../../../../nebula_app/src/gpui_shell/workspace/closing.rs) and + [application quit](../../../../nebula_app/src/gpui_shell/workspace/windowing/shutdown.rs) + previously aborted after identity discovery timed out. + +## Decision + +Keep the existing provider-owned transcript model and optional snapshot fields. +On Windows, anchor discovery to the live pane shell PID and creation time, then +reuse the shared process-tree traversal and PID-reuse checks. Inspect only Codex +processes in that tree. Recheck each process creation time after opening it. +Duplicate candidate disk handles only to obtain their path and file identity; +open an independent file object to read the bounded first metadata record and +verify it still identifies the same file. Never seek a duplicated live handle. +The shared metadata parser remains the identity authority on every platform. + +Run potentially blocking Windows file APIs in a short-lived invocation of the +same executable, before GUI or hook initialization. The owning background task +bounds output and kills/reaps the helper after two seconds. Per-process handle +snapshots have a 2 MiB cap. No persistent service or dependency is added. Only +identity metadata crosses the helper boundary; conversation bodies do not. + +Save the ID and native path together. A lifecycle-only Hook cannot overwrite an +identity established from the open file. Apply results only to the same command +epoch with an unchanged target; failed probes preserve the last known target. + +A completed CMD restore may use its exact missing-ID diagnostic despite lacking +an exit code. Also expose an explicit conversation choice after any failed +Codex restore, independent of diagnostic language. Preserve the old target until +the chosen conversation is acknowledged; never automatically retry a chooser. + +After bounded identity discovery, closing offers cancellation or saving available +state and exiting. Consent covers missing identities, not save errors or changed +editor drafts. Save durable state before stopping terminals. The same choice +applies to whole-application exit and update exit. + +## Rejected alternatives + +- Match by cwd, newest file or directory scan: neighboring panes may have distinct + conversations in the same directory. +- Read/seek a duplicated file handle: that changes the live process file cursor. +- Query handles on the UI thread or abandon a stuck worker thread: neither + bounds the lifetime of a blocking native request. +- Block exit forever when no ID arrives: the application cannot guarantee that + every external integration will report metadata. +- Treat a missing exit code as success or require English diagnostics for every + recovery action: native CMD and localized providers need usable fallback paths. + +## Consequences + +Native discovery requires access to the same user's process/file handles. When +access, timing or identity checks fail, the saved target remains intact. A +snapshot that never had a valid ID still needs an explicit choice. Closing with +missing IDs preserves available state but cannot promise automatic resumption +for those unidentified conversations. + +## Validation + +Owned CMD and PowerShell processes keep real rollout fixtures open. Native tests +verify pane/process separation, stable ID and path, unchanged live file cursor, +filename/metadata mismatch, multiple main rollouts, process creation mismatch, +metadata-only output and helper timeout. UI regressions cover the CMD completion +route, explicit selection without a recognized diagnostic, retained native IDs +and the cancel/continue exit prompt. These fixtures do not make provider model +calls or claim success for every external CLI. + +## Supersedes + +Extends [native resume identity](2026-09-21-native-resume-identity.md) for Windows +process discovery and missing-identity shutdown; its provider identity contract +remains in force. + +## Revisit when + +Codex changes its open-file or metadata contract, Windows removes the handle +query, or the application gains persistent terminal processes. diff --git a/docs/release-notes/v1.9.0.md b/docs/release-notes/v1.9.0.md index e14f309..b549e99 100644 --- a/docs/release-notes/v1.9.0.md +++ b/docs/release-notes/v1.9.0.md @@ -12,6 +12,9 @@ ### Fixed - Fixed Codex cold restoration using a Hook group identifier instead of the native conversation identity. Saved sessions with an older or unrecognized transcript filename retain their existing valid conversation ID. +- Windows local Codex sessions can recover their exact conversation ID and native path from the running process, including when a Hook identity is missing. Later lifecycle-only events retain the verified identity. +- When Codex reports a missing saved conversation, recovery opens its native chooser, including from CMD. Failed restores also offer an explicit conversation choice in the tab menu. +- Closing with unavailable AI session identities now offers saving known state and exiting; save failures and changed editor drafts still keep the application open. - Fixed file-editor selections and search matches blending into light and dark backgrounds. Ordinary text/code files now reveal the configured workspace background image, and the caret row follows the active theme. - Fixed copying the terminal working directory: menu and command-palette actions copy the directory reported by the shell, including guest paths, while normal selection copying retains the original text. - Fixed AI failure and cancellation events being presented as successful completion; Pi retry-aware outcomes and Claude failure events use the shared notification result handling. @@ -29,6 +32,9 @@ - Made notification duration configurable and preserved the selected duration when replacing a pane's result. - Reduced repeated Git/SVN drawer work by reusing repository snapshots and rendering visible rows. - Improved settings search, SSH connection cards and terminal file-link opening. +- Made cloud-storage configuration compact, bounded provider and credential fields, and aligned file-panel controls. +- Updated command-group navigation, ordering and add actions. +- Added settings for enabling each supported Agent integration while retaining other managed and user-owned hook entries. Windows packages include an installer and portable ZIP. Linux and macOS packages retain their Preview designation. This candidate is intended for installation testing; visual acceptance and live cold restoration with every external CLI remain separate from automated checks. @@ -44,6 +50,9 @@ Windows packages include an installer and portable ZIP. Linux and macOS packages ### 修复 - 修复 Codex 冷恢复误用 Hook 分组标识而非原生对话身份的问题。旧快照中的会话文件名无法识别时,保留原有有效对话 ID。 +- Windows 本地 Codex 会话可从正在运行的进程找回准确的对话 ID 和原生文件路径,补足 Hook 身份缺失的情况;后续仅包含生命周期信息的事件会保留已核验身份。 +- Codex 报告保存的对话不存在时,包括 CMD 在内的恢复流程可打开原生会话选择器;恢复失败后,也可从标签页菜单主动选择对话。 +- AI 会话身份暂时无法取得时,关闭操作提供保存已知状态并退出的选择;保存失败或编辑器草稿发生变化时仍保持应用打开。 - 修复文件编辑器选区与查找命中融入浅色、深色背景的问题;普通文本和代码文件可透出已配置的工作区背景图,光标所在行跟随当前主题。 - 修复终端工作目录复制:菜单和命令面板复制 Shell 上报的目录,包括访客环境路径;普通选区复制保留原始文字。 - 修复 AI 失败和取消事件被显示为成功完成的问题;Pi 重试后的结果及 Claude 失败事件统一进入通知结果处理。 @@ -61,6 +70,9 @@ Windows packages include an installer and portable ZIP. Linux and macOS packages - 支持配置通知停留时长,并在替换窗格结果时保留选定时长。 - 复用仓库快照、仅渲染可见行,减少 Git/SVN 抽屉的重复工作。 - 改进设置搜索、SSH 连接卡片和终端文件链接打开。 +- 收紧云存储配置布局,限制服务选择器和凭据输入宽度,并统一文件面板控件尺寸。 +- 更新命令分组导航、排序和新增操作的布局。 +- 新增各受支持 Agent 的集成开关,安装与移除时保留其他受管理及用户已有 Hook 项。 Windows 提供安装器和 ZIP 便携包,Linux 和 macOS 包继续标记为 Preview。本候选用于安装测试;视觉验收和所有外部 CLI 的实际关窗恢复仍需单独验证。 diff --git a/docs/runtime-control-api.md b/docs/runtime-control-api.md index 8fb14d8..5693c63 100644 --- a/docs/runtime-control-api.md +++ b/docs/runtime-control-api.md @@ -105,6 +105,18 @@ generation 绑定(Codex 退出重开后不会把任务投给新会话),pan 不是"本来就是静的",这正是把提交前的 idle 误判成"已完成"的那个经典竞态。`--no-submit` 与 `--wait` 是逻辑矛盾(没提交等什么),由参数解析直接拒绝,不会执行一半再静默停下。 +发送/粘贴命令会在写入前校验等待超时,包括读取 stdin/文件粘贴内容之前。提交响应缺少 +有效的非零状态基线时,资源命令和旧 `ctl prompt/paste --wait` 都明确报错,不降级为 +可能立即匹配旧 idle 的无基线等待。超时只表示未确认结果,不代表输入没有送达;先读目标 +状态与输出,再决定是否重试,不能盲目重复投递。 +这些接口仍然等待 pane/Agent 的语义状态,不提供独立任务回合的关联回执;并发向同一 +Agent 投递时,状态结束不能单独证明某一条请求已完成。`settled` 也不等同于任务成功。 + +已识别 Codex 的提交将文本、Right 粘贴结束边界和按当前键盘协议编码的 Enter 按顺序 +放在同一次 PTY 写入中;终端启用 bracketed paste 时同时使用粘贴包络。它不依赖首次 +屏幕重绘或固定延时来推断输入已经接收。普通 shell 保留回显屏障,`--no-submit` 不附加 +边界键或 Enter。此处的提交只说明已交给 PTY 输入链路,不是服务端模型请求成功的保证。 + `agent send` 与 `agent delegate` 的结果合同不同:前者只负责投递(可选同步等待),后者从 `PEBREL_PANE_ID` 记录调用 Agent 的位置和会话身份,登记并提交成功后立即返回。目标 Agent 的 完成 Hook 到达后,Pebrel 把最多 4000 字符的结构化最终消息作为不可信 `worker_output` 自动提交 @@ -374,6 +386,8 @@ Shell/hook 结束事件归位;因此即使命令在 120ms Runtime pump 的两 - `exit_code_unavailable`:当前 Shell 没有提供可信的 OSC 133 exit code。 - `ssh_not_ready`:SSH Pane 尚未进入可安全读写的 Ready 阶段,或连接已经失败。 - `runtime_unavailable`:当前壳/生命周期没有该动作所需的真实 owner;不会伪造成功。 +- `submission_outcome_unknown`:CLI 的 Prompt、Paste 或委派请求遇到传输/响应解析失败; + 输入可能已经送达。先读取目标状态与输出,不能直接当作“未发送”重试。 - `timeout`:`pane.wait` 未在期限内观察到目标状态。`details` 会带上 `after_seq` 与最后 观察到的 `observed_state_change_seq`,用于区分「Pane 一直没动」和「跃迁了但没到目标态」。 diff --git a/extra/completions/windows/_pebrel b/extra/completions/windows/_pebrel index 082fa2b..d56bdde 100644 --- a/extra/completions/windows/_pebrel +++ b/extra/completions/windows/_pebrel @@ -1132,7 +1132,7 @@ _arguments "${_arguments_options[@]}" : \ '(--wsl)--ssh=[Install or remove hooks on an SSH host (alias or user@host)]:DESTINATION:_default' \ '--wsl=[Install or remove hooks in a WSL distribution'\''s own user configuration]:DISTRIBUTION:_default' \ '--wsl-user=[WSL user; defaults to that distribution'\''s configured user]:USER:_default' \ -'--remove[Remove Pebrel'\''s hooks from claude'\''s settings.json instead of installing them]' \ +'--remove[Remove Pebrel-managed hooks for every supported agent and preserve other integrations. Without this flag, enable Claude Code and Codex plus agents explicitly enabled in settings]' \ '-h[Print help]' \ '--help[Print help]' \ && ret=0 diff --git a/extra/completions/windows/pebrel.fish b/extra/completions/windows/pebrel.fish index d1b4a4b..f1244f5 100644 --- a/extra/completions/windows/pebrel.fish +++ b/extra/completions/windows/pebrel.fish @@ -521,7 +521,7 @@ complete -c pebrel -n "__fish_pebrel_using_subcommand notify-test" -s h -l help complete -c pebrel -n "__fish_pebrel_using_subcommand setup-ai" -l ssh -d 'Install or remove hooks on an SSH host (alias or user@host)' -r complete -c pebrel -n "__fish_pebrel_using_subcommand setup-ai" -l wsl -d 'Install or remove hooks in a WSL distribution\'s own user configuration' -r complete -c pebrel -n "__fish_pebrel_using_subcommand setup-ai" -l wsl-user -d 'WSL user; defaults to that distribution\'s configured user' -r -complete -c pebrel -n "__fish_pebrel_using_subcommand setup-ai" -l remove -d 'Remove Pebrel\'s hooks from claude\'s settings.json instead of installing them' +complete -c pebrel -n "__fish_pebrel_using_subcommand setup-ai" -l remove -d 'Remove Pebrel-managed hooks for every supported agent and preserve other integrations. Without this flag, enable Claude Code and Codex plus agents explicitly enabled in settings' complete -c pebrel -n "__fish_pebrel_using_subcommand setup-ai" -s h -l help -d 'Print help' complete -c pebrel -n "__fish_pebrel_using_subcommand ssh" -s h -l help -d 'Print help' complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from ctl env window tab pane agent migrate config notify-test setup-ai ssh help" -f -a "ctl" -d 'Agent-oriented terminal control: split panes, run commands, start Codex/Claude, send prompts, wait for state changes, and read verified terminal output' diff --git a/extra/logo/README.md b/extra/logo/README.md index 7b26cc5..e8543ef 100644 --- a/extra/logo/README.md +++ b/extra/logo/README.md @@ -92,6 +92,21 @@ by Git and are not required by the asset tests. ## Third-Party Logo Assets +### Cursor and GitHub Copilot settings icons + +`nebula_app/assets/icons/nebula-agent-cursor.svg` and +`nebula_app/assets/icons/nebula-agent-copilot.svg` use the monochrome marks from +`@lobehub/icons-static-svg` 1.95.0, retrieved on 2026-09-20: + +- Cursor: https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.95.0/icons/cursor.svg +- GitHub Copilot: https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.95.0/icons/githubcopilot.svg +- License: [MIT](LICENSE-lobe-icons), copyright LobeHub. + +Only the unused title, CSS sizing attributes and explicit closing path tags were +removed. The viewBox and path geometry are unchanged. The two embedded SVGs total +2,393 bytes; the other settings icons reuse the sidebar's existing small textures. +These marks identify the installed tools and do not imply endorsement. + ### Claude Code and Trae CLI `ai_claude.svg` and `ai_trae.svg` are vector masters from LobeHub's icon diff --git a/nebula_app/Cargo.toml b/nebula_app/Cargo.toml index c5ca3db..5014853 100644 --- a/nebula_app/Cargo.toml +++ b/nebula_app/Cargo.toml @@ -231,6 +231,7 @@ windows-sys = { version = "0.59", features = [ # A single process snapshot includes creation times even for protected # processes, so reused parent PIDs cannot attach unrelated processes to a pane. "Wdk_System_SystemInformation", + "Wdk_System_Threading", # RtlGetVersion:系统 backdrop 只从 Windows 11 build 22621 起可用, # 不能依赖会受兼容清单影响的旧版本查询 API。 "Wdk_System_SystemServices", diff --git a/nebula_app/assets/icons/nebula-agent-copilot.svg b/nebula_app/assets/icons/nebula-agent-copilot.svg new file mode 100644 index 0000000..c182fd0 --- /dev/null +++ b/nebula_app/assets/icons/nebula-agent-copilot.svg @@ -0,0 +1 @@ + diff --git a/nebula_app/assets/icons/nebula-agent-cursor.svg b/nebula_app/assets/icons/nebula-agent-cursor.svg new file mode 100644 index 0000000..25b84a0 --- /dev/null +++ b/nebula_app/assets/icons/nebula-agent-cursor.svg @@ -0,0 +1 @@ + diff --git a/nebula_app/i18n/en-US.json b/nebula_app/i18n/en-US.json index 4eca524..3d23a98 100644 --- a/nebula_app/i18n/en-US.json +++ b/nebula_app/i18n/en-US.json @@ -29,7 +29,8 @@ "interaction": "Interaction", "keymap": "Key Bindings", "advanced": "Advanced", - "backup": "Backup" + "backup": "Backup", + "agents": "Agents" }, "notifications": { "duration": "Notification duration", @@ -161,6 +162,29 @@ "silent_start": "Start silently", "silent_start_help": "Start in the background without showing a window. Enables the system tray; click its icon to reopen. Applies while the tray is enabled, except when opening a directory explicitly.", "save_failed": "Could not change startup settings: {error}" + }, + "agents": { + "title": "Coding agents", + "description": "Show agent activity, completion and approval requests in Pebrel. Claude Code and Codex are enabled by default.", + "refresh": "Refresh", + "platform_unsupported": "Automatic hook installation is currently available on Windows.", + "checking": "Checking installed agents and hooks…", + "applying": "Applying…", + "installed": "Notifications on", + "off": "Notifications off", + "not_detected": "Not installed", + "config_detected": "Agent configuration detected", + "unsupported": "Hook integration not available yet", + "unavailable": "Unavailable", + "needs_attention": "Needs attention", + "restart": "Changes apply when you restart the agent. These controls manage agents installed on this computer.", + "enabled": "Hooks installed. Restart the agent to apply.", + "disabled": "Hooks removed. Restart the agent to apply.", + "failed": "Could not update hooks: {error}", + "helper_missing": "The hook helper is missing. Repair the Pebrel installation to enable hooks.", + "needs_repair": "Installed · needs repair", + "initialize": "Run this agent once to create its configuration, then refresh.", + "config_path": "Configuration: {path}" } }, "provider": { @@ -401,6 +425,10 @@ "preview_limited": "The rich preview is shortened for this large document. Switch to Edit to read the loaded source." }, "commands": { + "add_command": "New command", + "root": "All commands", + "back_to_root": "Back to all commands", + "group_count": "{count} commands", "add_group": "New group", "group_name": "Group name", "builtin_group": "Built-in commands", @@ -789,9 +817,14 @@ "files.search_empty": "No matching files", "files.search_empty_reason": "No files or folders match this query in the current directory.", "session": { + "choose_conversation": "Choose a conversation…", + "exit_incomplete_title": "Close without identifying every AI session?", + "exit_incomplete_body": "The window layout and known conversation IDs will be saved before closing. Running commands will stop. Conversations whose IDs are still unavailable may need to be selected manually when you reopen the application.", + "exit_anyway": "Save available state and close", "save_failed": "Could not save the workspace. The application will stay open. Check free space and folder permissions, then retry.", "identity_pending": "Some running AI sessions have not reported a recovery identity. Try again after their startup finishes.", "restore_failed": "This AI conversation could not be restored. Its saved target is preserved. Use Retry recovery in the tab menu, or locate it in session history.", + "restore_choose_codex": "The saved Codex conversation is unavailable. Choose a conversation in Codex to continue.", "restore_ambiguous": "More than one native session file matches this conversation. The saved target is preserved; choose the exact conversation from session history.", "retry": "Retry session recovery" }, @@ -865,5 +898,10 @@ "announced": "AI turn notifications connected (Claude / Codex / Kimi / Pi / opencode). To revoke: pebrel setup-ai --remove" }, "notification.command_finished": "Command finished in {seconds}s", - "notification.command_failed": "Command failed (exit {code}) after {seconds}s" + "notification.command_failed": "Command failed (exit {code}) after {seconds}s", + "files.path_edit": "Edit directory path", + "files.path_placeholder": "Absolute or relative directory path", + "files.path_choose": "Choose a directory", + "files.path_open": "Open directory", + "files.path_failed": "Could not open this directory: {error}" } diff --git a/nebula_app/i18n/zh-CN.json b/nebula_app/i18n/zh-CN.json index dda3101..b4c7b5c 100644 --- a/nebula_app/i18n/zh-CN.json +++ b/nebula_app/i18n/zh-CN.json @@ -29,7 +29,8 @@ "interaction": "交互", "keymap": "按键映射", "advanced": "高级", - "backup": "备份" + "backup": "备份", + "agents": "Agents" }, "notifications": { "duration": "通知显示时长", @@ -161,6 +162,29 @@ "silent_start": "静默启动", "silent_start_help": "启动后在后台运行,不显示窗口。开启时会同时启用系统托盘,点击托盘图标可重新打开窗口。托盘启用时生效,显式打开目录时除外。", "save_failed": "无法更改启动设置:{error}" + }, + "agents": { + "title": "编程智能体", + "description": "在 Pebrel 中显示运行状态、完成通知和授权请求。Claude Code 与 Codex 默认开启。", + "refresh": "刷新", + "platform_unsupported": "目前仅 Windows 支持自动安装 hooks。", + "checking": "正在检测工具和 hooks…", + "applying": "处理中…", + "installed": "已开启通知", + "off": "未开启通知", + "not_detected": "未安装", + "config_detected": "已检测到工具配置", + "unsupported": "尚未提供 hook 接入", + "unavailable": "不可用", + "needs_attention": "需要检查", + "restart": "更改将在重启 Agent 后生效。这里管理安装在本机的 Agent。", + "enabled": "Hooks 已安装,请重启 agent 使其生效。", + "disabled": "Hooks 已移除,请重启 agent 使其生效。", + "failed": "无法更新 hooks:{error}", + "helper_missing": "缺少 hook 辅助程序,请修复 Pebrel 安装后再启用。", + "needs_repair": "已安装 · 需修复", + "initialize": "请先运行一次此工具以创建配置,然后刷新。", + "config_path": "配置:{path}" } }, "provider": { @@ -401,6 +425,10 @@ "preview_limited": "此文档较大,富文本预览已缩短。切换到编辑可查看已加载的源码。" }, "commands": { + "add_command": "新增命令", + "root": "全部命令", + "back_to_root": "返回全部命令", + "group_count": "{count} 条命令", "add_group": "新建分组", "group_name": "分组名称", "builtin_group": "内置命令", @@ -789,9 +817,14 @@ "files.search_empty": "没有匹配的文件", "files.search_empty_reason": "当前目录中没有符合查询条件的文件或文件夹。", "session": { + "choose_conversation": "选择要继续的对话…", + "exit_incomplete_title": "仍有 AI 会话未能识别,继续关闭?", + "exit_incomplete_body": "关闭前会保存窗口布局和已知的对话 ID,并停止正在运行的命令。尚未取得 ID 的对话,重新打开后可能需要手动选择。", + "exit_anyway": "保存已知状态并关闭", "save_failed": "无法保存工作区,应用将保持打开。请检查剩余空间和目录权限后重试。", "identity_pending": "部分运行中的 AI 会话尚未提供恢复身份,请等待它们启动完成后重试。", "restore_failed": "此 AI 对话未能恢复,原恢复记录已保留。可在标签菜单中重试恢复,或到历史会话中定位。", + "restore_choose_codex": "保存的 Codex 对话已不可用。请在 Codex 的会话列表中选择要继续的对话。", "restore_ambiguous": "有多个原生会话文件匹配此对话,原恢复记录已保留。请在历史会话中选择准确的对话。", "retry": "重试会话恢复" }, @@ -865,5 +898,10 @@ "announced": "已接入 AI 回合通知(Claude / Codex / Kimi / Pi / opencode)。撤销:pebrel setup-ai --remove" }, "notification.command_finished": "命令结束,用时 {seconds} 秒", - "notification.command_failed": "命令失败(退出码 {code}),用时 {seconds} 秒" + "notification.command_failed": "命令失败(退出码 {code}),用时 {seconds} 秒", + "files.path_edit": "编辑目录路径", + "files.path_placeholder": "输入绝对路径或相对路径", + "files.path_choose": "选择目录", + "files.path_open": "打开目录", + "files.path_failed": "无法打开这个目录:{error}" } diff --git a/nebula_app/i18n/zh-TW.json b/nebula_app/i18n/zh-TW.json index dab0542..4123d46 100644 --- a/nebula_app/i18n/zh-TW.json +++ b/nebula_app/i18n/zh-TW.json @@ -16,7 +16,8 @@ "interaction": "互動", "keymap": "按鍵對應", "advanced": "進階", - "backup": "備份" + "backup": "備份", + "agents": "Agents" }, "network": { "title": "網路代理", @@ -42,6 +43,29 @@ "font": { "loading": "正在列舉系統字型…", "no_matches": "沒有符合的字型" + }, + "agents": { + "title": "程式設計智能體", + "description": "在 Pebrel 中顯示執行狀態、完成通知和授權請求。Claude Code 與 Codex 預設開啟。", + "refresh": "重新整理", + "platform_unsupported": "目前僅 Windows 支援自動安裝 hooks。", + "checking": "正在偵測工具與 hooks…", + "applying": "處理中…", + "installed": "已開啟通知", + "off": "未開啟通知", + "not_detected": "未安裝", + "config_detected": "已偵測到工具設定", + "unsupported": "尚未提供 hook 整合", + "unavailable": "無法使用", + "needs_attention": "需要檢查", + "restart": "變更將在重新啟動 Agent 後生效。這裡管理安裝在本機的 Agent。", + "enabled": "Hooks 已安裝,請重新啟動 agent 使其生效。", + "disabled": "Hooks 已移除,請重新啟動 agent 使其生效。", + "failed": "無法更新 hooks:{error}", + "helper_missing": "缺少 hook 輔助程式,請修復 Pebrel 安裝後再啟用。", + "needs_repair": "已安裝 · 需修復", + "initialize": "請先執行一次此工具以建立設定,然後重新整理。", + "config_path": "設定:{path}" } }, "common": { @@ -180,5 +204,19 @@ "announced": "已接入 AI 回合通知(Claude / Codex / Kimi / Pi / opencode)。撤銷:pebrel setup-ai --remove" }, "notification.command_finished": "命令結束,耗時 {seconds} 秒", - "notification.command_failed": "命令失敗(結束代碼 {code}),耗時 {seconds} 秒" + "notification.command_failed": "命令失敗(結束代碼 {code}),耗時 {seconds} 秒", + "commands.add_command": "新增命令", + "commands.root": "全部命令", + "commands.back_to_root": "返回全部命令", + "commands.group_count": "{count} 條命令", + "files.path_edit": "編輯目錄路徑", + "files.path_placeholder": "輸入絕對路徑或相對路徑", + "files.path_choose": "選擇目錄", + "files.path_open": "開啟目錄", + "files.path_failed": "無法開啟這個目錄:{error}", + "session.restore_choose_codex": "儲存的 Codex 對話已無法使用。請在 Codex 的工作階段清單中選擇要繼續的對話。", + "session.choose_conversation": "選擇要繼續的對話…", + "session.exit_incomplete_title": "仍有 AI 工作階段無法識別,繼續關閉?", + "session.exit_incomplete_body": "關閉前會儲存視窗配置及已知的對話 ID,並停止正在執行的命令。尚未取得 ID 的對話,重新開啟後可能需要手動選擇。", + "session.exit_anyway": "儲存已知狀態並關閉" } diff --git a/nebula_app/res/hooks/pi.ts b/nebula_app/res/hooks/pi.ts index 3ed3edb..47ac1fe 100644 --- a/nebula_app/res/hooks/pi.ts +++ b/nebula_app/res/hooks/pi.ts @@ -4,7 +4,11 @@ import { openSync, readSync, closeSync } from "node:fs"; import { randomUUID } from "node:crypto"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +const SOURCE: string = "pi"; + async function supportsSettled(): Promise { + // OMP 的版本号不对应 Pi 的事件合同,不能据此等待不存在的 settled。 + if (SOURCE !== "pi") return false; // Pi's loader supplies either package alias (or a bundled virtual module). // A static runtime import of the renamed package prevents old Pi from loading // this extension at all, before any agent_end fallback can be registered. @@ -63,7 +67,7 @@ export default async function (pi: ExtensionAPI) { const identity = sessionFor(ctx); const bridge_sequence = (sequenceEpoch + BigInt(++sequence)).toString(); const event_id = `${bridge_instance}:${bridge_sequence}`; - spawn(hook, ["pi", JSON.stringify({ + const child = spawn(hook, [SOURCE, JSON.stringify({ kind, ...identity, bridge_instance, @@ -72,10 +76,14 @@ export default async function (pi: ExtensionAPI) { cwd: ctx?.cwd || "", ...result, })], { - detached: true, + // POSIX 脱离会话会丢失 /dev/tty,使 SSH 的 OSC 传输静默失效。 + detached: process.platform === "win32", stdio: "ignore", windowsHide: true, - }).unref(); + }); + // spawn 的 ENOENT 通过异步事件报告,不能让缺失 helper 终止 Agent。 + child.on("error", () => {}); + child.unref(); } catch (_) {} }; diff --git a/nebula_app/src/ai_agents.rs b/nebula_app/src/ai_agents.rs index ecfbf1c..9538031 100644 --- a/nebula_app/src/ai_agents.rs +++ b/nebula_app/src/ai_agents.rs @@ -152,7 +152,8 @@ impl AgentKind { self.slug() } - fn aliases(self) -> &'static [&'static str] { + /// 进程识别与设置页可执行文件发现共用同一组官方/兼容名称。 + pub(crate) fn aliases(self) -> &'static [&'static str] { match self { Self::Claude => &["claude", "claude-code"], Self::Codex => &["codex", "codex-cli"], diff --git a/nebula_app/src/ai_hook.rs b/nebula_app/src/ai_hook.rs index d50fc39..92595ce 100644 --- a/nebula_app/src/ai_hook.rs +++ b/nebula_app/src/ai_hook.rs @@ -13,7 +13,11 @@ mod bridges; mod event; pub(crate) mod installation; +// Native discovery/configuration keeps the existing private installer boundary. +#[path = "platform/agent_integrations.rs"] +pub(crate) mod integrations; pub(crate) mod lifecycle; +mod native_events; mod ordering; mod payload; mod protocol; @@ -48,12 +52,53 @@ pub const LEGACY_PANE_ENV: &str = "NEBULA_PANE_ID"; pub const HOOK_EXE_ENV: &str = "PEBREL_HOOK_EXE"; pub const LEGACY_HOOK_EXE_ENV: &str = "NEBULA_HOOK_EXE"; -/// Marker locating our entries inside `settings.json` — matches on the -/// helper's name so entries survive Nebula moving to a new absolute path. +/// 仅用于阻止未知 notify 包装器再次套娃;子串不能作为修改/删除的归属依据。 fn contains_helper(value: &str) -> bool { value.contains("pebrel-hook") || value.contains("nebula-hook") } +fn is_helper_executable(value: &str) -> bool { + let filename = value.rsplit(['/', '\\']).next().unwrap_or_default(); + let absolute = value.starts_with('/') + || value.starts_with("\\\\") + || (value.as_bytes().first().is_some_and(u8::is_ascii_alphabetic) + && value.as_bytes().get(1) == Some(&b':') + && matches!(value.as_bytes().get(2), Some(b'/' | b'\\'))); + (value == filename || absolute) + && !value.contains(['"', '\r', '\n', '\0']) + && ["pebrel-hook.exe", "nebula-hook.exe", "pebrel-hook", "nebula-hook"] + .iter() + .any(|name| filename.eq_ignore_ascii_case(name)) +} + +/// 只接受历史安装器产生的单条调用;echo、管道和追加命令仍属于用户。 +fn is_helper_shell_command(command: &str, source: &str) -> bool { + let Some(path) = command.trim().strip_suffix(source).and_then(|s| s.strip_suffix(' ')) else { + return false; + }; + let path = path.trim_end(); + let executable = if let Some(quoted) = path.strip_prefix('"').and_then(|s| s.strip_suffix('"')) + { + if quoted.contains('"') { + return false; + } + quoted + } else if let Some(quoted) = path.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')) { + if quoted.contains('\'') { + return false; + } + quoted + } else { + if path.chars().any(char::is_whitespace) + || path.contains(['"', '\'', ';', '&', '|', '<', '>']) + { + return false; + } + path + }; + is_helper_executable(executable) +} + /// The hook entry's argv tail. `claude` is the source discriminator /// `nebula-hook` reads from `args[0]`, and it must travel as a real argument: /// appended to the command string instead, some shell has to re-parse the whole diff --git a/nebula_app/src/ai_hook/event.rs b/nebula_app/src/ai_hook/event.rs index 942ddae..667cd92 100644 --- a/nebula_app/src/ai_hook/event.rs +++ b/nebula_app/src/ai_hook/event.rs @@ -77,7 +77,7 @@ pub fn capabilities_for(source: &str) -> AiHookCapabilities { bridge_sequence: true, serialized_delivery: true, }, - "pi" => AiHookCapabilities { + "pi" | "omp" => AiHookCapabilities { lifecycle: true, attention_events: false, attention_context: false, @@ -95,7 +95,7 @@ pub fn capabilities_for(source: &str) -> AiHookCapabilities { bridge_sequence: false, serialized_delivery: false, }, - "kimi" => AiHookCapabilities { + "kimi" | "copilot" | "grok" => AiHookCapabilities { lifecycle: true, attention_events: true, attention_context: true, @@ -103,6 +103,14 @@ pub fn capabilities_for(source: &str) -> AiHookCapabilities { bridge_sequence: false, serialized_delivery: false, }, + "cursor" => AiHookCapabilities { + lifecycle: true, + attention_events: false, + attention_context: false, + background_tasks: false, + bridge_sequence: false, + serialized_delivery: false, + }, _ => AiHookCapabilities { lifecycle: false, attention_events: false, diff --git a/nebula_app/src/ai_hook/installation.rs b/nebula_app/src/ai_hook/installation.rs index 2a0464c..54f328e 100644 --- a/nebula_app/src/ai_hook/installation.rs +++ b/nebula_app/src/ai_hook/installation.rs @@ -6,7 +6,7 @@ use serde_json::{Value, json}; use super::CodexHookMode; mod notify; -pub(crate) use notify::desired_codex_notify; +pub(crate) use notify::{desired_codex_notify, has_codex_notify, stripped_codex_notify}; pub(crate) const CODEX_TURN_EVENTS: &[&str] = &["SessionStart", "UserPromptSubmit", "Stop"]; pub(crate) const CODEX_FULL_EVENTS: &[&str] = &[ diff --git a/nebula_app/src/ai_hook/installation/notify.rs b/nebula_app/src/ai_hook/installation/notify.rs index 1428f97..7251983 100644 --- a/nebula_app/src/ai_hook/installation/notify.rs +++ b/nebula_app/src/ai_hook/installation/notify.rs @@ -1,5 +1,5 @@ //! Legacy notify composition shared by Windows and SSH installers. -use crate::ai_hook::contains_helper; +use crate::ai_hook::{contains_helper, is_helper_executable}; /// notify 序列化后的字节预算。正常接线只有几百字节;超过它的唯一已知 /// 途径是与其他 notify 包装器互相包装的指数膨胀(#38,最终 130 MB 撑爆 @@ -17,7 +17,7 @@ const NOTIFY_BYTE_BUDGET: usize = 8 * 1024; pub(crate) fn desired_codex_notify(current: &[String], helper: &str) -> Option> { let desired: Vec = match current.first() { // Already ours: heal the helper path, keep any chain tail as-is. - Some(first) if contains_helper(first) => { + Some(_) if owns_notify(current) => { let mut argv = current.to_vec(); argv[0] = helper.to_owned(); argv @@ -59,11 +59,9 @@ fn heal_nested_codex_notify(argv: &mut [String], helper: &str, depth: usize) -> return false; } let mut changed = false; - if let Some(first) = argv.first_mut().filter(|first| contains_helper(first)) { - if first != helper { - *first = helper.to_owned(); - changed = true; - } + if owns_notify(argv) && argv[0] != helper { + argv[0] = helper.to_owned(); + changed = true; } for index in 1..argv.len() { if argv[index - 1] != "--previous-notify" { @@ -81,3 +79,48 @@ fn heal_nested_codex_notify(argv: &mut [String], helper: &str, depth: usize) -> } changed } + +fn owns_notify(argv: &[String]) -> bool { + argv.first().is_some_and(|value| is_helper_executable(value)) + && argv.get(1).is_some_and(|source| source == "codex") + && (argv.len() == 2 || (argv.len() > 3 && argv[2] == "--chain")) +} + +/// 只解析已支持的 argv/JSON 包装,不用名字片段把用户文案当成已安装 Hook。 +pub(crate) fn has_codex_notify(argv: &[String]) -> bool { + stripped_codex_notify(argv).is_some() +} + +/// 返回移除自身后的原 notifier;空数组表示可以删除 notify 键。 +pub(crate) fn stripped_codex_notify(argv: &[String]) -> Option> { + let mut result = argv.to_vec(); + strip_nested(&mut result, 0).then_some(result) +} + +fn strip_nested(argv: &mut Vec, depth: usize) -> bool { + if depth >= 8 || argv.iter().map(String::len).sum::() > NOTIFY_BYTE_BUDGET { + return false; + } + let mut changed = false; + if owns_notify(argv) { + // 两层历史包装也逐层解开,但始终保留最初的程序与参数顺序。 + *argv = if argv.len() > 3 { argv[3..].to_vec() } else { Vec::new() }; + strip_nested(argv, depth + 1); + changed = true; + } + for index in 1..argv.len() { + if argv[index - 1] != "--previous-notify" { + continue; + } + let Ok(mut previous) = serde_json::from_str::>(&argv[index]) else { + continue; + }; + if strip_nested(&mut previous, depth + 1) + && let Ok(serialized) = serde_json::to_string(&previous) + { + argv[index] = serialized; + changed = true; + } + } + changed +} diff --git a/nebula_app/src/ai_hook/lifecycle.rs b/nebula_app/src/ai_hook/lifecycle.rs index 3b2719c..9f415eb 100644 --- a/nebula_app/src/ai_hook/lifecycle.rs +++ b/nebula_app/src/ai_hook/lifecycle.rs @@ -181,7 +181,7 @@ impl AgentActivity { } let same_process = owner.pid.is_some() && owner.pid == event.agent_pid || owner.remote_process.is_some() && owner.remote_process == event.remote_process - || event.source == "pi" + || matches!(event.source.as_str(), "pi" | "omp") && owner.bridge_instance.is_some() && owner.bridge_instance == event.bridge_instance; match (owner.pid, event.agent_pid) { @@ -285,7 +285,7 @@ impl AgentActivity { super::AiTurnOutcome::Failed | super::AiTurnOutcome::Incomplete | super::AiTurnOutcome::Cancelled - ) || (event.source == "pi" + ) || (matches!(event.source.as_str(), "pi" | "omp" | "cursor" | "copilot") && event.turn_outcome == super::AiTurnOutcome::Unknown) { AgentStatus::Idle diff --git a/nebula_app/src/ai_hook/native_events.rs b/nebula_app/src/ai_hook/native_events.rs new file mode 100644 index 0000000..2cd9dac --- /dev/null +++ b/nebula_app/src/ai_hook/native_events.rs @@ -0,0 +1,147 @@ +//! Native lifecycle hooks that carry their event in the helper argv. +use serde_json::Value; + +use super::payload::{attention_message, context_string}; +use super::{AiHookKind, AiTurnOutcome}; + +pub(super) fn parse( + source: &str, + event: &str, + payload: &Value, +) -> Option<(AiHookKind, Option)> { + let kind = match event { + "session-start" => AiHookKind::SessionStart, + "prompt" => AiHookKind::PromptSubmit, + "tool-complete" => AiHookKind::ToolComplete, + "done" => AiHookKind::TurnDone, + "failed" if source == "grok" => AiHookKind::TurnDone, + "error" + if source == "copilot" && payload.get("recoverable") == Some(&Value::Bool(false)) => + { + AiHookKind::TurnDone + }, + "session-end" => AiHookKind::SessionEnd, + "notification" => { + let category = + context_string(payload, &["notification_type", "notificationType", "type"]); + let actionable = category + .as_deref() + .is_some_and(|value| value == "elicitation_dialog" || value == "permission_prompt"); + if !actionable { + return None; + } + return Some((AiHookKind::NeedsAttention, attention_message(payload))); + }, + _ => return None, + }; + Some((kind, None)) +} + +pub(super) fn outcome(source: &str, event: &str, payload: &Value) -> AiTurnOutcome { + if matches!(event, "failed" | "error") { + return AiTurnOutcome::Failed; + } + match source { + "cursor" => match payload.get("status").and_then(Value::as_str) { + Some("completed") => AiTurnOutcome::Succeeded, + Some("aborted") => AiTurnOutcome::Cancelled, + Some("error") => AiTurnOutcome::Failed, + _ => AiTurnOutcome::Unknown, + }, + "copilot" => match payload.get("stopReason").and_then(Value::as_str) { + Some("end_turn") => AiTurnOutcome::Succeeded, + _ => AiTurnOutcome::Unknown, + }, + "grok" if event == "done" => AiTurnOutcome::Succeeded, + _ => AiTurnOutcome::Unknown, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ai_hook::parse_envelope; + + #[test] + fn native_events_preserve_provider_and_resume_identity() { + for source in ["copilot", "grok"] { + for (event, kind) in [ + ("session-start", AiHookKind::SessionStart), + ("prompt", AiHookKind::PromptSubmit), + ("done", AiHookKind::TurnDone), + ("tool-complete", AiHookKind::ToolComplete), + ("session-end", AiHookKind::SessionEnd), + ] { + let raw = format!( + "nebula-hook/1 source={source} pane=7 event={event}\n{{\"sessionId\":\"native-session\"}}" + ); + let parsed = parse_envelope(raw.as_bytes()).unwrap(); + assert_eq!(parsed.source, source); + assert_eq!(parsed.session_id.as_deref(), Some("native-session")); + assert_eq!(parsed.kind, kind); + assert_eq!(parsed.pane, Some(7)); + } + let idle = format!( + "nebula-hook/1 source={source} event=notification\n{{\"notification_type\":\"idle_prompt\"}}" + ); + assert!(parse_envelope(idle.as_bytes()).is_none()); + let attention = format!( + "nebula-hook/1 source={source} event=notification\n{{\"notificationType\":\"elicitation_dialog\",\"message\":\"Approve?\"}}" + ); + assert_eq!( + parse_envelope(attention.as_bytes()).unwrap().kind, + AiHookKind::NeedsAttention + ); + } + } + + #[test] + fn cursor_resume_ids_are_source_specific() { + for (source, field) in [("cursor", "conversation_id"), ("cursor", "session_id")] { + for event in ["session-start", "prompt", "done", "tool-complete", "session-end"] { + let raw = format!( + "nebula-hook/1 source={source} pane=4 event={event}\n{{\"{field}\":\"native-id\",\"sessionId\":\"foreign-id\"}}" + ); + let parsed = parse_envelope(raw.as_bytes()).unwrap(); + assert_eq!(parsed.source, source); + assert_eq!(parsed.session_id.as_deref(), Some("native-id")); + } + let raw = format!( + "nebula-hook/1 source={source} event=done\n{{\"sessionId\":\"foreign-id\"}}" + ); + assert!(parse_envelope(raw.as_bytes()).unwrap().session_id.is_none()); + } + for (status, outcome) in [ + ("completed", AiTurnOutcome::Succeeded), + ("aborted", AiTurnOutcome::Cancelled), + ("error", AiTurnOutcome::Failed), + ] { + let raw = format!( + "nebula-hook/1 source=cursor event=done\n{{\"status\":\"{status}\",\"conversation_id\":\"id\"}}" + ); + let parsed = parse_envelope(raw.as_bytes()).unwrap(); + assert_eq!(parsed.kind, AiHookKind::TurnDone); + assert_eq!(parsed.turn_outcome, outcome); + } + } + + #[test] + fn omp_results_do_not_become_pi_sessions() { + let parsed = parse_envelope(b"nebula-hook/1 source=omp pane=3\n{\"kind\":\"done\",\"session_id\":\"omp-session\",\"stop_reason\":\"error\"}").unwrap(); + assert_eq!(parsed.source, "omp"); + assert_eq!(parsed.session_id.as_deref(), Some("omp-session")); + assert_eq!(parsed.kind, AiHookKind::TurnDone); + assert_eq!(parsed.turn_outcome, AiTurnOutcome::Failed); + } + + #[test] + fn native_failures_and_foreign_cursor_payloads_never_report_success() { + let grok = parse_envelope(b"nebula-hook/1 source=grok event=failed\n{\"hookEventName\":\"StopFailure\",\"sessionId\":\"grok-session\"}").unwrap(); + assert_eq!(grok.turn_outcome, AiTurnOutcome::Failed); + let retry = b"nebula-hook/1 source=copilot event=error\n{\"recoverable\":true,\"sessionId\":\"retry\"}"; + assert!(parse_envelope(retry).is_none()); + let failed = b"nebula-hook/1 source=copilot event=error\n{\"recoverable\":false,\"sessionId\":\"failed\"}"; + assert_eq!(parse_envelope(failed).unwrap().turn_outcome, AiTurnOutcome::Failed); + assert!(parse_envelope(b"nebula-hook/1 source=cursor event=done\n{\"hookEventName\":\"Stop\",\"sessionId\":\"grok\"}").is_none()); + } +} diff --git a/nebula_app/src/ai_hook/protocol.rs b/nebula_app/src/ai_hook/protocol.rs index 2035fac..a743964 100644 --- a/nebula_app/src/ai_hook/protocol.rs +++ b/nebula_app/src/ai_hook/protocol.rs @@ -27,13 +27,14 @@ pub(super) fn parse_envelope(bytes: &[u8]) -> Option { if fields.next() != Some("nebula-hook/1") { return None; } - let (mut source, mut pane, mut codex_mode) = (None, None, None); + let (mut source, mut pane, mut codex_mode, mut native_event) = (None, None, None, None); for field in fields { match field.split_once('=') { Some(("source", v)) => source = Some(v.to_owned()), Some(("pane", v)) => pane = v.parse().ok(), Some(("codex_hooks", "full")) => codex_mode = Some(CodexHookMode::Full), Some(("codex_hooks", "turns")) => codex_mode = Some(CodexHookMode::Turns), + Some(("event", event)) => native_event = Some(event), _ => (), } } @@ -64,6 +65,8 @@ pub(super) fn parse_envelope(bytes: &[u8]) -> Option { "claude" | "kimi" => &["session_id"], "codex" if native_codex => &["session_id"], "codex" => &["thread-id"], + "cursor" => &["conversation_id", "session_id"], + "copilot" | "grok" => &["sessionId"], // opencode/pi 由我们自己的 bridge 规范化成 snake_case;camelCase 是 // provider SDK 原样透传时的兼容路径。 _ => &["session_id", "sessionID", "sessionId"], @@ -192,7 +195,7 @@ pub(super) fn parse_envelope(bytes: &[u8]) -> Option { // `{"kind":"prompt|done|attention","message":?}` payload (see the // embedded plugin in `ensure_opencode_plugin`), so this side stays // decoupled from opencode's evolving SDK event schema. - "opencode" | "pi" => match payload.get("kind").and_then(Value::as_str) { + "opencode" | "pi" | "omp" => match payload.get("kind").and_then(Value::as_str) { Some("session-start") => (AiHookKind::SessionStart, None), Some("prompt") => (AiHookKind::PromptSubmit, None), Some("tool-complete") => (AiHookKind::ToolComplete, None), @@ -201,6 +204,10 @@ pub(super) fn parse_envelope(bytes: &[u8]) -> Option { Some("attention") => (AiHookKind::NeedsAttention, attention_message(&payload)), _ => return None, }, + "cursor" if payload.get("hookEventName").is_some() => return None, + "cursor" | "copilot" | "grok" => { + super::native_events::parse(&source, native_event?, &payload)? + }, _ => return None, }; if source == "codex" && kind == AiHookKind::TurnDone && event_id.is_none() { @@ -219,7 +226,11 @@ pub(super) fn parse_envelope(bytes: &[u8]) -> Option { && payload.get("hook_event_name").and_then(Value::as_str) == Some("Interrupt") { AiTurnOutcome::Cancelled - } else if source == "pi" && kind == AiHookKind::TurnDone { + } else if matches!(source.as_str(), "cursor" | "copilot" | "grok") + && kind == AiHookKind::TurnDone + { + super::native_events::outcome(&source, native_event?, &payload) + } else if matches!(source.as_str(), "pi" | "omp") && kind == AiHookKind::TurnDone { match payload.get("stop_reason").and_then(Value::as_str) { Some("stop") => AiTurnOutcome::Succeeded, Some("error") => AiTurnOutcome::Failed, diff --git a/nebula_app/src/ai_hook/win.rs b/nebula_app/src/ai_hook/win.rs index e114e4f..030855e 100644 --- a/nebula_app/src/ai_hook/win.rs +++ b/nebula_app/src/ai_hook/win.rs @@ -4,14 +4,17 @@ use std::sync::atomic::{AtomicBool, Ordering}; use serde_json::{Value, json}; -use super::{CLAUDE_EVENTS, HELPER_ARGS, contains_helper}; +use super::{CLAUDE_EVENTS, HELPER_ARGS, is_helper_executable, is_helper_shell_command}; mod codex_hooks; mod codex_notify; mod config_guard; +mod cursor; +mod extended; mod kimi; mod managed_files; mod runtime_skills; +pub(super) mod settings; mod transport; use codex_hooks::{ensure_codex_hooks, remove_codex_hooks}; use codex_notify::{codex_config_dir, ensure_codex_notify, remove_codex_notify}; @@ -83,6 +86,7 @@ pub fn ensure_claude_hooks() -> bool { let Some(command) = helper_command() else { return false }; let path = dir.join("settings.json"); + let Ok(Some(_lock)) = crate::atomic_file::try_lock(&path) else { return false }; let mut root: Value = match std::fs::read_to_string(&path) { Ok(raw) => match serde_json::from_str(&raw) { Ok(json) => json, @@ -94,7 +98,11 @@ pub fn ensure_claude_hooks() -> bool { return false; }, }, - Err(_) => json!({}), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => json!({}), + Err(error) => { + log::warn!("ai_hook: cannot read {} ({error}); left alone", path.display()); + return false; + }, }; let Some(changed) = install_into(&mut root, &command) else { @@ -144,8 +152,7 @@ fn install_into(root: &mut Value, command: &str) -> Option { continue; }; for cmd in cmds { - let ours = cmd.get("command").and_then(Value::as_str).is_some_and(contains_helper); - if !ours { + if !is_our_claude_hook(cmd) { continue; } found = true; @@ -178,16 +185,42 @@ fn install_into(root: &mut Value, command: &str) -> Option { Some(changed) } +fn is_our_claude_hook(entry: &Value) -> bool { + if entry.get("type").is_some_and(|kind| kind != "command") { + return false; + } + let Some(command) = entry.get("command").and_then(Value::as_str) else { return false }; + match entry.get("args") { + Some(args) => is_helper_executable(command) && args == &json!(HELPER_ARGS), + // 历史 shell 形式和曾缺少 args 的裸路径需要可修复、可卸载。 + None => is_helper_executable(command) || is_helper_shell_command(command, "claude"), + } +} + /// Strip every nebula-hook entry (and matchers left empty by that). fn remove_hooks() -> std::io::Result { let Some(dir) = claude_config_dir() else { return Ok(false) }; let path = dir.join("settings.json"); + if !path.exists() { + return Ok(false); + } + let _lock = crate::atomic_file::try_lock(&path)? + .ok_or_else(|| std::io::Error::other("Claude hook configuration is busy"))?; let raw = match std::fs::read_to_string(&path) { Ok(raw) => raw, - Err(_) => return Ok(false), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(error), }; let mut root: Value = serde_json::from_str(&raw).map_err(|e| std::io::Error::other(e.to_string()))?; + let changed = remove_claude_hooks_from(&mut root); + if changed { + write_atomic(&path, &serde_json::to_string_pretty(&root)?)?; + } + Ok(changed) +} + +fn remove_claude_hooks_from(root: &mut Value) -> bool { let mut changed = false; if let Some(hooks) = root.get_mut("hooks").and_then(Value::as_object_mut) { for event in CLAUDE_EVENTS { @@ -197,9 +230,7 @@ fn remove_hooks() -> std::io::Result { for matcher in matchers.iter_mut() { if let Some(cmds) = matcher.get_mut("hooks").and_then(Value::as_array_mut) { let before = cmds.len(); - cmds.retain(|c| { - !c.get("command").and_then(Value::as_str).is_some_and(contains_helper) - }); + cmds.retain(|entry| !is_our_claude_hook(entry)); changed |= cmds.len() != before; } } @@ -209,78 +240,67 @@ fn remove_hooks() -> std::io::Result { changed |= matchers.len() != before; } } - if changed { - write_atomic(&path, &serde_json::to_string_pretty(&root)?)?; - } - Ok(changed) + changed } -/// `nebula setup-ai [--remove]` entrypoint (console attached in `main`). +/// CLI 与设置菜单复用实际安装/移除和读回校验;卸载必须尽力处理所有接入。 pub fn setup_ai_cli(remove: bool) -> i32 { - let Some(dir) = claude_config_dir() else { - eprintln!("找不到用户目录(USERPROFILE / CLAUDE_CONFIG_DIR)。"); - return 1; + use nebula_settings::{AgentHook, RawSettings}; + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + let _lock = loop { + match settings::lock() { + Ok(lock) => break lock, + Err(_) if std::time::Instant::now() < deadline => { + std::thread::sleep(std::time::Duration::from_millis(100)); + }, + Err(error) => { + eprintln!("无法取得 Hook 配置操作锁:{error}"); + return 1; + }, + } }; - let path = dir.join("settings.json"); + if !remove && helper_path().is_none() { + eprintln!("runtime/ 和 pebrel.exe 同目录中均未找到 pebrel-hook.exe,无法安装。"); + return 1; + } + let updates = if remove { AgentHook::all_updates(false) } else { AgentHook::setup_updates() }; + let mut failed = false; + // 持久选择先于文件操作;其他实例拿到锁后只会看到最新授权。 + if let Err(error) = nebula_settings::persist_keys(&updates) { + eprintln!("无法保存 Hook 开关:{error}"); + if !remove { + return 1; + } + failed = true; + } + let raw = RawSettings::load(); + for agent in AgentHook::ALL { + if !remove && !agent.enabled(&raw) { + continue; + } + let name = agent.settings_key().trim_start_matches("ai_hooks_"); + if !remove && !settings::configuration(agent).is_some_and(|(_, present)| present) { + println!("{name}: 未检测到配置目录,首次运行该 Agent 后可在设置中开启。"); + continue; + } + match settings::apply_and_verify(agent, !remove) { + Ok(()) if remove => println!("{name}: Pebrel Hook 已清理,用户的其他配置已保留。"), + Ok(()) => println!("{name}: Hook 配置已写入并校验。"), + Err(error) => { + eprintln!("{name}: Hook 配置操作失败:{error}"); + failed = true; + }, + } + } if remove { - let mut failed = false; - match remove_hooks() { - Ok(true) => println!("claude: 已从 {} 移除 hooks。", path.display()), - Ok(false) => println!("claude: {} 中没有 Pebrel 的 hooks。", path.display()), - Err(err) => { - eprintln!("claude: 移除失败:{err}"); - failed = true; - }, - } - if let Err(error) = remove_codex_hooks() { - eprintln!("codex: 原生 hook 移除失败,已保留配置:{error}"); - failed = true; - } - match remove_codex_notify() { - Ok(true) => println!("codex: 已还原 config.toml 的 notify。"), - Ok(false) => println!("codex: notify 不是 Pebrel 接管的,未改动。"), - Err(err) => { - eprintln!("codex: 还原失败:{err}"); - failed = true; - }, - } - match kimi::remove_kimi_hooks() { - Ok(true) => println!("kimi: 已从 config.toml 移除 hooks。"), - Ok(false) => println!("kimi: config.toml 中没有 Pebrel 的 hooks。"), - Err(err) => { - eprintln!("kimi: 移除失败:{err}"); - failed = true; - }, - } - match remove_opencode_plugin() { - Ok(true) => println!("opencode: 已删除 Pebrel 管理的插件。"), - Ok(false) => println!("opencode: 没有 Pebrel 的插件,未改动。"), - Err(err) => { - eprintln!("opencode: 删除失败:{err}"); - failed = true; - }, - } - match remove_pi_extension() { - Ok(true) => println!("pi: 已删除 Pebrel 管理的扩展。"), - Ok(false) => println!("pi: 没有 Pebrel 的扩展,未改动。"), - Err(err) => { - eprintln!("pi: 删除失败:{err}"); - failed = true; - }, - } for (agent, path) in runtime_skill_candidates() { match remove_runtime_skill(&path) { Ok(ManagedSkillRemoval::Removed) => { println!("{agent}: 已移除 Pebrel Runtime Skill({})。", path.display()) }, - Ok(ManagedSkillRemoval::Absent) => { - println!("{agent}: 没有 Pebrel 管理的 Runtime Skill,未改动。") - }, + Ok(ManagedSkillRemoval::Absent) => {}, Ok(ManagedSkillRemoval::Conflict) => { - eprintln!( - "{agent}: {} 已被用户修改,保留该 Skill;如需删除请手动确认内容。", - path.display() - ); + eprintln!("{agent}: {} 已被用户修改,保留该 Skill。", path.display()); failed = true; }, Err(error) => { @@ -289,133 +309,31 @@ pub fn setup_ai_cli(remove: bool) -> i32 { }, } } - // 持久开关:不写它,下次 Nebula 启动(含开机自启)会把上面 - // 刚清掉的四处原样装回——移除必须比自愈活得久(#8、#38)。 - match nebula_settings::persist_keys(&[("ai_hooks", "0".to_owned())]) { - Ok(()) => println!( - "已写入 ai_hooks=0:Pebrel 启动时不再自动接线(重新启用:pebrel setup-ai)。" - ), - Err(err) => { - eprintln!("警告:无法写入 ai_hooks=0({err}),下次启动仍会自动装回。"); - failed = true; - }, - } - // 卸载器必须尽最大努力清理所有集成,不能因一个损坏的用户配置 - // 提前返回而让其他 Hook 永久指向即将被删除的程序目录。 - return i32::from(failed); - } - match helper_command() { - Some(command) => { - println!("hook 命令:{command} {}(exec 形式,不经 shell 解析)", HELPER_ARGS[0]) - }, - None => { - eprintln!("runtime/ 和 pebrel.exe 同目录中均未找到 pebrel-hook.exe,无法安装。"); - return 1; - }, - } - let mut setup_failed = false; - // 显式安装即重新授权:清掉 --remove 落下的持久开关,守护线程下次 - // 启动恢复自愈。 - if let Err(err) = nebula_settings::persist_keys(&[("ai_hooks", "1".to_owned())]) { - eprintln!( - "警告:无法写入 ai_hooks=1({err});若之前执行过 --remove,自动接线仍是关闭状态。" - ); - } - if dir.exists() { - if ensure_claude_hooks() { - println!("claude: 已写入 {}(首次改动备份 *.pebrel-bak)。", path.display()); - } else { - println!("claude: {} 已是最新。", path.display()); - } } else { - println!("claude: 未检测到({} 不存在),跳过。", dir.display()); - } - match codex_config_dir().map(|d| d.join("config.toml")) { - Some(cfg) if cfg.exists() => { - if ensure_codex_hooks() { - println!( - "codex: 已写入原生生命周期 hook;请在 Codex /hooks 中审阅后启用。旧 notify 保留兼容。" - ); + for (agent, path, result) in ensure_runtime_skills() { + match result { + Ok(ManagedSkillInstall::Installed) => { + println!("{agent}: 已安装 Pebrel Runtime Skill 到 {}。", path.display()) + }, + Ok(ManagedSkillInstall::Current) => {}, + Ok(ManagedSkillInstall::Conflict) => { + eprintln!( + "{agent}: {} 存在非 Pebrel 管理或被编辑的 Skill,未覆盖。", + path.display() + ); + failed = true; + }, + Err(error) => { + eprintln!("{agent}: 安装 Runtime Skill 失败:{error}"); + failed = true; + }, } - if ensure_codex_notify() { - println!("codex: 已接管 notify(原 notifier 经 --chain 保留)。"); - } else { - println!("codex: {} 已是最新。", cfg.display()); - } - }, - _ => println!("codex: 未检测到 config.toml,跳过。"), - } - match kimi::kimi_config_dir() { - Some(cfg_dir) if cfg_dir.exists() => { - let cfg = cfg_dir.join("config.toml"); - if kimi::ensure_kimi_hooks() { - println!("kimi: 已写入 {}(首次改动备份 *.pebrel-bak)。", cfg.display()); - } else { - println!("kimi: {} 已是最新。", cfg.display()); - } - }, - _ => println!("kimi: 未检测到(~/.kimi-code 不存在),跳过。"), - } - match opencode_config_dir() { - Some(cfg) if cfg.exists() => { - let dir = cfg.join("plugins"); - setup_failed |= report_cli_bridge_install("opencode", &dir, Bridge::Opencode); - }, - _ => println!("opencode: 未检测到(~/.config/opencode 不存在),跳过。"), - } - match pi_agent_dir() { - Some(agent) if agent.exists() => { - let dir = agent.join("extensions"); - setup_failed |= report_cli_bridge_install("pi", &dir, Bridge::Pi); - }, - _ => println!("pi: 未检测到(~/.pi/agent 不存在),跳过。"), - } - for (agent, path, result) in ensure_runtime_skills() { - match result { - Ok(ManagedSkillInstall::Installed) => { - println!("{agent}: 已安装 Pebrel Runtime Skill 到 {}。", path.display()) - }, - Ok(ManagedSkillInstall::Current) => { - println!("{agent}: Pebrel Runtime Skill 已是最新。") - }, - Ok(ManagedSkillInstall::Conflict) => { - eprintln!( - "{agent}: {} 或旧目录存在非 Pebrel 管理或被编辑的 Skill,未覆盖。", - path.display() - ); - setup_failed = true; - }, - Err(error) => { - eprintln!("{agent}: 安装 Runtime Skill 失败:{error}"); - setup_failed = true; - }, } + println!("默认接入 Claude Code 与 Codex;其他 Agent 由设置菜单控制。"); + println!("Codex 原生 Hook 可能需要在 /hooks 中审阅启用;notify 兼容路径仍保留。"); + println!("对新启动的会话生效;正在运行的会话保持原快照。"); } - println!("对新启动的会话生效;正在运行的会话保持原快照。"); - i32::from(setup_failed) -} - -fn report_cli_bridge_install(agent: &str, directory: &Path, bridge: Bridge) -> bool { - let path = directory.join(bridge.files().0); - match install_bridge(directory, bridge) { - Ok(managed_files::Install::Installed) => { - println!("{agent}: 已安装 {}。", path.display()); - announce(); - false - }, - Ok(managed_files::Install::Current) => { - println!("{agent}: {} 已是最新。", path.display()); - false - }, - Ok(managed_files::Install::Conflict) => { - eprintln!("{agent}: {} 或旧文件已被编辑或属于用户,未覆盖。", path.display()); - true - }, - Err(error) => { - eprintln!("{agent}: 安装 {} 失败:{error}", path.display()); - true - }, - } + i32::from(failed) } fn claude_config_dir() -> Option { @@ -627,6 +545,30 @@ mod generated_hook_tests { const HELPER: &str = "C:/Program Files/Pebrel/runtime/pebrel-hook.exe"; + #[test] + fn claude_install_and_remove_preserve_commands_that_only_mention_the_helper() { + let foreign = json!([ + {"type":"command", "command":"echo pebrel-hook.exe"}, + {"type":"command", "command":"echo C:/pebrel-hook.exe"}, + {"type":"command", "command":"echo C:/pebrel-hook.exe claude"}, + {"type":"command", "command":"C:/pebrel-hook.exe.backup", "args":["claude"]}, + {"type":"command", "command":HELPER, "args":["custom"]}, + {"type":"command", "command":"\"C:/pebrel-hook.exe\" claude && echo user"} + ]); + let mut root = json!({"hooks":{"Stop":[{"hooks":foreign}]}, "custom":true}); + for _ in 0..12 { + assert_eq!(install_into(&mut root, HELPER), Some(true)); + assert_eq!(root["hooks"]["Stop"][0]["hooks"], foreign); + let installed = root.clone(); + assert_eq!(install_into(&mut root, HELPER), Some(false)); + assert_eq!(root, installed); + assert!(super::remove_claude_hooks_from(&mut root)); + assert_eq!(root["hooks"]["Stop"], json!([{"hooks":foreign}])); + assert!(!super::remove_claude_hooks_from(&mut root)); + assert_eq!(root["custom"], true); + } + } + #[test] fn claude_install_includes_permission_requests_and_remains_idempotent() { let mut root = json!({}); diff --git a/nebula_app/src/ai_hook/win/codex_hooks.rs b/nebula_app/src/ai_hook/win/codex_hooks.rs index f4a70e3..4ae9a11 100644 --- a/nebula_app/src/ai_hook/win/codex_hooks.rs +++ b/nebula_app/src/ai_hook/win/codex_hooks.rs @@ -110,6 +110,69 @@ pub(super) fn ensure_codex_hooks() -> bool { } } +fn groups(helper: &str, mode: CodexHookMode) -> Value { + // Codex 的 Windows runner 使用 COMSPEC /C;可执行文件路径自身仍需引号。 + let command = format!("\"{}\" codex {}", helper.replace('"', "\\\""), mode.argument()); + installation::codex_groups(&command, Some(&command), mode) +} + +pub(super) fn installed_at(directory: &Path) -> io::Result { + let Some(marker) = read(&directory.join(MARKER))? else { return Ok(false) }; + let marker: Value = serde_json::from_str(&marker)?; + let previous = + marker.get("groups").ok_or_else(|| io::Error::other("invalid hook ownership marker"))?; + let raw = read(&directory.join("hooks.json"))?; + // 先复用卸载的归属校验,编辑过的自有条目必须在菜单中暴露冲突。 + installation::merge_groups(raw.as_deref(), Some(&previous.to_string()), &json!({})) + .map_err(io::Error::other)?; + let current: Value = + raw.map(|raw| serde_json::from_str(&raw)).transpose()?.unwrap_or_else(|| json!({})); + Ok(previous.as_object().is_some_and(|events| { + events.iter().any(|(event, groups)| { + groups.as_array().is_some_and(|groups| { + groups.iter().any(|group| { + current["hooks"][event] + .as_array() + .is_some_and(|entries| entries.contains(group)) + }) + }) + }) + })) +} + +pub(super) fn current_at(directory: &Path, helper: &str) -> io::Result { + current_for_mode(directory, helper, supported_mode()) +} + +fn current_for_mode( + directory: &Path, + helper: &str, + mode: Option, +) -> io::Result { + let Some(mode) = mode else { return Ok(true) }; + let Some(config) = read(&directory.join("config.toml"))? else { return Ok(false) }; + let Some((_, introduced)) = + installation::enable_codex_feature(&config).map_err(io::Error::other)? + else { + // Provider 的显式 opt-out 保持有效,此时 legacy notify 仍可独立工作。 + return Ok(true); + }; + if introduced { + return Ok(false); + } + let marker: Value = read(&directory.join(MARKER))? + .map(|raw| serde_json::from_str(&raw)) + .transpose()? + .unwrap_or_else(|| json!({})); + let desired = groups(helper, mode); + let previous = marker.get("groups").map(Value::to_string); + let raw = read(&directory.join("hooks.json"))?; + let (updated, _) = installation::merge_groups(raw.as_deref(), previous.as_deref(), &desired) + .map_err(io::Error::other)?; + let current: Option = raw.map(|raw| serde_json::from_str(&raw)).transpose()?; + Ok(marker.get("groups") == Some(&desired) && current == Some(serde_json::from_str(&updated)?)) +} + fn install(directory: &Path, helper: &str, mode: CodexHookMode) -> io::Result { let config_path = directory.join("config.toml"); let Some(_config_lock) = crate::atomic_file::try_lock(&config_path)? else { return Ok(false) }; @@ -127,11 +190,7 @@ fn install(directory: &Path, helper: &str, mode: CodexHookMode) -> io::Result std::io::Result { .and_then(|v| v.as_array()) .map(|a| a.iter().filter_map(|i| i.as_str().map(str::to_owned)).collect()) .unwrap_or_default(); - if !current.first().is_some_and(|f| contains_helper(f)) { - return Ok(false); // not ours - } - match current.iter().position(|a| a == "--chain") { - // Restore the original argv that lived behind --chain. - Some(chain) => { - let mut array = toml_edit::Array::new(); - for arg in ¤t[chain + 1..] { - array.push(arg.as_str()); - } - doc["notify"] = toml_edit::value(array); - }, - // We created the key; remove it outright. - None => { - doc.as_table_mut().remove("notify"); - }, + let Some(restored) = stripped_codex_notify(¤t) else { return Ok(false) }; + if restored.is_empty() { + doc.as_table_mut().remove("notify"); + } else { + let mut array = toml_edit::Array::new(); + for arg in &restored { + array.push(arg.as_str()); + } + doc["notify"] = toml_edit::value(array); } write_atomic(&path, &doc.to_string())?; Ok(true) @@ -116,7 +109,7 @@ pub(super) fn remove_codex_notify() -> std::io::Result { #[cfg(test)] mod codex_notify_tests { - use super::desired_codex_notify; + use super::{desired_codex_notify, stripped_codex_notify}; const HELPER: &str = "C:/Program Files/Pebrel/runtime/pebrel-hook.exe"; @@ -159,6 +152,33 @@ mod codex_notify_tests { assert_eq!(desired_codex_notify(¤t, HELPER), None); } + #[test] + fn removal_restores_direct_and_nested_notifiers_without_claiming_similar_names() { + let original = argv(&["C:/user/notifier.exe", "turn-ended", "路径 with spaces"]); + let installed = desired_codex_notify(&original, HELPER).unwrap(); + assert_eq!(stripped_codex_notify(&installed), Some(original.clone())); + assert_eq!(stripped_codex_notify(&argv(&[HELPER, "codex"])), Some(vec![])); + let nested = argv(&[ + "C:/wrapper/notifier.exe", + "--previous-notify", + &serde_json::to_string(&installed).unwrap(), + "keep", + ]); + let removed = stripped_codex_notify(&nested).unwrap(); + assert_eq!(removed[0..2], nested[0..2]); + assert_eq!(removed[3], "keep"); + assert_eq!(serde_json::from_str::>(&removed[2]).unwrap(), original); + assert!(stripped_codex_notify(&removed).is_none()); + for foreign in [ + argv(&["C:/pebrel-hook-logger.exe", "codex"]), + argv(&["echo", "pebrel-hook.exe"]), + argv(&[HELPER, "custom"]), + argv(&["foreign.exe", "--notify", "encoded:nebula-hook.exe:payload"]), + ] { + assert!(stripped_codex_notify(&foreign).is_none()); + } + } + // #38 的核心形态:codex-computer-use 重新注册时把我们的 chain JSON // 编码进 --previous-notify。我们不在最外层,但已在链中——再包一层 // 就进入互相包装、反斜杠每轮翻倍的指数爆炸。 diff --git a/nebula_app/src/ai_hook/win/config_guard.rs b/nebula_app/src/ai_hook/win/config_guard.rs index 2b6c10b..2ba57b2 100644 --- a/nebula_app/src/ai_hook/win/config_guard.rs +++ b/nebula_app/src/ai_hook/win/config_guard.rs @@ -1,46 +1,30 @@ //! Install/repair scheduling. Provider file edits remain in their installers. -use super::{ - ManagedSkillInstall, claude_config_dir, codex_config_dir, ensure_claude_hooks, - ensure_codex_hooks, ensure_codex_notify, ensure_opencode_plugin, ensure_pi_extension, - ensure_runtime_skills, kimi, opencode_config_dir, pi_agent_dir, -}; +use super::{ManagedSkillInstall, ensure_runtime_skills, settings}; +use nebula_settings::{AgentHook, RawSettings}; +use std::collections::HashSet; use std::time::Duration; -// ─── settings self-heal ───────────────────────────────────────────────── - -/// Boot entrypoint: install now, then keep installed (see module docs). pub fn spawn_config_guard() { - // `setup-ai --remove` 落下的持久开关:用户明确断开过就不再自动 - // 装回(#38 的自愈复发面 / #8 卸载后仍在 hook)。重新启用走 - // `nebula setup-ai`。 - if hooks_disabled() { - log::info!("ai_hook: ai_hooks=0 (setup-ai --remove); auto-install disabled"); - return; - } if let Err(err) = std::thread::Builder::new().name("pebrel-ai-setup".into()).spawn(config_guard) { log::warn!("ai_hook: failed to spawn settings guard: {err}"); } } -/// `nebula_settings.txt` 里 `ai_hooks=0`(由 `setup-ai --remove` 写入)。 -fn hooks_disabled() -> bool { - nebula_settings::RawSettings::load().bool_on("ai_hooks") == Some(false) -} - -/// 一轮完整自愈。每轮都重读开关:`setup-ai --remove` 可能发生在本进程 -/// 存活期间,它触发的 config 变更事件会立刻打回这里——不重读就会在 -/// 400ms 内把刚移除的接线原样装回(#38 实测的自愈复发路径)。 fn heal_all() { - if hooks_disabled() { + let Ok(_lock) = settings::lock() else { return }; + // 锁内重读授权,不能把菜单/卸载刚移除的 Hook 按旧快照装回。 + let raw = match RawSettings::try_load() { + Ok(raw) => raw, + Err(error) => { + log::warn!("ai_hook: cannot read hook preferences; installation skipped: {error}"); + return; + }, + }; + settings::heal_enabled(&raw); + if raw.bool_on("ai_hooks") == Some(false) { return; } - ensure_claude_hooks(); - ensure_codex_notify(); - ensure_codex_hooks(); - kimi::ensure_kimi_hooks(); - ensure_opencode_plugin(); - ensure_pi_extension(); for (agent, path, result) in ensure_runtime_skills() { match result { Ok(ManagedSkillInstall::Installed) => { @@ -61,74 +45,65 @@ fn heal_all() { fn config_guard() { use notify::{RecursiveMode, Watcher}; - - // Neither CLI installed (yet): re-check occasionally instead of - // watching directories that do not exist. - let (claude_dir, codex_dir, kimi_dir) = loop { - let claude = claude_config_dir().filter(|d| d.exists()); - let codex = codex_config_dir().filter(|d| d.exists()); - let kimi = kimi::kimi_config_dir().filter(|d| d.exists()); - if claude.is_some() - || codex.is_some() - || kimi.is_some() - || opencode_config_dir().is_some_and(|d| d.exists()) - || pi_agent_dir().is_some_and(|d| d.exists()) - { - break (claude, codex, kimi); - } - std::thread::sleep(Duration::from_secs(300)); - }; + use std::sync::mpsc::{RecvTimeoutError, sync_channel}; heal_all(); - - let (tx, rx) = std::sync::mpsc::channel(); - let mut watcher = match notify::recommended_watcher(move |res| { - let _ = tx.send(res); - }) { - Ok(watcher) => watcher, - Err(err) => { - log::warn!("ai_hook: settings watcher unavailable ({err}); polling instead"); - poll_guard() - }, - }; - for dir in [&claude_dir, &codex_dir, &kimi_dir].into_iter().flatten() { - if let Err(err) = watcher.watch(dir, RecursiveMode::NonRecursive) { - log::warn!("ai_hook: cannot watch {}: {err}; polling instead", dir.display()); - poll_guard(); - } - } - - loop { - match rx.recv() { - Ok(event) => { - // Only the two config files matter — ~/.codex especially - // is a busy directory (sessions, sqlite WALs) that would - // otherwise trigger constant re-checks. - let relevant = match &event { - Ok(ev) => { - ev.paths.is_empty() - || ev.paths.iter().any(|p| { - p.file_name().is_some_and(|n| { - n == "settings.json" || n == "config.toml" || n == "hooks.json" - }) - }) - }, - Err(_) => true, - }; - if !relevant { - continue; - } - // Debounce the writer's burst, then heal. Our own atomic - // rename lands here once and heals to a no-op. - while rx.recv_timeout(Duration::from_millis(400)).is_ok() {} - heal_all(); + // 配置事件只代表需要重读;合并成一个信号,避免繁忙目录堆积整批事件。 + let (tx, rx) = sync_channel(1); + let mut watcher = + match notify::recommended_watcher(move |result: notify::Result| { + let relevant = result.as_ref().map_or(true, |event| { + event.paths.is_empty() + || event.paths.iter().any(|path| { + path.file_name().is_some_and(|name| { + name == "settings.json" + || name == "config.toml" + || name == "hooks.json" + || name == "pebrel.js" + || name == "pebrel.ts" + || name == "pebrel.json" + || name == "pebrel_settings.txt" + || name == "nebula_settings.txt" + }) + }) + }); + if relevant { + let _ = tx.try_send(()); + } + }) { + Ok(watcher) => watcher, + Err(error) => { + log::warn!("ai_hook: settings watcher unavailable ({error}); polling instead"); + poll_guard() }, - Err(_) => return, // channel closed: shutting down + }; + let mut watched = HashSet::new(); + loop { + let dirs = std::iter::once(nebula_settings::settings_dir()).chain( + AgentHook::ALL.into_iter().filter_map(|agent| { + settings::configuration(agent)?.0.parent().map(std::path::Path::to_path_buf) + }), + ); + for dir in dirs.filter(|dir| dir.is_dir()) { + if !watched.contains(&dir) { + match watcher.watch(&dir, RecursiveMode::NonRecursive) { + Ok(()) => { + watched.insert(dir); + }, + Err(error) => log::debug!("ai_hook: cannot watch {}: {error}", dir.display()), + } + } } + match rx.recv_timeout(Duration::from_secs(300)) { + Ok(()) => while rx.recv_timeout(Duration::from_millis(400)).is_ok() {}, + Err(RecvTimeoutError::Timeout) => {}, + Err(RecvTimeoutError::Disconnected) => return, + } + // 定时一轮兼顾启动后新安装的 Agent,以及未能建立 watcher 的目录。 + heal_all(); } } -/// Degraded guard when file watching is unavailable: heal every 5 min. fn poll_guard() -> ! { loop { std::thread::sleep(Duration::from_secs(300)); diff --git a/nebula_app/src/ai_hook/win/cursor.rs b/nebula_app/src/ai_hook/win/cursor.rs new file mode 100644 index 0000000..b020682 --- /dev/null +++ b/nebula_app/src/ai_hook/win/cursor.rs @@ -0,0 +1,193 @@ +//! Cursor 的共享 JSON 配置;只认领完整的自有命令,保留用户 Hook。 + +use std::io; +use std::path::{Path, PathBuf}; + +use base64::Engine as _; +use serde_json::{Value, json}; + +const EVENTS: &[(&str, &str)] = &[ + ("sessionStart", "session-start"), + ("sessionEnd", "session-end"), + ("beforeSubmitPrompt", "prompt"), + ("stop", "done"), + ("afterShellExecution", "tool-complete"), +]; + +pub(super) fn path() -> Option { + Some(crate::platform::dirs::home_dir()?.join(".cursor/hooks.json")) +} + +fn command(helper: &str, event: &str) -> String { + super::extended::encoded_powershell(&format!( + "& '{}' cursor --event {event}", + helper.replace('\'', "''"), + )) +} + +fn owned(command: &str) -> bool { + let Some(encoded) = + command.strip_prefix("powershell.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand ") + else { + return false; + }; + let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(encoded) else { return false }; + if bytes.len() % 2 != 0 { + return false; + } + let words: Vec<_> = + bytes.chunks_exact(2).map(|pair| u16::from_le_bytes([pair[0], pair[1]])).collect(); + let Ok(script) = String::from_utf16(&words) else { return false }; + let Some((escaped, tail)) = script.strip_prefix("& '").and_then(|s| s.rsplit_once("' ")) else { + return false; + }; + let helper = escaped.replace("''", "'"); + if helper.replace('\'', "''") != escaped { + return false; + } + let filename = helper.rsplit(['/', '\\']).next().unwrap_or(""); + ["pebrel-hook.exe", "nebula-hook.exe"].iter().any(|name| filename.eq_ignore_ascii_case(name)) + && EVENTS.iter().any(|(_, event)| tail == format!("cursor --event {event}")) +} + +fn invalid(message: &str) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message) +} + +fn edit(raw: &str, helper: &str, enabled: bool) -> io::Result<(String, bool, bool)> { + let mut doc: Value = serde_json::from_str(raw).map_err(io::Error::other)?; + let root = doc.as_object_mut().ok_or_else(|| invalid("Expected a JSON object"))?; + if root.get("version").is_some_and(|version| version != 1) { + return Err(invalid("Unsupported hooks configuration version; file preserved")); + } + let mut changed = false; + let mut installed = false; + if !root.contains_key("hooks") && enabled { + root.insert("hooks".into(), json!({})); + changed = true; + } + if let Some(hooks) = root.get_mut("hooks") { + let hooks = hooks.as_object_mut().ok_or_else(|| invalid("Expected a hooks object"))?; + for (native, event) in EVENTS { + if !hooks.contains_key(*native) && !enabled { + continue; + } + let entries = hooks + .entry(*native) + .or_insert(json!([])) + .as_array_mut() + .ok_or_else(|| invalid("Expected a hook command array; file preserved"))?; + let is_owned = + |entry: &Value| entry.get("command").and_then(Value::as_str).is_some_and(owned); + let ours: Vec<_> = entries.iter().filter(|entry| is_owned(entry)).collect(); + installed |= !ours.is_empty(); + let desired = json!({"command": command(helper, event), "timeout": 10}); + if (enabled && ours == vec![&desired]) || (!enabled && ours.is_empty()) { + continue; + } + entries.retain(|entry| !is_owned(entry)); + if enabled { + entries.push(desired); + } + changed = true; + } + } + if enabled && root.get("version").is_none() { + root.insert("version".into(), json!(1)); + changed = true; + } + let text = if changed { + serde_json::to_string_pretty(&doc).map_err(io::Error::other)? + "\n" + } else { + raw.to_owned() + }; + Ok((text, installed, changed)) +} + +pub(super) fn installed_at(path: &Path) -> io::Result { + match std::fs::read_to_string(path) { + Ok(raw) => Ok(edit(&raw, "", false)?.1), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error), + } +} + +pub(super) fn current(raw: &str, helper: &str) -> io::Result { + Ok(!edit(raw, helper, true)?.2) +} + +pub(super) fn apply_at(path: &Path, helper: &str, enabled: bool) -> io::Result<()> { + if !enabled && !path.exists() { + return Ok(()); + } + let _lock = crate::atomic_file::try_lock(path)? + .ok_or_else(|| io::Error::other("Cursor hook configuration is busy"))?; + let raw = match std::fs::read_to_string(path) { + Ok(raw) => raw, + Err(error) if error.kind() == io::ErrorKind::NotFound => "{}".into(), + Err(error) => return Err(error), + }; + let (updated, _, changed) = edit(&raw, helper, enabled)?; + if changed { + crate::atomic_file::write(path, updated.as_bytes())?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + const HELPER: &str = "C:/Program Files/用户's Tools/pebrel-hook.exe"; + + #[test] + fn preserves_foreign_hooks_and_migrates_old_helpers_without_duplicates() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("hooks.json"); + let original = + r#"{"version":1,"custom":true,"hooks":{"stop":[{"command":"foreign-hook"}]}}"#; + std::fs::write(&path, original).unwrap(); + apply_at(&path, "C:/old/nebula-hook.exe", true).unwrap(); + assert!(installed_at(&path).unwrap()); + assert!(!current(&std::fs::read_to_string(&path).unwrap(), HELPER).unwrap()); + apply_at(&path, HELPER, true).unwrap(); + let installed = std::fs::read_to_string(&path).unwrap(); + assert!(current(&installed, HELPER).unwrap()); + assert_eq!(installed.matches("EncodedCommand").count(), EVENTS.len()); + apply_at(&path, HELPER, true).unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), installed); + apply_at(&path, "", false).unwrap(); + assert!(!installed_at(&path).unwrap()); + let removed = std::fs::read_to_string(&path).unwrap(); + assert!(removed.contains("foreign-hook") && removed.contains("custom")); + apply_at(&path, "", false).unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), removed); + } + + #[test] + fn malformed_future_and_busy_configuration_is_not_overwritten() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("hooks.json"); + for raw in ["broken", r#"{"version":2,"hooks":{}}"#, r#"{"hooks":{"stop":42}}"#] { + std::fs::write(&path, raw).unwrap(); + assert!(apply_at(&path, HELPER, true).is_err()); + assert_eq!(std::fs::read_to_string(&path).unwrap(), raw); + } + std::fs::write(&path, "{}").unwrap(); + let _lock = crate::atomic_file::try_lock(&path).unwrap().unwrap(); + assert!(apply_at(&path, HELPER, true).is_err()); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "{}"); + } + + #[test] + fn edited_commands_are_not_claimed_by_filename_substring() { + assert!(owned(&command(HELPER, "done"))); + for script in [ + "echo 'pebrel-hook.exe'", + "& 'C:/pebrel-hook.exe' cursor --event done; echo custom", + "& 'C:/other.exe' cursor --event done", + "& 'C:/pebrel-hook.exe' other --event done", + ] { + assert!(!owned(&super::super::extended::encoded_powershell(script))); + } + } +} diff --git a/nebula_app/src/ai_hook/win/extended.rs b/nebula_app/src/ai_hook/win/extended.rs new file mode 100644 index 0000000..20faadd --- /dev/null +++ b/nebula_app/src/ai_hook/win/extended.rs @@ -0,0 +1,161 @@ +//! Native hook adapters for additional agents. +//! Shared preferences and managed-file ownership govern installation and removal. + +use std::io; +use std::path::PathBuf; + +use nebula_settings::AgentHook; +use serde_json::{Map, json}; + +use super::managed_files; + +pub(super) fn directory(agent: AgentHook) -> Option { + if agent == AgentHook::Copilot { + if let Some(dir) = std::env::var_os("COPILOT_HOME").filter(|dir| !dir.is_empty()) { + return Some(PathBuf::from(dir)); + } + } + let home = crate::platform::dirs::home_dir()?; + Some(match agent { + AgentHook::Copilot => home.join(".copilot"), + AgentHook::Grok => home.join(".grok"), + AgentHook::OhMyPi => home.join(".omp/agent"), + _ => return None, + }) +} + +pub(super) fn path(agent: AgentHook) -> Option { + let suffix = + if agent == AgentHook::OhMyPi { "extensions/pebrel.ts" } else { "hooks/pebrel.json" }; + Some(directory(agent)?.join(suffix)) +} + +pub(super) fn content(agent: AgentHook, helper: &str) -> io::Result { + if agent == AgentHook::OhMyPi { + return Ok(super::PI_EXTENSION_TS + .replace("Pebrel ↔ Pi", "Pebrel ↔ Oh My Pi") + .replace("@earendil-works/pi-coding-agent", "@oh-my-pi/pi-coding-agent") + .replace("const SOURCE: string = \"pi\";", "const SOURCE: string = \"omp\";")); + } + let source = match agent { + AgentHook::Copilot => "copilot", + AgentHook::Grok => "grok", + _ => return Err(io::Error::other("Unsupported managed hook provider")), + }; + let mut hooks = Map::new(); + let events = if agent == AgentHook::Copilot { + vec![ + ("sessionStart", "session-start"), + ("userPromptSubmitted", "prompt"), + ("agentStop", "done"), + ("postToolUse", "tool-complete"), + ("postToolUseFailure", "tool-complete"), + ("errorOccurred", "error"), + ("sessionEnd", "session-end"), + ("notification", "notification"), + ] + } else { + vec![ + ("SessionStart", "session-start"), + ("UserPromptSubmit", "prompt"), + ("PostToolUse", "tool-complete"), + ("PostToolUseFailure", "tool-complete"), + ("Stop", "done"), + ("StopFailure", "failed"), + ("SessionEnd", "session-end"), + ("Notification", "notification"), + ] + }; + for (native, event) in events { + // Copilot has explicit shell fields. Grok executes command through a + // shell; an encoded PowerShell command avoids path quoting differences + // between cmd, PowerShell and Git Bash on Windows. + let powershell = format!("& '{}' {source} --event {event}", helper.replace('\'', "''")); + let command = encoded_powershell(&powershell); + let value = if agent == AgentHook::Copilot { + json!([{ "type": "command", "bash": command, "powershell": powershell, "timeoutSec": 10 }]) + } else { + // Grok 文档的 matcher 匹配工具名;通知类别在载荷解析端筛选。 + json!([{ "hooks": [{ "type": "command", "command": command, "timeout": 10 }] }]) + }; + hooks.insert(native.to_owned(), value); + } + let mut root = json!({ "hooks": hooks }); + if agent == AgentHook::Copilot { + root["version"] = json!(1); + } + serde_json::to_string_pretty(&root).map_err(io::Error::other) +} + +pub(super) fn encoded_powershell(script: &str) -> String { + use base64::Engine as _; + let bytes: Vec = script.encode_utf16().flat_map(u16::to_le_bytes).collect(); + format!( + "powershell.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand {}", + base64::engine::general_purpose::STANDARD.encode(bytes) + ) +} + +pub(super) fn installed(agent: AgentHook, helper: &str) -> io::Result { + let Some(path) = path(agent) else { return Ok(false) }; + let legacy = + path.with_file_name(if agent == AgentHook::OhMyPi { "nebula.ts" } else { "nebula.json" }); + managed_files::installed(&path, &legacy, &content(agent, helper)?, &[]) +} + +pub(super) fn apply(agent: AgentHook, helper: &str, enabled: bool) -> io::Result<()> { + let path = + path(agent).ok_or_else(|| io::Error::other("Agent configuration directory unavailable"))?; + let legacy = + path.with_file_name(if agent == AgentHook::OhMyPi { "nebula.ts" } else { "nebula.json" }); + let content = content(agent, helper)?; + if !enabled && !path.exists() && !legacy.exists() { + return Ok(()); + } + let _lock = crate::atomic_file::try_lock(&path)? + .ok_or_else(|| io::Error::other("Hook configuration is busy"))?; + if enabled { + if managed_files::install(&path, &legacy, &content, &[])? + == managed_files::Install::Conflict + { + return Err(io::Error::other(format!( + "Preserving edited or unmanaged integration: {}", + path.display() + ))); + } + } else { + managed_files::remove(&path, &legacy, &content, &[])?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::Value; + + #[test] + fn native_event_names_and_shell_paths_are_preserved() { + let helper = "C:/Program Files/用户's Tools/pebrel-hook.exe"; + let copilot: Value = + serde_json::from_str(&content(AgentHook::Copilot, helper).unwrap()).unwrap(); + assert_eq!(copilot["version"], 1); + let command = &copilot["hooks"]["agentStop"][0]; + assert!(command["powershell"].as_str().unwrap().contains("用户''s Tools")); + assert!(command["powershell"].as_str().unwrap().ends_with("copilot --event done")); + let grok: Value = serde_json::from_str(&content(AgentHook::Grok, helper).unwrap()).unwrap(); + assert!(grok["hooks"]["Notification"][0].get("matcher").is_none()); + assert!(grok["hooks"]["StopFailure"].is_array()); + assert!( + grok["hooks"]["Stop"][0]["hooks"][0]["command"] + .as_str() + .unwrap() + .starts_with("powershell.exe ") + ); + let omp = content(AgentHook::OhMyPi, helper).unwrap(); + assert!(omp.contains("@oh-my-pi/pi-coding-agent")); + assert!(omp.contains("const SOURCE: string = \"omp\";")); + assert!(!omp.contains("const SOURCE: string = \"pi\";")); + assert!(omp.contains("if (SOURCE !== \"pi\") return false;")); + } +} diff --git a/nebula_app/src/ai_hook/win/kimi.rs b/nebula_app/src/ai_hook/win/kimi.rs index 786141f..25a8f43 100644 --- a/nebula_app/src/ai_hook/win/kimi.rs +++ b/nebula_app/src/ai_hook/win/kimi.rs @@ -7,15 +7,15 @@ //! 空格的安装路径会被 shell 切开(#80 的同源教训)。事件 JSON 经 stdin //! 传给 helper;kimi 侧 fail-open,helper 任何路径都 exit 0。 //! -//! 与 claude/codex 同一套纪律:幂等合并、只认 `command` 里含 helper 标记 -//! 的条目(`contains_helper`)、过期路径就地自愈、首次改动留 +//! 与 claude/codex 同一套纪律:幂等合并、只认完整 helper 调用 +//! 的条目、过期路径就地自愈、首次改动留 //! `*.pebrel-bak`、解析失败或形状意外拒写、目录不存在不 scaffold。 use std::path::{Path, PathBuf}; use toml_edit::{ArrayOfTables, DocumentMut, Item, Table, Value}; -use crate::ai_hook::contains_helper; +use crate::ai_hook::is_helper_shell_command; /// 订阅的 kimi 事件,每个一条 `[[hooks]]`。其余事件(SessionHeartbeat、 /// Notification、PreToolUse 等)不订阅。 @@ -47,11 +47,12 @@ fn hook_command(helper: &Path) -> String { format!("\"{}\" kimi", helper.display().to_string().replace('\\', "/")) } -/// 「自己的条目」= 订阅事件 ∩ command 含 helper 标记。用户把 pebrel-hook +/// 「自己的条目」= 订阅事件 ∩ 完整 helper 调用。用户把 pebrel-hook /// 手工挂到未订阅事件上的条目不在移除范围内(与 claude remove_hooks /// 只遍历 CLAUDE_EVENTS 同理)。 fn is_our_entry(event: Option<&str>, command: Option<&str>) -> bool { - event.is_some_and(|event| KIMI_EVENTS.contains(&event)) && command.is_some_and(contains_helper) + event.is_some_and(|event| KIMI_EVENTS.contains(&event)) + && command.is_some_and(|command| is_helper_shell_command(command, "kimi")) } /// 把文档的 `hooks` 键归一化成数组表。`hooks = []`(空内联数组)与空的 @@ -81,7 +82,7 @@ fn install_into_doc(doc: &mut DocumentMut, command: &str) -> Option { let mut found = false; for entry in hooks.iter_mut() { let ours = entry.get("event").and_then(Item::as_str) == Some(event) - && entry.get("command").and_then(Item::as_str).is_some_and(contains_helper); + && is_our_entry(Some(event), entry.get("command").and_then(Item::as_str)); if !ours { continue; } @@ -144,9 +145,25 @@ fn remove_from_doc(doc: &mut DocumentMut) -> bool { } } +/// 设置页只读复用同一 TOML 规则,区分部分安装和完整配置。 +pub(super) fn inspect(raw: &str, helper: &Path) -> std::io::Result<(bool, bool)> { + let mut doc = raw.parse::().map_err(std::io::Error::other)?; + let installed = remove_from_doc(&mut doc.clone()); + let current = install_into_doc(&mut doc, &hook_command(helper)) == Some(false); + Ok((installed, current)) +} + /// 在 `path`(config.toml)上执行安装/自愈。返回是否写了文件。 fn ensure_kimi_hooks_in(path: &Path, helper: &Path) -> bool { - let raw = std::fs::read_to_string(path).unwrap_or_default(); // 文件不存在 → 空文档 + let Ok(Some(_lock)) = crate::atomic_file::try_lock(path) else { return false }; + let raw = match std::fs::read_to_string(path) { + Ok(raw) => raw, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(error) => { + log::warn!("ai_hook: cannot read {} ({error}); left alone", path.display()); + return false; + }, + }; let Ok(mut doc) = raw.parse::() else { log::warn!("ai_hook: {} is not valid TOML; left alone", path.display()); return false; @@ -201,9 +218,15 @@ pub(super) fn ensure_kimi_hooks() -> bool { } fn remove_kimi_hooks_in(path: &Path) -> std::io::Result { + if !path.exists() { + return Ok(false); + } + let _lock = crate::atomic_file::try_lock(path)? + .ok_or_else(|| std::io::Error::other("Kimi hook configuration is busy"))?; let raw = match std::fs::read_to_string(path) { Ok(raw) => raw, - Err(_) => return Ok(false), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(error), }; let mut doc = raw.parse::().map_err(|err| std::io::Error::other(err.to_string()))?; @@ -324,6 +347,14 @@ command = "notify-send done" [[hooks]] event = "PreToolUse" command = "\"D:/tools/pebrel-hook.exe\" kimi" + +[[hooks]] +event = "Stop" +command = "echo pebrel-hook.exe" + +[[hooks]] +event = "Stop" +command = "\"D:/tools/pebrel-hook.exe\" kimi && echo user" "#, ) .unwrap(); @@ -342,6 +373,8 @@ command = "\"D:/tools/pebrel-hook.exe\" kimi" [ ("Notification", "notify-send done"), ("PreToolUse", "\"D:/tools/pebrel-hook.exe\" kimi"), + ("Stop", "echo pebrel-hook.exe"), + ("Stop", "\"D:/tools/pebrel-hook.exe\" kimi && echo user"), ], "别人的条目与手工挂在未订阅事件上的 helper 条目都要留下" ); diff --git a/nebula_app/src/ai_hook/win/managed_files.rs b/nebula_app/src/ai_hook/win/managed_files.rs index 3f5826e..b44d242 100644 --- a/nebula_app/src/ai_hook/win/managed_files.rs +++ b/nebula_app/src/ai_hook/win/managed_files.rs @@ -42,6 +42,28 @@ fn is_owned(path: &Path, content: &[u8], expected: &[u8], legacy_hashes: &[&str] .is_ok_and(|marker| marker.trim() == fingerprint) } +/// 复用安装/卸载的归属判断;设置页不能把同名的用户文件显示成已接入。 +pub(super) fn installed( + path: &Path, + legacy: &Path, + content: &str, + legacy_hashes: &[&str], +) -> io::Result { + let mut installed = false; + for file in [path, legacy] { + if let Some(bytes) = read_if_present(file)? { + if !is_owned(file, &bytes, content.as_bytes(), legacy_hashes) { + return Err(io::Error::other(format!( + "Preserving edited or unmanaged integration: {}", + file.display() + ))); + } + installed = true; + } + } + Ok(installed) +} + /// Rename the old bridge before updating it so the loader never sees two copies. /// Older releases had no ownership marker, so only their exact verified payloads /// may migrate; an edited or unknown file stays untouched. diff --git a/nebula_app/src/ai_hook/win/settings.rs b/nebula_app/src/ai_hook/win/settings.rs new file mode 100644 index 0000000..14a45ca --- /dev/null +++ b/nebula_app/src/ai_hook/win/settings.rs @@ -0,0 +1,325 @@ +//! Hook inspection and serialized settings operations, shared with the guard/CLI. + +use std::io; +use std::path::{Path, PathBuf}; + +use nebula_settings::{AgentHook, RawSettings}; +use serde_json::Value; + +use super::super::installation::{desired_codex_notify, has_codex_notify}; +use super::super::integrations::HookInspection; +use super::*; + +pub(super) fn lock() -> io::Result { + crate::atomic_file::try_lifetime_lock(&nebula_settings::settings_dir().join("agent-hooks"))? + .ok_or_else(|| io::Error::other("Another hook operation is running. Please retry.")) +} + +pub(super) fn configuration(agent: AgentHook) -> Option<(PathBuf, bool)> { + let (path, footprint) = match agent { + AgentHook::Claude => { + let dir = claude_config_dir()?; + (dir.join("settings.json"), dir.is_dir()) + }, + AgentHook::Codex => { + let path = codex_config_dir()?.join("config.toml"); + let footprint = path.is_file(); + (path, footprint) + }, + AgentHook::OpenCode => { + let dir = opencode_config_dir()?; + (dir.join("plugins/pebrel.js"), dir.is_dir()) + }, + AgentHook::Pi => { + let dir = pi_agent_dir()?; + (dir.join("extensions/pebrel.ts"), dir.is_dir()) + }, + AgentHook::Kimi => { + let dir = kimi::kimi_config_dir()?; + (dir.join("config.toml"), dir.is_dir()) + }, + AgentHook::Cursor => { + let path = cursor::path()?; + let footprint = path.parent()?.is_dir(); + (path, footprint) + }, + _ => (extended::path(agent)?, extended::directory(agent)?.is_dir()), + }; + Some((path, footprint)) +} + +pub(crate) fn inspect(agent: AgentHook) -> HookInspection { + let helper = helper_command(); + let mut result = HookInspection { helper_missing: helper.is_none(), ..Default::default() }; + if let Some((path, footprint)) = configuration(agent) { + result.available = footprint; + match installed_at(agent, &path, helper.as_deref().unwrap_or_default()) { + Ok(installed) => { + result.installed = installed; + // Installed hooks must be visible even if the saved intent is off: + // a previous removal may have failed, or another tool wrote them. + result.enabled = installed; + if installed && let Some(helper) = helper.as_deref() { + match current_at(agent, &path, helper) { + Ok(current) => result.needs_repair = !current, + Err(error) => result.error = Some(error.to_string()), + } + } + }, + Err(error) => result.error = Some(error.to_string()), + } + result.config_path = Some(path); + } + result +} + +fn read_optional(path: &Path) -> io::Result> { + match std::fs::read_to_string(path) { + Ok(raw) => Ok(Some(raw)), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error), + } +} + +fn installed_at(agent: AgentHook, path: &Path, helper: &str) -> io::Result { + match agent { + AgentHook::Claude => { + let Some(raw) = read_optional(path)? else { return Ok(false) }; + let value: Value = serde_json::from_str(&raw).map_err(io::Error::other)?; + let root = + value.as_object().ok_or_else(|| io::Error::other("Expected a JSON object"))?; + let Some(hooks) = root.get("hooks") else { return Ok(false) }; + let hooks = hooks + .as_object() + .ok_or_else(|| io::Error::other("Expected hooks to be an object"))?; + Ok(CLAUDE_EVENTS.iter().any(|event| { + hooks.get(*event).and_then(Value::as_array).is_some_and(|groups| { + groups.iter().any(|group| { + group + .get("hooks") + .and_then(Value::as_array) + .is_some_and(|commands| commands.iter().any(is_our_claude_hook)) + }) + }) + })) + }, + AgentHook::Codex => { + let native = path.parent().map(codex_hooks::installed_at).transpose()?.unwrap_or(false); + let Some(raw) = read_optional(path)? else { return Ok(native) }; + let doc = raw.parse::().map_err(io::Error::other)?; + Ok(native + || doc.get("notify").and_then(|v| v.as_array()).is_some_and(|array| { + let current: Vec<_> = array + .iter() + .filter_map(|value| value.as_str().map(str::to_owned)) + .collect(); + has_codex_notify(¤t) + })) + }, + AgentHook::Kimi => { + let Some(raw) = read_optional(path)? else { return Ok(false) }; + Ok(kimi::inspect(&raw, Path::new(helper))?.0) + }, + AgentHook::Cursor => cursor::installed_at(path), + AgentHook::OpenCode | AgentHook::Pi => { + let bridge = if agent == AgentHook::Pi { Bridge::Pi } else { Bridge::Opencode }; + let (name, legacy, content, hashes) = bridge.files(); + let dir = + path.parent().ok_or_else(|| io::Error::other("Missing configuration directory"))?; + managed_files::installed(&dir.join(name), &dir.join(legacy), content, hashes) + }, + _ => extended::installed(agent, helper), + } +} + +/// Installation presence and completeness are different facts. A partial old +/// configuration stays visible as installed, but cannot pass write verification. +fn current_at(agent: AgentHook, path: &Path, helper: &str) -> io::Result { + let Some(raw) = read_optional(path)? else { return Ok(false) }; + match agent { + AgentHook::Cursor => cursor::current(&raw, helper), + AgentHook::Kimi => Ok(kimi::inspect(&raw, Path::new(helper))?.1), + AgentHook::Claude => { + let mut value: Value = serde_json::from_str(&raw).map_err(io::Error::other)?; + Ok(install_into(&mut value, helper) == Some(false)) + }, + AgentHook::Codex => { + let doc = raw.parse::().map_err(io::Error::other)?; + let Some(array) = doc.get("notify").and_then(|value| value.as_array()) else { + return Ok(false); + }; + let Some(current) = array + .iter() + .map(|value| value.as_str().map(str::to_owned)) + .collect::>>() + else { + return Ok(false); + }; + Ok(has_codex_notify(¤t) + && desired_codex_notify(¤t, helper).is_none() + && codex_hooks::current_at( + path.parent() + .ok_or_else(|| io::Error::other("Missing configuration directory"))?, + helper, + )?) + }, + AgentHook::OpenCode | AgentHook::Pi => { + let bridge = if agent == AgentHook::Pi { Bridge::Pi } else { Bridge::Opencode }; + let (_, legacy, content, _) = bridge.files(); + Ok(raw == content && !path.with_file_name(legacy).exists()) + }, + _ => Ok(raw == extended::content(agent, helper)?), + } +} + +pub(crate) fn set_enabled(agent: AgentHook, enabled: bool) -> io::Result<()> { + let _lock = lock()?; + if enabled { + let (_, footprint) = configuration(agent) + .ok_or_else(|| io::Error::other("Agent configuration unavailable"))?; + if !footprint { + return Err(io::Error::other( + "Start this agent once to create its configuration, then refresh.", + )); + } + if helper_path().is_none() { + return Err(io::Error::other("pebrel-hook.exe is missing from this installation.")); + } + } + // Persist first so another instance's guard cannot reinstall a removed hook. + nebula_settings::persist_keys(&[( + agent.settings_key(), + if enabled { "1" } else { "0" }.into(), + )])?; + apply_and_verify(agent, enabled) +} + +pub(super) fn apply_and_verify(agent: AgentHook, enabled: bool) -> io::Result<()> { + apply(agent, enabled)?; + let state = inspect(agent); + if let Some(error) = state.error { + return Err(io::Error::other(error)); + } + if state.installed != enabled || (enabled && state.needs_repair) { + return Err(io::Error::other( + "Hook configuration could not be verified. Check file permissions and configuration, then retry.", + )); + } + Ok(()) +} + +pub(super) fn apply(agent: AgentHook, enabled: bool) -> io::Result<()> { + match (agent, enabled) { + (AgentHook::Claude, true) => { + ensure_claude_hooks(); + }, + (AgentHook::Codex, true) => { + ensure_codex_notify(); + ensure_codex_hooks(); + }, + (AgentHook::OpenCode, true) => { + ensure_opencode_plugin(); + }, + (AgentHook::Pi, true) => { + ensure_pi_extension(); + }, + (AgentHook::Kimi, true) => { + kimi::ensure_kimi_hooks(); + }, + (AgentHook::Claude, false) => { + remove_hooks()?; + }, + (AgentHook::Codex, false) => { + let native = remove_codex_hooks(); + let notify = remove_codex_notify(); + native?; + notify?; + }, + (AgentHook::OpenCode, false) => { + remove_opencode_plugin()?; + }, + (AgentHook::Pi, false) => { + remove_pi_extension()?; + }, + (AgentHook::Kimi, false) => { + kimi::remove_kimi_hooks()?; + }, + (AgentHook::Cursor, _) => { + let path = cursor::path() + .ok_or_else(|| io::Error::other("Cursor configuration unavailable"))?; + cursor::apply_at(&path, &helper_command().unwrap_or_default(), enabled)?; + }, + (_, _) => extended::apply(agent, &helper_command().unwrap_or_default(), enabled)?, + } + Ok(()) +} + +pub(super) fn heal_enabled(raw: &RawSettings) { + for agent in AgentHook::ALL { + if agent.enabled(raw) && configuration(agent).is_some_and(|(_, footprint)| footprint) { + if let Err(error) = apply(agent, true) { + log::warn!("ai_hook: {}: {error}", agent.settings_key()); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_existing_hooks_without_a_saved_preference_or_current_helper() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + std::fs::write( + &path, + r#"{"hooks":{"Stop":[{"hooks":[{"command":"C:/old/nebula-hook.exe claude"}]}]}}"#, + ) + .unwrap(); + assert!(installed_at(AgentHook::Claude, &path, "").unwrap()); + std::fs::write(&path, r#"{"hooks":{"Stop":[{"hooks":[{"command":"user-notifier"}]}]}}"#) + .unwrap(); + assert!(!installed_at(AgentHook::Claude, &path, "").unwrap()); + std::fs::write(&path, "broken JSON").unwrap(); + assert!(installed_at(AgentHook::Claude, &path, "").is_err()); + let path = dir.path().join("config.toml"); + std::fs::write( + &path, + "notify = ['C:/old/pebrel-hook.exe', 'codex', '--chain', 'my-notifier']\n", + ) + .unwrap(); + assert!(installed_at(AgentHook::Codex, &path, "").unwrap()); + std::fs::write(&path, "notify = ['my-notifier']\n").unwrap(); + assert!(!installed_at(AgentHook::Codex, &path, "").unwrap()); + } + + #[test] + fn discovers_current_and_legacy_plugins_and_reports_edits() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("pebrel.ts"); + let legacy = dir.path().join("nebula.ts"); + std::fs::write(&legacy, PI_EXTENSION_TS).unwrap(); + assert!(installed_at(AgentHook::Pi, &path, "").unwrap()); + std::fs::write(&legacy, "user extension").unwrap(); + assert!(installed_at(AgentHook::Pi, &path, "").is_err()); + assert_eq!(std::fs::read_to_string(&legacy).unwrap(), "user extension"); + } + + #[test] + fn partial_claude_install_is_visible_but_never_verified_as_complete() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + let helper = "C:/tools/pebrel-hook.exe"; + let mut value = json!({"hooks":{"Stop":[{"hooks":[{"type":"command","command":helper,"args":HELPER_ARGS}]}]}}); + std::fs::write(&path, value.to_string()).unwrap(); + assert!(installed_at(AgentHook::Claude, &path, helper).unwrap()); + assert!(!current_at(AgentHook::Claude, &path, helper).unwrap()); + install_into(&mut value, helper).unwrap(); + std::fs::write(&path, value.to_string()).unwrap(); + assert!(current_at(AgentHook::Claude, &path, helper).unwrap()); + value["hooks"]["SessionStart"] = json!("invalid shape"); + std::fs::write(&path, value.to_string()).unwrap(); + assert!(!current_at(AgentHook::Claude, &path, helper).unwrap()); + } +} diff --git a/nebula_app/src/cli.rs b/nebula_app/src/cli.rs index 1ebea03..abbe941 100644 --- a/nebula_app/src/cli.rs +++ b/nebula_app/src/cli.rs @@ -913,8 +913,9 @@ impl ConfigLanguage { #[cfg(windows)] #[derive(Args, Debug)] pub struct SetupAiOptions { - /// Remove Pebrel's hooks from claude's settings.json instead of - /// installing them. + /// Remove Pebrel-managed hooks for every supported agent and preserve + /// other integrations. Without this flag, enable Claude Code and Codex + /// plus agents explicitly enabled in settings. #[clap(long)] pub remove: bool, /// Install or remove hooks on an SSH host (alias or user@host). diff --git a/nebula_app/src/display/side_panel/mod.rs b/nebula_app/src/display/side_panel/mod.rs index 6c6918c..9ddc842 100644 --- a/nebula_app/src/display/side_panel/mod.rs +++ b/nebula_app/src/display/side_panel/mod.rs @@ -1408,6 +1408,11 @@ impl SidePanel { if self.search.trim().is_empty() { return false; } + self.browse_directory(path, guest_path) + } + + /// Directory selection and typed paths share the existing window-local root transition. + pub(crate) fn browse_directory(&mut self, path: PathBuf, guest_path: Option) -> bool { if let Some(guest) = guest_path { let Some(distro) = self.file_wsl_root().map(|root| root.distro.clone()) else { return false; diff --git a/nebula_app/src/gpui_shell/assets.rs b/nebula_app/src/gpui_shell/assets.rs index c161ea2..0e96309 100644 --- a/nebula_app/src/gpui_shell/assets.rs +++ b/nebula_app/src/gpui_shell/assets.rs @@ -14,7 +14,7 @@ use std::borrow::Cow; use gpui::{AssetSource, Result, SharedString}; -/// 编译期嵌入。就这么几个、每个不到 1KB,用不着 rust-embed 那一套。 +/// 小型 SVG 编译期嵌入,不引入额外资源加载依赖。 macro_rules! icons { ($($name:literal),* $(,)?) => { &[$(( @@ -37,6 +37,8 @@ const NEBULA_ICONS: &[(&str, &[u8])] = icons![ "vcs-changes", "vcs-history", "vcs-conflict", + "agent-cursor", + "agent-copilot", ]; /// 先查本仓库,未命中再交给组件库。 @@ -64,6 +66,8 @@ impl AssetSource for NebulaAssets { /// 自带图标的路径。走路径而不是 `IconName`:扩展那个枚举等于改 fork。 pub mod nav { + pub const AGENT_CURSOR: &str = "icons/nebula-agent-cursor.svg"; + pub const AGENT_COPILOT: &str = "icons/nebula-agent-copilot.svg"; pub const LAYOUT_GRID: &str = "icons/nebula-layout-grid.svg"; pub const MOUSE_POINTER: &str = "icons/nebula-mouse-pointer.svg"; pub const SLIDERS: &str = "icons/nebula-sliders.svg"; diff --git a/nebula_app/src/gpui_shell/settings_pane.rs b/nebula_app/src/gpui_shell/settings_pane.rs index ac326b6..3f49e78 100644 --- a/nebula_app/src/gpui_shell/settings_pane.rs +++ b/nebula_app/src/gpui_shell/settings_pane.rs @@ -36,6 +36,7 @@ use crate::gpui_shell::prelude::*; use crate::gpui_shell::widgets::NebulaButton; mod about; +mod agents; mod app_icon; mod appearance; mod appearance_advanced; @@ -102,6 +103,7 @@ pub struct SettingsPane { launch_at_login: bool, /// 当前分区(`SECTIONS` 下标);默认落在应用主页。 active_section: usize, + agents: agents::AgentSettingsState, appearance_picker: Option, appearance_picker_seq: u64, pub(super) theme_editor: Option, @@ -1302,6 +1304,7 @@ impl SettingsPane { 6 => self.section_interaction(cx), 7 => self.section_keymap(cx), 8 => self.section_advanced(cx), + 10 => self.section_agents(cx), _ => self.section_backup(cx), } .into_any_element() diff --git a/nebula_app/src/gpui_shell/settings_pane/agents.rs b/nebula_app/src/gpui_shell/settings_pane/agents.rs new file mode 100644 index 0000000..f2fe649 --- /dev/null +++ b/nebula_app/src/gpui_shell/settings_pane/agents.rs @@ -0,0 +1,362 @@ +use super::*; +use crate::ai_agents::AgentKind; +use crate::ai_hook::integrations::{self, AgentIntegration}; +use crate::i18n::Message; +use std::collections::HashMap; + +type AgentLogos = HashMap<(crate::display::AiLogo, bool), Arc>; + +#[cfg(all(test, feature = "gpui-test-support"))] +mod tests; + +#[cfg(all(test, feature = "gpui-test-support"))] +type TestOperation = std::sync::Arc< + dyn Fn( + nebula_settings::AgentHook, + bool, + ) + -> futures::future::BoxFuture<'static, (Result<(), String>, Vec)> + + Send + + Sync, +>; + +pub(super) struct AgentSettingsState { + rows: Option>, + loading: bool, + busy: Option, + sequence: u64, + feedback: Option<(AgentKind, Result)>, + focus: Vec, + logos: AgentLogos, + #[cfg(all(test, feature = "gpui-test-support"))] + test_operation: Option, +} + +impl AgentSettingsState { + pub(super) fn new(cx: &mut Context) -> Self { + Self { + rows: None, + loading: false, + busy: None, + sequence: 0, + feedback: None, + focus: integrations::AGENTS.iter().map(|_| cx.focus_handle().tab_stop(true)).collect(), + logos: HashMap::new(), + #[cfg(all(test, feature = "gpui-test-support"))] + test_operation: None, + } + } +} + +impl SettingsPane { + pub(in crate::gpui_shell) fn set_agent_logos(&mut self, logos: &AgentLogos) { + // 直接共享侧边栏已经缩放好的纹理,打开设置不再解码原图或另建图片缓存。 + self.agents.logos.clone_from(logos); + } + + fn refresh_agents(&mut self, cx: &mut Context) { + if self.agents.loading || self.agents.busy.is_some() { + return; + } + self.agents.loading = true; + self.agents.sequence = self.agents.sequence.wrapping_add(1); + let sequence = self.agents.sequence; + let task = cx.background_executor().spawn(async { integrations::inspect() }); + cx.spawn(async move |this, cx| { + let rows = task.await; + let _ = this.update(cx, |pane, cx| { + if pane.agents.sequence != sequence { + return; + } + pane.agents.rows = Some(rows); + pane.agents.loading = false; + cx.notify(); + }); + }) + .detach(); + cx.notify(); + } + + fn toggle_agent_hook(&mut self, agent: AgentKind, enabled: bool, cx: &mut Context) { + if self.agents.loading || self.agents.busy.is_some() { + return; + } + let Some(row) = + self.agents.rows.as_ref().and_then(|rows| rows.iter().find(|row| row.agent == agent)) + else { + return; + }; + if !can_toggle(row) { + return; + } + let Some(hook) = row.hook else { return }; + self.agents.busy = Some(agent); + self.agents.feedback = None; + self.agents.sequence = self.agents.sequence.wrapping_add(1); + let sequence = self.agents.sequence; + #[cfg(all(test, feature = "gpui-test-support"))] + let test_operation = self.agents.test_operation.clone(); + let task = cx.background_executor().spawn(async move { + #[cfg(all(test, feature = "gpui-test-support"))] + if let Some(operation) = test_operation { + return operation(hook, enabled).await; + } + let result = integrations::set_enabled(hook, enabled); + (result, integrations::inspect()) + }); + // A submitted file operation completes if the pane closes. The weak + // entity/sequence prevents its result from reviving a destroyed view. + cx.spawn(async move |this, cx| { + let (result, rows) = task.await; + let _ = this.update(cx, |pane, cx| { + if pane.agents.sequence != sequence { + return; + } + pane.agents.rows = Some(rows); + pane.agents.busy = None; + pane.agents.feedback = Some((agent, result.map(|()| enabled))); + cx.notify(); + }); + }) + .detach(); + cx.notify(); + } + + pub(super) fn section_agents(&mut self, cx: &mut Context) -> gpui::Div { + if self.agents.rows.is_none() && !self.agents.loading { + self.refresh_agents(cx); + } + let language = crate::gpui_shell::config::ui_language(cx); + let muted = cx.theme().muted_foreground; + let mut list = v_flex() + .w_full() + .rounded_lg() + .border_1() + .border_color(crate::gpui_shell::theme::settings_hairline(cx)); + if let Some(rows) = self.agents.rows.as_ref() { + for (index, row) in rows.iter().enumerate() { + list = list.child(self.agent_hook_row(index, row, cx)); + } + } + v_flex() + .w_full() + .max_w(px(720.0)) + .gap_4() + .child( + h_flex() + .justify_between() + .gap_3() + .child( + div() + .text_size(px(20.0)) + .font_weight(gpui::FontWeight::SEMIBOLD) + .child(language.text(Message::SettingsAgentsTitle)), + ) + .child( + Button::new("agents-refresh") + .ghost() + .small() + .icon(IconName::Redo) + .label(language.text(Message::SettingsAgentsRefresh)) + .disabled(self.agents.loading || self.agents.busy.is_some()) + .on_click(cx.listener(|this, _, _, cx| this.refresh_agents(cx))), + ), + ) + .child( + div() + .text_sm() + .text_color(muted) + .child(language.text(Message::SettingsAgentsDescription)), + ) + .when(!crate::platform::CAPABILITIES.ai_hook_server, |view| { + view.child( + div() + .text_sm() + .text_color(muted) + .child(language.text(Message::SettingsAgentsPlatformUnsupported)), + ) + }) + .when(self.agents.loading, |view| { + view.child( + div() + .text_sm() + .text_color(muted) + .child(language.text(Message::SettingsAgentsChecking)), + ) + }) + .child(list) + .child( + div() + .text_sm() + .text_color(muted) + .child(language.text(Message::SettingsAgentsRestart)), + ) + } + + fn agent_hook_row( + &self, + index: usize, + row: &AgentIntegration, + cx: &mut Context, + ) -> gpui::Stateful { + let language = crate::gpui_shell::config::ui_language(cx); + let agent = row.agent; + let state = &row.inspection; + let busy = self.agents.busy == Some(agent); + let disabled = self.agents.loading || self.agents.busy.is_some() || !can_toggle(row); + let checked = state.enabled; + let focus = self.agents.focus[index].clone(); + let status = agent_status(row, busy); + let note = self + .agents + .feedback + .as_ref() + .filter(|(kind, _)| *kind == agent) + .map(|(_, result)| match result { + Ok(true) => language.text(Message::SettingsAgentsEnabled).to_owned(), + Ok(false) => language.text(Message::SettingsAgentsDisabled).to_owned(), + Err(error) => language.format(Message::SettingsAgentsFailed, &[("error", error)]), + }) + .or_else(|| { + state.error.as_ref().map(|error| { + language.format(Message::SettingsAgentsFailed, &[("error", error)]) + }) + }) + .or_else(|| { + (state.helper_missing + && row.executable.is_some() + && row.hook.is_some() + && crate::platform::CAPABILITIES.ai_hook_server) + .then(|| language.text(Message::SettingsAgentsHelperMissing).to_owned()) + }) + .or_else(|| { + (!state.available && row.executable.is_some() && row.hook.is_some()) + .then(|| language.text(Message::SettingsAgentsInitialize).to_owned()) + }); + let muted = cx.theme().muted_foreground; + let hover = crate::gpui_shell::theme::settings_hover_bg(cx, false); + let border = crate::gpui_shell::theme::settings_hairline(cx); + let ring = cx.theme().ring; + let text = v_flex() + .flex_1() + .min_w_0() + .gap_1() + .child( + div().text_sm().font_weight(gpui::FontWeight::MEDIUM).child(agent.display_name()), + ) + .child(div().text_xs().text_color(muted).child(language.text(status))) + .when_some(note, |view, note| { + view.child(div().text_xs().text_color(muted).child(note)) + }); + let icon = if let Some(path) = match agent { + AgentKind::Cursor => Some(crate::gpui_shell::assets::nav::AGENT_CURSOR), + AgentKind::Copilot => Some(crate::gpui_shell::assets::nav::AGENT_COPILOT), + _ => None, + } { + Icon::default().path(path).size(px(16.0)).into_any_element() + } else if let Some(image) = crate::display::ai_logo_for_program(agent.slug()) + .and_then(|logo| self.agents.logos.get(&(logo, cx.theme().is_dark()))) + { + img(image.clone()).size(px(16.0)).into_any_element() + } else { + div() + .font_family(crate::font_install::REQUIRED_FONT_FAMILY) + .text_size(px(16.0)) + .child(crate::display::program_icon(agent.slug())) + .into_any_element() + }; + h_flex() + .id(("agent-hook-row", index)) + .debug_selector(move || format!("agent-hook-row-{index}")) + .w_full() + .min_h(px(64.0)) + .px_4() + .py_3() + .gap_3() + .border_b_1() + .border_color(if index + 1 < self.agents.rows.as_ref().map_or(0, Vec::len) { + border + } else { + gpui::transparent_black() + }) + .track_focus(&focus.clone().tab_stop(!disabled)) + .role(gpui::Role::Switch) + .aria_label(format!("{} hooks — {}", agent.display_name(), language.text(status))) + .aria_toggled(if checked { gpui::Toggled::True } else { gpui::Toggled::False }) + .when(!disabled, |view| { + view.cursor_pointer() + .hover(move |style| style.bg(hover)) + .active(move |style| style.bg(hover)) + .focus_visible(move |style| style.border_color(ring)) + }) + .on_mouse_down(MouseButton::Left, move |_, window, cx| { + if !disabled { + window.focus(&focus, cx); + } + }) + .on_click(cx.listener(move |this, _, _, cx| { + if !disabled { + this.toggle_agent_hook(agent, !checked, cx); + } + })) + .on_key_down(cx.listener(move |this, event: &KeyDownEvent, _, cx| { + if !disabled && matches!(event.keystroke.key.as_str(), "enter" | "space") { + cx.stop_propagation(); + this.toggle_agent_hook(agent, !checked, cx); + } + })) + .child( + div() + .size(px(28.0)) + .flex_shrink_0() + .flex() + .items_center() + .justify_center() + .child(icon), + ) + .child(text) + .child( + div().min_w(px(40.0)).min_h(px(32.0)).flex().items_center().justify_center().child( + crate::gpui_shell::widgets::NebulaSwitch::new(format!( + "agent-{}", + agent.slug() + )) + .checked(checked) + .disabled(disabled) + .on_click(cx.listener(move |this, enabled, _, cx| { + this.toggle_agent_hook(agent, *enabled, cx) + })), + ), + ) + } +} + +fn can_toggle(row: &AgentIntegration) -> bool { + crate::platform::CAPABILITIES.ai_hook_server + && row.hook.is_some() + && (row.inspection.installed + || (row.executable.is_some() + && row.inspection.available + && !row.inspection.helper_missing)) +} + +fn agent_status(row: &AgentIntegration, busy: bool) -> Message { + let state = &row.inspection; + if busy { + Message::SettingsAgentsApplying + } else if row.executable.is_none() { + Message::SettingsAgentsNotDetected + } else if state.installed && state.needs_repair { + Message::SettingsAgentsNeedsRepair + } else if state.installed { + Message::SettingsAgentsInstalled + } else if state.error.is_some() { + Message::SettingsAgentsNeedsAttention + } else if row.hook.is_none() { + Message::SettingsAgentsUnsupported + } else if !crate::platform::CAPABILITIES.ai_hook_server { + Message::SettingsAgentsUnavailable + } else { + Message::SettingsAgentsOff + } +} diff --git a/nebula_app/src/gpui_shell/settings_pane/agents/tests.rs b/nebula_app/src/gpui_shell/settings_pane/agents/tests.rs new file mode 100644 index 0000000..c2dc578 --- /dev/null +++ b/nebula_app/src/gpui_shell/settings_pane/agents/tests.rs @@ -0,0 +1,191 @@ +use super::*; +use crate::ai_hook::integrations::HookInspection; +use futures::FutureExt as _; +use gpui::{Modifiers, TestAppContext, VisualTestContext, point, size}; +use std::sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, +}; + +type Reply = (Result<(), String>, Vec); + +fn rows(installed: bool) -> Vec { + vec![AgentIntegration { + agent: AgentKind::Claude, + executable: Some("C:/test/claude.exe".into()), + hook: Some(nebula_settings::AgentHook::Claude), + inspection: HookInspection { + available: true, + installed, + enabled: installed, + config_path: Some("C:/test/.claude/settings.json".into()), + ..Default::default() + }, + }] +} + +fn fixture( + cx: &mut TestAppContext, +) -> ( + Entity, + &mut VisualTestContext, + futures::channel::oneshot::Sender, + Arc, +) { + let (sender, receiver) = futures::channel::oneshot::channel(); + let receiver = Arc::new(Mutex::new(Some(receiver))); + let calls = Arc::new(AtomicUsize::new(0)); + let counted = calls.clone(); + cx.update(|cx| { + gpui_component::init(cx); + cx.set_global(crate::gpui_shell::config::Settings::load(ThemeName::Nord)); + }); + let mut pane = None; + let (_, window) = cx.add_window_view(|window, cx| { + let view = cx.new(|cx| SettingsPane::new(window, cx)); + view.update(cx, |pane, _| { + pane.active_section = 10; + pane.agents.rows = Some(rows(true)); + pane.agents.test_operation = Some(Arc::new(move |hook, enabled| { + assert_eq!(hook, nebula_settings::AgentHook::Claude); + assert!(!enabled, "the real installed state makes the first action removal"); + counted.fetch_add(1, Ordering::SeqCst); + let receiver = receiver.lock().unwrap().take().expect("one operation at a time"); + async move { receiver.await.unwrap() }.boxed() + })); + }); + pane = Some(view.clone()); + gpui_component::Root::new(view, window, cx) + }); + window.simulate_resize(size(px(1040.0), px(900.0))); + window.run_until_parked(); + (pane.unwrap(), window, sender, calls) +} + +#[gpui::test] +fn installed_hook_row_accepts_padding_clicks_and_reports_write_failure(cx: &mut TestAppContext) { + if !crate::platform::CAPABILITIES.ai_hook_server { + return; + } + let (pane, window, reply, calls) = fixture(cx); + let bounds = window.debug_bounds("agent-hook-row-0").expect("Agents row rendered"); + assert!(bounds.size.height >= px(64.0)); + assert!(bounds.right() <= px(1040.0)); + // The row's left padding is a real hit target, not just the label or switch. + let padding = bounds.origin + point(px(5.0), px(5.0)); + window.simulate_click(padding, Modifiers::default()); + window.run_until_parked(); + assert_eq!(pane.read_with(window, |pane, _| pane.agents.busy), Some(AgentKind::Claude)); + window.simulate_click(padding, Modifiers::default()); + window.run_until_parked(); + assert_eq!(calls.load(Ordering::SeqCst), 1); + reply.send((Err("fixture write denied".into()), rows(true))).unwrap(); + window.run_until_parked(); + pane.read_with(window, |pane, _| { + assert!(pane.agents.busy.is_none()); + assert!(pane.agents.rows.as_ref().unwrap()[0].inspection.installed); + assert!(matches!(&pane.agents.feedback, Some((AgentKind::Claude, Err(error))) if error == "fixture write denied")); + }); +} + +#[gpui::test] +fn hook_switch_supports_keyboard_and_shows_success_only_after_completion(cx: &mut TestAppContext) { + if !crate::platform::CAPABILITIES.ai_hook_server { + return; + } + let (pane, window, reply, calls) = fixture(cx); + window.update(|window, cx| { + let focus = pane.read(cx).agents.focus[0].clone(); + window.focus(&focus, cx); + }); + window.simulate_keystrokes("space"); + window.run_until_parked(); + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert!(pane.read_with(window, |pane, _| pane.agents.feedback.is_none())); + reply.send((Ok(()), rows(false))).unwrap(); + window.run_until_parked(); + pane.read_with(window, |pane, _| { + assert!(!pane.agents.rows.as_ref().unwrap()[0].inspection.enabled); + assert!(matches!(&pane.agents.feedback, Some((AgentKind::Claude, Ok(false))))); + }); +} + +#[gpui::test] +fn configuration_without_an_executable_is_not_an_installed_agent(cx: &mut TestAppContext) { + let (pane, window, reply, calls) = fixture(cx); + drop(reply); + pane.update(window, |pane, cx| { + let mut rows = rows(false); + rows[0].executable = None; + assert!(rows[0].inspection.available); + assert!(matches!(agent_status(&rows[0], false), Message::SettingsAgentsNotDetected)); + assert!(!can_toggle(&rows[0])); + pane.agents.rows = Some(rows); + cx.notify(); + }); + window.run_until_parked(); + let bounds = window.debug_bounds("agent-hook-row-0").unwrap(); + window.simulate_click(bounds.center(), Modifiers::default()); + window.run_until_parked(); + assert_eq!(calls.load(Ordering::SeqCst), 0); + pane.read_with(window, |pane, _| assert!(pane.agents.busy.is_none())); +} + +#[gpui::test] +fn repeated_switches_follow_completed_state_and_ignore_inflight_clicks(cx: &mut TestAppContext) { + if !crate::platform::CAPABILITIES.ai_hook_server { + return; + } + let (pane, window, unused, _) = fixture(cx); + drop(unused); + let pending = Arc::new(Mutex::new(std::collections::VecDeque::new())); + let queued = pending.clone(); + window.update(|_, cx| { + pane.update(cx, |pane, _| { + pane.agents.test_operation = Some(Arc::new(move |_, enabled| { + let (sender, receiver) = futures::channel::oneshot::channel::(); + queued.lock().unwrap().push_back((enabled, sender)); + async move { receiver.await.unwrap() }.boxed() + })); + }); + }); + for cycle in 0..12 { + let enabled = cycle % 2 != 0; + let bounds = window.debug_bounds("agent-hook-row-0").unwrap(); + window.simulate_click(bounds.origin + point(px(5.0), px(5.0)), Modifiers::default()); + window.run_until_parked(); + window.simulate_click(bounds.center(), Modifiers::default()); + window.simulate_keystrokes("space"); + window.run_until_parked(); + assert_eq!(pending.lock().unwrap().len(), 1, "in-flight clicks must not enqueue writes"); + let (requested, reply) = pending.lock().unwrap().pop_front().unwrap(); + assert_eq!(requested, enabled); + reply.send((Ok(()), rows(enabled))).unwrap(); + window.run_until_parked(); + pane.read_with(window, |pane, _| { + assert!(pane.agents.busy.is_none()); + assert_eq!(pane.agents.rows.as_ref().unwrap()[0].inspection.enabled, enabled); + assert!(matches!(&pane.agents.feedback, Some((AgentKind::Claude, Ok(actual))) if *actual == enabled)); + }); + } +} + +#[gpui::test] +fn closing_settings_does_not_cancel_a_submitted_hook_write_or_reopen_the_view( + cx: &mut TestAppContext, +) { + if !crate::platform::CAPABILITIES.ai_hook_server { + return; + } + let (pane, window, reply, calls) = fixture(cx); + let bounds = window.debug_bounds("agent-hook-row-0").unwrap(); + window.simulate_click(bounds.center(), Modifiers::default()); + window.run_until_parked(); + assert_eq!(calls.load(Ordering::SeqCst), 1); + let weak = pane.downgrade(); + window.update(|window, _| window.remove_window()); + drop(pane); + reply.send((Ok(()), rows(false))).unwrap(); + window.run_until_parked(); + assert!(weak.upgrade().is_none()); +} diff --git a/nebula_app/src/gpui_shell/settings_pane/backup/tests.rs b/nebula_app/src/gpui_shell/settings_pane/backup/tests.rs index bdc7b80..49ee301 100644 --- a/nebula_app/src/gpui_shell/settings_pane/backup/tests.rs +++ b/nebula_app/src/gpui_shell/settings_pane/backup/tests.rs @@ -31,6 +31,10 @@ fn pairing_design_backup_groups_scope_and_save_feedback_with_storage( }); let card = cx.debug_bounds("cloud-storage-card").expect("storage configuration"); assert!(card.right() <= px(width)); + assert!(card.size.width <= px(720.0)); + let provider = cx.debug_bounds("cloud-provider").expect("bounded provider dropdown"); + assert!(provider.size.width <= px(220.0)); + assert!(provider.left() >= card.left() && provider.right() <= card.right()); for selector in ["cloud-scope-toggle", "cloud-save-status"] { let control = cx.debug_bounds(selector).expect("in-card control"); assert!(control.origin.x >= card.origin.x && control.right() <= card.right()); diff --git a/nebula_app/src/gpui_shell/settings_pane/backup/view.rs b/nebula_app/src/gpui_shell/settings_pane/backup/view.rs index 03c6c10..5d14c0f 100644 --- a/nebula_app/src/gpui_shell/settings_pane/backup/view.rs +++ b/nebula_app/src/gpui_shell/settings_pane/backup/view.rs @@ -1,6 +1,6 @@ //! Compact backup dashboard and configuration card based on the approved layout. use super::*; -use crate::gpui_shell::widgets::{NebulaSwitch, device_icon_anchor}; +use crate::gpui_shell::widgets::NebulaSwitch; use gpui_component::menu::PopupMenuItem; fn card(cx: &App) -> gpui::Div { @@ -8,11 +8,10 @@ fn card(cx: &App) -> gpui::Div { .w_full() .min_w_0() .gap_4() - .p_5() + .p_4() .rounded_lg() .border_1() - .border_color(cx.theme().border) - .bg(cx.theme().secondary.opacity(0.3)) + .border_color(crate::gpui_shell::theme::settings_hairline(cx)) } fn provider(config: &remote::BackupRemoteConfig) -> Message { @@ -36,13 +35,8 @@ impl SettingsPane { self.initialize_backup(cx); let language = crate::gpui_shell::config::ui_language(cx); let configuration = self.backup_ui.configuration; - let tabs = h_flex() - .gap_1() - .p_1() - .rounded_md() - .border_1() - .border_color(cx.theme().border) - .children( + let tabs = + gpui_component::button::ButtonGroup::new("cloud-tabs").small().outline().children( [(false, Message::CloudSnapshots), (true, Message::CloudSettings)].into_iter().map( |(selected, title)| { Button::new(if selected { @@ -56,7 +50,8 @@ impl SettingsPane { }) .label(language.text(title)) .small() - .ghost() + .h(px(28.0)) + .rounded(px(14.0)) .selected(configuration == selected) .on_click(cx.listener(move |this, _, _, cx| { this.backup_ui.configuration = selected; @@ -72,7 +67,8 @@ impl SettingsPane { if configuration { self.backup_configuration(cx) } else { self.backup_dashboard(cx) }; v_flex() .w_full() - .gap_5() + .max_w(px(720.0)) + .gap_4() .child( h_flex() .gap_3() @@ -143,6 +139,7 @@ impl SettingsPane { .child( Button::new("cloud-refresh") .ghost() + .size(px(28.0)) .icon(Icon::default().path(crate::gpui_shell::assets::nav::REFRESH)) .tooltip(language.text(Message::CloudRefresh)) .disabled(off || self.backup_ui.listing || busy) @@ -151,13 +148,20 @@ impl SettingsPane { ); if self.backup_ui.snapshots.is_empty() { history = history.child( - div().py_5().text_sm().text_color(cx.theme().muted_foreground).child( - language.text(if self.backup_ui.checked { + v_flex() + .w_full() + .py_6() + .gap_2() + .items_center() + .text_center() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(Icon::new(IconName::FolderOpen).size(px(24.0))) + .child(language.text(if self.backup_ui.checked { Message::CloudEmpty } else { Message::CloudLoadHistory - }), - ), + })), ); } for (index, name) in self.backup_ui.snapshots.iter().enumerate() { @@ -218,12 +222,12 @@ impl SettingsPane { .gap_4() .items_center() .flex_wrap() - .child(device_icon_anchor(IconName::Globe, cx)) + .child(Icon::new(IconName::Globe).size(px(20.0))) .child( v_flex() .flex_1() - .min_w(px(220.0)) - .gap_2() + .min_w(px(160.0)) + .gap_1() .child( h_flex() .gap_2() @@ -315,6 +319,12 @@ impl SettingsPane { let busy = self.backup_busy || self.backup_ui.secret_busy; let owner = cx.entity().downgrade(); let provider_menu = Button::new("cloud-provider") + .debug_selector(|| "cloud-provider".into()) + .w(px(SETTINGS_SELECT_WIDTH)) + .max_w_full() + .small() + .h(px(32.0)) + .dropdown_caret(true) .label(language.text(provider(&self.backup_remote))) .disabled(busy) .dropdown_menu(move |mut menu, _, _| { @@ -343,7 +353,7 @@ impl SettingsPane { .gap_3() .items_center() .flex_wrap() - .child(device_icon_anchor(IconName::Globe, cx)) + .child(Icon::new(IconName::Globe).size(px(18.0))) .child( div() .flex_1() @@ -355,6 +365,12 @@ impl SettingsPane { .debug_selector(|| "cloud-scope-toggle".into()) .label(language.text(Message::CloudScope)) .small() + .ghost() + .icon(if self.backup_ui.scope_open { + IconName::ChevronUp + } else { + IconName::ChevronDown + }) .selected(self.backup_ui.scope_open) .on_click(cx.listener(|this, _, _, cx| { this.backup_ui.scope_open = !this.backup_ui.scope_open; @@ -364,7 +380,8 @@ impl SettingsPane { ) .child( v_flex() - .gap_1() + .items_start() + .gap_2() .child( div() .text_xs() @@ -389,20 +406,25 @@ impl SettingsPane { storage = storage.child( v_flex() .w_full() - .gap_1() + .max_w(px(420.0)) + .gap_2() .child( div() .text_xs() .text_color(cx.theme().muted_foreground) .child(language.text(*label)), ) - .child(Input::new(&self.backup_remote_inputs[index]).disabled(busy)), + .child( + Input::new(&self.backup_remote_inputs[index]).h(px(32.0)).disabled(busy), + ), ); } if matches!(self.backup_remote.protocol, BackupProtocol::WebDav | BackupProtocol::S3) { storage = storage.child( v_flex() - .gap_1() + .w_full() + .max_w(px(420.0)) + .gap_2() .child( h_flex() .gap_2() @@ -420,12 +442,9 @@ impl SettingsPane { .gap_2() .items_center() .flex_wrap() - .child( - div() - .flex_1() - .min_w(px(180.0)) - .child(Input::new(&self.backup_secret_input).disabled(busy)), - ) + .child(div().flex_1().min_w(px(180.0)).child( + Input::new(&self.backup_secret_input).h(px(32.0)).disabled(busy), + )) .child( Button::new("cloud-store-secret") .label(language.text(Message::CloudStoreCredential)) @@ -517,7 +536,12 @@ impl SettingsPane { .font_weight(gpui::FontWeight::SEMIBOLD) .child(language.text(Message::CloudPassphrase)), ) - .child(Input::new(&self.backup_pass_input).disabled(busy)) + .child( + div() + .w_full() + .max_w(px(420.0)) + .child(Input::new(&self.backup_pass_input).h(px(32.0)).disabled(busy)), + ) .child( div() .text_xs() diff --git a/nebula_app/src/gpui_shell/settings_pane/initialization.rs b/nebula_app/src/gpui_shell/settings_pane/initialization.rs index 571f41b..d972d16 100644 --- a/nebula_app/src/gpui_shell/settings_pane/initialization.rs +++ b/nebula_app/src/gpui_shell/settings_pane/initialization.rs @@ -572,6 +572,7 @@ impl SettingsPane { runtime, launch_at_login: crate::platform::startup::launch_at_login(), active_section: 1, + agents: agents::AgentSettingsState::new(cx), appearance_picker: None, appearance_picker_seq: 0, theme_editor: None, diff --git a/nebula_app/src/gpui_shell/settings_pane/navigation.rs b/nebula_app/src/gpui_shell/settings_pane/navigation.rs index 83c2599..5736490 100644 --- a/nebula_app/src/gpui_shell/settings_pane/navigation.rs +++ b/nebula_app/src/gpui_shell/settings_pane/navigation.rs @@ -9,7 +9,7 @@ pub(super) const BUG_REPORT_TEMPLATE: &str = "bug_report.yml"; /// 左侧分区的稳定路由表。2026-08-28 产品裁定:默认 GPUI 导航收敛为常用项, /// 暂时隐藏“AI 供应商”;页面实现与索引继续保留。后续恢复入口时只改 /// [`HIDDEN_NAV_SECTIONS`],不得删除或重排这里的条目。 -pub(super) const SECTION_IDS: [&str; 10] = [ +pub(super) const SECTION_IDS: [&str; 11] = [ "application", "appearance", "profiles", @@ -20,12 +20,13 @@ pub(super) const SECTION_IDS: [&str; 10] = [ "keymap", "advanced", "backup", + "agents", ]; /// Bilingual search aliases for the stable section routes. Search is a route /// finder, so a query such as "font", "opacity", or "更新" lands on the /// section that owns the control instead of merely filtering the current page. -pub(super) const SECTION_SEARCH_TERMS: [&str; 10] = [ +pub(super) const SECTION_SEARCH_TERMS: [&str; 11] = [ "application app 应用 update 更新 version 版本 github support 支持", "appearance ligatures 连字 外观 theme 主题 custom 自定义 template 模板 import 导入 export 导出 font 字体 opacity 透明度 background 背景 cursor 光标 icon 图标 dim inactive panes 调暗非活动窗格 分屏变暗 scrollback scrolling speed history 回滚 滚动 速度 历史", "profiles 配置文件 shell terminal 终端 completion 补全 startup 启动 ai message notifications toast alerts bell 提醒 通知 弹窗 消息 右下角 ai消息通知 ai 消息通知 ai消息弹窗 ai 消息弹窗 铃声 duration timeout persistent auto-dismiss 时长 秒 常驻 自动关闭", @@ -36,6 +37,7 @@ pub(super) const SECTION_SEARCH_TERMS: [&str; 10] = [ "keymap key binding shortcut quick terminal 快速终端 独立窗口 已有窗口 按键映射 快捷键", "advanced 高级 session 会话 tray 托盘 restore 恢复 startup autostart login silent 自启动 静默启动 开机 登录", "backup cloud sync 云备份 云同步 云存储 备份 export 导出 restore 恢复 webdav s3 sftp 坚果云 123 123云盘 nextcloud synology 群晖 nas r2 minio snapshots 快照", + "agents agent hook hooks 智能体 钩子 接入 claude codex opencode cursor kimi pi omp copilot grok", ]; pub(super) const HIDDEN_NAV_SECTIONS: &[usize] = &[3]; @@ -43,7 +45,7 @@ pub(super) const HIDDEN_NAV_SECTIONS: &[usize] = &[3]; /// 保留原来的分组展开顺序,组名不再渲染;数组里仍保存稳定的 [`SECTION_IDS`] /// 下标,不复制设置状态或路由。 pub(super) const NAV_GROUPS: [(&str, &[usize]); 3] = - [("workspace", &[0, 1, 2, 6, 7]), ("connections", &[3, 4, 5]), ("system", &[8, 9])]; + [("workspace", &[0, 1, 2, 10, 6, 7]), ("connections", &[3, 4, 5]), ("system", &[8, 9])]; pub(super) fn section_label(index: usize, language: crate::display::UiLanguage) -> &'static str { match SECTION_IDS.get(index).copied() { @@ -57,6 +59,7 @@ pub(super) fn section_label(index: usize, language: crate::display::UiLanguage) Some("keymap") => language.tr("settings.sidebar.keymap"), Some("advanced") => language.tr("settings.sidebar.advanced"), Some("backup") => language.tr("settings.sidebar.backup"), + Some("agents") => language.text(crate::i18n::Message::SettingsSidebarAgents), _ => "", } } @@ -108,14 +111,15 @@ pub(super) fn section_icon(index: usize) -> SharedString { match index { 0 => crate::gpui_shell::assets::nav::LAYOUT_GRID.into(), 1 => IconName::Palette.path(), - 2 => IconName::Folder.path(), + 2 => IconName::SquareTerminal.path(), 3 => IconName::Bot.path(), - 4 => IconName::SquareTerminal.path(), + 4 => IconName::Network.path(), 5 => IconName::Globe.path(), 6 => crate::gpui_shell::assets::nav::MOUSE_POINTER.into(), 7 => crate::gpui_shell::assets::nav::KEYMAP.into(), 8 => crate::gpui_shell::assets::nav::SLIDERS.into(), 9 => IconName::HardDrive.path(), + 10 => IconName::Bot.path(), _ => IconName::Inbox.path(), } } diff --git a/nebula_app/src/gpui_shell/settings_pane/segmented.rs b/nebula_app/src/gpui_shell/settings_pane/segmented.rs index a0d2397..f383694 100644 --- a/nebula_app/src/gpui_shell/settings_pane/segmented.rs +++ b/nebula_app/src/gpui_shell/settings_pane/segmented.rs @@ -26,33 +26,38 @@ impl SettingsPane { let labels = localized_select_labels(key, values, crate::gpui_shell::config::ui_language(cx)); Some( - h_flex() - .w(px(SETTINGS_SELECT_WIDTH)) - .gap_1() - .children(values.iter().copied().zip(labels).enumerate().map( - |(index, (value, label))| { - Button::new(SharedString::from(format!("settings-choice-{key}-{value}"))) - .debug_selector(move || format!("settings-choice-{key}-{value}")) - .flex_1() - .min_w_0() - .small() - .outline() - .selected(index == selected) - .label(label) - .on_click(cx.listener(move |this, _, window, cx| { - match this.try_persist(&[(key, value.to_owned())], cx) { - Ok(()) => this.sync_select(key, value, window, cx), - Err(error) => crate::gpui_shell::toast::toast( - window, - cx, - crate::display::ToastKind::Warning, - error.to_string(), - ), - } - })) - }, - )) - .into_any_element(), + gpui_component::button::ButtonGroup::new(SharedString::from(format!( + "settings-choices-{key}" + ))) + .w(px(SETTINGS_SELECT_WIDTH)) + .max_w_full() + .small() + .outline() + .children(values.iter().copied().zip(labels).enumerate().map( + |(index, (value, label))| { + Button::new(SharedString::from(format!("settings-choice-{key}-{value}"))) + .debug_selector(move || format!("settings-choice-{key}-{value}")) + .flex_1() + .min_w_0() + .small() + .h(px(28.0)) + .rounded(px(14.0)) + .selected(index == selected) + .label(label) + .on_click(cx.listener(move |this, _, window, cx| { + match this.try_persist(&[(key, value.to_owned())], cx) { + Ok(()) => this.sync_select(key, value, window, cx), + Err(error) => crate::gpui_shell::toast::toast( + window, + cx, + crate::display::ToastKind::Warning, + error.to_string(), + ), + } + })) + }, + )) + .into_any_element(), ) } } diff --git a/nebula_app/src/gpui_shell/settings_pane/tests.rs b/nebula_app/src/gpui_shell/settings_pane/tests.rs index 35dedab..19d505a 100644 --- a/nebula_app/src/gpui_shell/settings_pane/tests.rs +++ b/nebula_app/src/gpui_shell/settings_pane/tests.rs @@ -84,7 +84,7 @@ fn ai_toast_setting_is_searchable_and_has_a_visible_switch(cx: &mut gpui::TestAp #[test] fn settings_nav_visibility_keeps_stable_routes_and_restores_backup() { let visibility: Vec<_> = (0..SECTION_IDS.len()).map(is_nav_section_visible).collect(); - assert_eq!(visibility, vec![true, true, true, false, true, true, true, true, true, true]); + assert_eq!(visibility, vec![true, true, true, false, true, true, true, true, true, true, true]); assert_eq!( SECTION_IDS, [ @@ -98,6 +98,7 @@ fn settings_nav_visibility_keeps_stable_routes_and_restores_backup() { "keymap", "advanced", "backup", + "agents", ] ); } @@ -105,14 +106,14 @@ fn settings_nav_visibility_keeps_stable_routes_and_restores_backup() { #[test] fn settings_nav_starts_with_application_then_frequent_options() { let visible: Vec<_> = visible_nav_sections().collect(); - assert_eq!(visible, vec![0, 1, 2, 6, 7, 4, 5, 8, 9]); + assert_eq!(visible, vec![0, 1, 2, 10, 6, 7, 4, 5, 8, 9]); let zh_labels: Vec<_> = visible .iter() .map(|index| section_label(*index, crate::display::UiLanguage::ZhCn)) .collect(); assert_eq!( zh_labels, - vec!["应用", "外观", "终端", "交互", "按键映射", "SSH", "网络", "高级", "备份"] + vec!["应用", "外观", "终端", "Agents", "交互", "按键映射", "SSH", "网络", "高级", "备份"] ); let en_labels: Vec<_> = visible .iter() @@ -124,6 +125,7 @@ fn settings_nav_starts_with_application_then_frequent_options() { "Application", "Appearance", "Terminal", + "Agents", "Interaction", "Key Bindings", "SSH", diff --git a/nebula_app/src/gpui_shell/ssh_settings/library.rs b/nebula_app/src/gpui_shell/ssh_settings/library.rs index 01f2c0b..ab37feb 100644 --- a/nebula_app/src/gpui_shell/ssh_settings/library.rs +++ b/nebula_app/src/gpui_shell/ssh_settings/library.rs @@ -682,38 +682,24 @@ impl SettingsPane { ) }, )) - .child( - h_flex() - .gap_2() - .items_center() - .when(hidden_count > 0, |row| { - let show = self.ssh_show_hidden; - row.child( - NebulaButton::new("ssh-toggle-hidden") - .label(if show { - SharedString::from( - language.pick("收起已隐藏", "Collapse hidden"), - ) - } else { - SharedString::from(format!( - "{} {hidden_count}", - language.pick("已隐藏", "Hidden") - )) - }) - .on_click(cx.listener(|this, _, _, cx| { - this.ssh_show_hidden = !this.ssh_show_hidden; - cx.notify(); - })), - ) - }) - .child(div().flex_1()) - .child( - div() - .text_xs() - .text_color(muted) - .child(language.tr("settings.ssh.shared_data_hint")), - ), - ) + .when(hidden_count > 0, |group| { + let show = self.ssh_show_hidden; + group.child( + NebulaButton::new("ssh-toggle-hidden") + .label(if show { + SharedString::from(language.pick("收起已隐藏", "Collapse hidden")) + } else { + SharedString::from(format!( + "{} {hidden_count}", + language.pick("已隐藏", "Hidden") + )) + }) + .on_click(cx.listener(|this, _, _, cx| { + this.ssh_show_hidden = !this.ssh_show_hidden; + cx.notify(); + })), + ) + }) .when_some(hidden_rows, |group, rows| { group.child(div().h(px(8.0))).child( v_flex() diff --git a/nebula_app/src/gpui_shell/terminal/view.rs b/nebula_app/src/gpui_shell/terminal/view.rs index 2670fb7..dea90ae 100644 --- a/nebula_app/src/gpui_shell/terminal/view.rs +++ b/nebula_app/src/gpui_shell/terminal/view.rs @@ -1477,33 +1477,6 @@ impl Render for TerminalView { ); } } - if let Some(answer) = self.answers.latest.clone() { - let provider = if answer.provider == "claude" { "Claude Code" } else { "Codex" }; - return div() - .size_full() - .relative() - .child(root) - .child( - crate::gpui_shell::prelude::h_flex() - .absolute() - .top_0() - .right_2() - .px_2() - .gap_2() - .items_center() - .bg(cx.theme().background) - .child(div().text_xs().child(format!("{provider} · 回答"))) - .child( - crate::gpui_shell::prelude::Button::new("answer-open") - .label("阅读") - .small() - .on_click( - cx.listener(|view, _, window, cx| view.open_answer(window, cx)), - ), - ), - ) - .into_any_element(); - } root.into_any_element() } } diff --git a/nebula_app/src/gpui_shell/terminal/view/agent_activity.rs b/nebula_app/src/gpui_shell/terminal/view/agent_activity.rs index 76df0a0..625e23d 100644 --- a/nebula_app/src/gpui_shell/terminal/view/agent_activity.rs +++ b/nebula_app/src/gpui_shell/terminal/view/agent_activity.rs @@ -23,10 +23,20 @@ impl TerminalView { log::debug!("ai_hook: pane={} ignored hook outside the active lifecycle", self.pane_id); return true; } - let target = event.session_id.as_ref().map(|id| crate::session::AgentSession { - source: event.source.clone(), - session_id: Some(id.clone()), - session_file: event.session_file.clone(), + if !self.recovery.accepts_choice_event(event) { + return false; + } + // A lifecycle-only Hook ID cannot downgrade the independently verified + // open rollout. Keep consuming lifecycle events; the next process probe + // can discover a new conversation selected inside the same CLI process. + let keep_native_identity = + event.source == "codex" && event.session_file.is_none() && self.ai_session_from_probe; + let target = event.session_id.as_ref().filter(|_| !keep_native_identity).map(|id| { + crate::session::AgentSession { + source: event.source.clone(), + session_id: Some(id.clone()), + session_file: event.session_file.clone(), + } }); if target.as_ref().is_some_and(|target| !self.recovery.accepts(target)) { return false; @@ -88,7 +98,7 @@ impl TerminalView { self.clear_foreground_agent_state(cx); } else { self.running_program = Some(event.source.clone()); - if let Some(id) = event.session_id.as_deref() { + if let Some(id) = event.session_id.as_deref().filter(|_| !keep_native_identity) { self.ai_session_from_probe = false; self.ai_session = Some(crate::display::AiSessionIdentity { source: event.source.clone(), diff --git a/nebula_app/src/gpui_shell/terminal/view/output_tests.rs b/nebula_app/src/gpui_shell/terminal/view/output_tests.rs index 476b8de..595bf15 100644 --- a/nebula_app/src/gpui_shell/terminal/view/output_tests.rs +++ b/nebula_app/src/gpui_shell/terminal/view/output_tests.rs @@ -4,6 +4,38 @@ use gpui::TestAppContext; use std::cell::{Cell, RefCell}; use std::rc::Rc; +#[gpui::test] +fn runtime_submission_codex_prompt_does_not_depend_on_a_repaint(cx: &mut TestAppContext) { + let (view, window, receiver) = open(cx); + view.update(window, |view, cx| { + view.running_program = Some("codex".into()); + view.runtime_prompt("继续".into(), true, cx).unwrap(); + assert!(matches!(receiver.try_recv().unwrap(), Msg::Input(bytes) + if bytes.as_ref() == "继续\x1b[C\r".as_bytes())); + assert!(view.pending_runtime_submit.is_none()); + assert_eq!(view.runtime_task_state(), crate::runtime_api::RuntimeTaskState::Running); + view.process_event(TermEvent::Wakeup, cx); + assert!(receiver.try_recv().is_err(), "a later repaint must not send Enter twice"); + }); +} + +#[gpui::test] +fn runtime_submission_codex_paste_and_no_submit_preserve_their_contracts(cx: &mut TestAppContext) { + let (view, window, receiver) = open(cx); + view.update(window, |view, cx| { + view.running_program = Some("codex".into()); + view.runtime_prompt("draft".into(), false, cx).unwrap(); + assert!( + matches!(receiver.try_recv().unwrap(), Msg::Input(bytes) if bytes.as_ref() == b"draft") + ); + feed(view, b"\x1b[?2004h"); + view.runtime_paste("first\nsecond".into(), true, InputOrigin::Program, cx).unwrap(); + assert!(matches!(receiver.try_recv().unwrap(), Msg::Input(bytes) + if bytes.as_ref() == b"\x1b[200~first\rsecond\x1b[201~\x1b[C\r")); + assert!(view.pending_runtime_submit.is_none()); + }); +} + #[gpui::test] fn working_directory_copy_preserves_prompt_selection_and_utf8(cx: &mut TestAppContext) { let (view, window, _) = open(cx); diff --git a/nebula_app/src/gpui_shell/terminal/view/runtime.rs b/nebula_app/src/gpui_shell/terminal/view/runtime.rs index f01e857..bf8b158 100644 --- a/nebula_app/src/gpui_shell/terminal/view/runtime.rs +++ b/nebula_app/src/gpui_shell/terminal/view/runtime.rs @@ -159,6 +159,7 @@ impl TerminalView { exit_code: Option, cx: &mut Context, ) { + let choose_codex_session = self.codex_restore_target_missing(exit_code); self.notify_command_done(exit_code, cx); self.last_command_failed = exit_code.is_some_and(|code| code != 0); if self.clear_foreground_agent_state(cx) { @@ -168,6 +169,9 @@ impl TerminalView { self.last_run = Some(crate::runtime_api::RuntimeRunOutcome::command_done(run, exit_code)); } + if choose_codex_session { + self.choose_codex_recovery_session(cx); + } cx.notify(); } @@ -521,16 +525,20 @@ impl TerminalView { "the pane is still committing previous runtime input", )); } - let recognized_agent = submit && self.runtime_agent().is_some(); + let agent = self.runtime_agent(); + let recognized_agent = submit && agent.is_some(); + let codex_submit = submit && agent.as_ref().is_some_and(|agent| agent.kind == "codex"); if submit { self.capture_runtime_prompt(); } - let mut bytes = - crate::input::terminal_input::build_runtime_text_sequence(&text, self.term_mode()); - if submit { - // Codex/Claude 可启用 kitty 或 Win32 输入协议;裸 CR 只在 legacy VT - // 下等价于 Enter。Win32 模式下文本也已编码为 VK_PACKET 记录, - // 整个提交因此是一条同质协议流,不依赖 ConPTY 的读取边界。 + let mut bytes = if codex_submit { + crate::input::terminal_input::build_runtime_codex_submission(&text, self.term_mode()) + } else { + crate::input::terminal_input::build_runtime_text_sequence(&text, self.term_mode()) + }; + if submit && !codex_submit { + // Non-Codex input keeps the echo barrier. Enter must still follow + // the negotiated keyboard protocol; a bare CR is only legacy VT. let submit_bytes = self.runtime_key_sequence( crate::runtime_api::RuntimeKey::Enter, crate::runtime_api::RuntimeKeyModifiers::default(), @@ -668,7 +676,9 @@ impl TerminalView { )); } - if submit { + let codex_submit = + submit && self.runtime_agent().is_some_and(|agent| agent.kind == "codex"); + if submit && !codex_submit { let submit_bytes = self.runtime_key_sequence( crate::runtime_api::RuntimeKey::Enter, crate::runtime_api::RuntimeKeyModifiers::default(), @@ -679,7 +689,15 @@ impl TerminalView { submit_bytes, }); } - self.paste_now_impl(&text, false, cx); + if codex_submit { + let bytes = crate::input::terminal_input::build_runtime_codex_submission( + &text, + self.term_mode(), + ); + self.write_input(bytes, cx); + } else { + self.paste_now_impl(&text, false, cx); + } if submit { self.awaiting_input = false; self.mark_command_running(); @@ -706,21 +724,20 @@ impl TerminalView { pub(crate) fn ai_session_save_pending(&self) -> bool { // A failed refresh cannot erase an already durable identity. Pi/Codex // without any native target must finish identifying before safe exit. - self.session_agent().is_some_and(|agent| { - matches!(agent.source.as_str(), "pi" | "codex") - && agent.session_id.as_deref().is_none_or(str::is_empty) - }) + self.ai_session_probe_pending + || self.session_agent().is_some_and(|agent| { + matches!(agent.source.as_str(), "pi" | "codex") + && agent.session_id.as_deref().is_none_or(str::is_empty) + }) } /// Read the active conversation metadata when its hook has not reported an ID. /// File and process operations run on the background executor. Only results - /// for the same foreground command are applied; hook identities take priority. + /// for the same foreground command and unchanged identity are applied. pub(super) fn probe_missing_codex_session(&mut self, cx: &mut Context) { if self.exited.is_some() - || (self.agent_activity.hook_seen() - && !self.ai_session_from_probe - && self.ai_session.is_some()) || self.ai_session_probe_pending + || self.ssh_destination.is_some() || !self .running_program .as_deref() @@ -738,10 +755,16 @@ impl TerminalView { let Some(exec_context) = self.exec_context.clone() else { return }; let epoch = self.ai_session_probe_epoch; let pane_id = self.pane_id; + let shell_pid = self.session.as_ref().map(|session| session.shell_pid); + let previous_target = self.session_agent(); self.ai_session_probe_pending = true; self.last_ai_session_probe = Some(std::time::Instant::now()); let work = cx.background_executor().spawn(async move { - crate::platform::ai_session_identity::probe_codex_session(pane_id, Some(&exec_context)) + crate::platform::ai_session_identity::probe_codex_session( + pane_id, + Some(&exec_context), + shell_pid, + ) }); cx.spawn(async move |this, cx| { let session_id = work.await; @@ -752,9 +775,7 @@ impl TerminalView { if !probe_result_is_current( view.ai_session_probe_epoch, epoch, - view.agent_activity.hook_seen() - && !view.ai_session_from_probe - && view.ai_session.is_some(), + view.session_agent() != previous_target, view.running_program.as_deref(), ) { return; @@ -764,7 +785,7 @@ impl TerminalView { let target = crate::session::AgentSession { source: "codex".to_owned(), session_id: Some(session.session_id.clone()), - session_file: None, + session_file: Some(session.session_file), }; let previous = view.recovery.target.clone(); if !view.recovery.confirm(target) { diff --git a/nebula_app/src/gpui_shell/terminal/view/startup_command.rs b/nebula_app/src/gpui_shell/terminal/view/startup_command.rs index ea66fe4..2e8224e 100644 --- a/nebula_app/src/gpui_shell/terminal/view/startup_command.rs +++ b/nebula_app/src/gpui_shell/terminal/view/startup_command.rs @@ -9,6 +9,7 @@ pub(super) struct SessionRecovery { resolving: bool, submitted: bool, failed: bool, + choosing_session: bool, } impl SessionRecovery { @@ -16,14 +17,25 @@ impl SessionRecovery { self.resolving || (self.awaiting_confirmation && !self.submitted && !self.failed) } + pub(super) fn accepts_choice_event(&self, event: &crate::ai_hook::AiHookEvent) -> bool { + use crate::ai_hook::AiHookKind; + !self.choosing_session + || (event.source == "codex" + && (matches!(event.kind, AiHookKind::SessionStart | AiHookKind::PromptSubmit) + || (event.codex_hooks.is_none() && event.kind == AiHookKind::TurnDone))) + } + pub(super) fn accepts(&self, target: &crate::session::AgentSession) -> bool { !(self.awaiting_confirmation && self.target.as_ref().is_some_and(|expected| { expected.source != target.source - || (expected.session_id.is_some() && expected.session_id != target.session_id) - || (expected.session_file.is_some() - && (target.session_file.is_some() || expected.session_id.is_none()) - && expected.session_file != target.session_file) + || (!self.choosing_session + && ((expected.session_id.is_some() + && expected.session_id != target.session_id) + || (expected.session_file.is_some() + && (target.session_file.is_some() + || expected.session_id.is_none()) + && expected.session_file != target.session_file))) })) } @@ -41,6 +53,7 @@ impl SessionRecovery { self.target = Some(target); self.awaiting_confirmation = false; self.failed = false; + self.choosing_session = false; true } @@ -58,6 +71,54 @@ pub(super) struct PendingShellCommand { } impl TerminalView { + /// Only an unconfirmed cold resume and the provider's exact missing-ID + /// error may open a chooser. Ordinary command failures retain their target. + pub(super) fn codex_restore_target_missing(&self, exit_code: Option) -> bool { + // CMD's authoritative process/prompt boundary has no exit code. The + // exact missing target message still identifies this failed restore. + if exit_code == Some(0) + || !self.recovery.awaiting_confirmation + || !self.recovery.submitted + || self.recovery.choosing_session + || self.running_program.as_deref() != Some("codex") + { + return false; + } + let Some(target) = &self.recovery.target else { return false }; + let Some(id) = target.session_id.as_deref().filter(|_| target.source == "codex") else { + return false; + }; + self.runtime_read(0, 80).is_ok_and(|read| { + let compact: String = read.text.split_whitespace().collect(); + compact.contains(&format!("ERROR:NosavedsessionfoundwithID{id}.")) + }) + } + + pub(super) fn choose_codex_recovery_session(&mut self, cx: &mut Context) { + // Keep the old target until the user's choice receives a native + // acknowledgement. Directory similarity cannot recover a missing ID. + self.recovery.choosing_session = true; + self.recovery.submitted = false; + self.recovery.failed = false; + self.last_command_failed = false; + self.run_command("codex resume".into(), cx); + cx.emit(TerminalViewEvent::Notification(crate::notify::Notification::Text { + body: ui_language().text(crate::i18n::Message::SessionRestoreChooseCodex).to_owned(), + program: Some("codex".into()), + })); + } + + pub(crate) fn can_choose_recovery_session(&self) -> bool { + self.can_retry_recovery() + && self.recovery.target.as_ref().is_some_and(|target| target.source == "codex") + } + + pub(crate) fn choose_recovery_session(&mut self, cx: &mut Context) { + if self.can_choose_recovery_session() { + self.choose_codex_recovery_session(cx); + } + } + pub(crate) fn seed_restored_cwd(&mut self, cwd: String, cx: &mut Context) { self.process_event(TermEvent::CwdReport(cwd), cx); } diff --git a/nebula_app/src/gpui_shell/terminal/view/startup_tests.rs b/nebula_app/src/gpui_shell/terminal/view/startup_tests.rs index f411647..8fc7fd8 100644 --- a/nebula_app/src/gpui_shell/terminal/view/startup_tests.rs +++ b/nebula_app/src/gpui_shell/terminal/view/startup_tests.rs @@ -545,3 +545,180 @@ fn cold_resume_preserves_codex_id_with_a_legacy_native_file(cx: &mut TestAppCont assert!(view.recovery_pending(), "submission still waits for provider confirmation"); }); } + +fn submit_codex_restore( + view: &mut TerminalView, + receiver: &Receiver, + cx: &mut Context, +) -> crate::session::AgentSession { + let saved = crate::session::AgentSession { + source: "codex".into(), + session_id: Some("saved-missing".into()), + session_file: None, + }; + view.restore_agent(saved.clone(), cx); + feed(view, b"\x1b]133;A\x07user@host:~$ "); + view.flush_pending_shell_command(cx); + feed(view, b"codex resume saved-missing"); + view.flush_pending_runtime_submit(cx); + view.process_event(Event::CommandStart, cx); + receiver.try_iter().for_each(drop); + saved +} + +#[gpui::test] +fn missing_codex_target_opens_the_native_chooser_and_confirms_the_users_choice( + cx: &mut TestAppContext, +) { + let (view, window, receiver) = open(cx); + view.update(window, |view, cx| { + let saved = submit_codex_restore(view, &receiver, cx); + feed(view, b"\r\nERROR: No saved session found with ID saved-missing. Run codex resume without an ID.\r\n"); + view.process_event(Event::CommandDone { exit_code: Some(1) }, cx); + assert_eq!(view.session_agent(), Some(saved)); + assert!(view.pending_shell_command.is_some()); + assert!(receiver.try_iter().all(|message| !matches!(message, Msg::Input(_)))); + feed(view, b"\x1b]133;A\x07user@host:~$ "); + view.process_event(Event::Wakeup, cx); + assert!(receiver.try_iter().any(|message| { + matches!(message, Msg::Input(bytes) if bytes.as_ref() == b"codex resume") + })); + assert!(view.recovery_pending(), "opening a chooser is not a recovered conversation"); + let selected = crate::session::AgentSession { + source: "codex".into(), + session_id: Some("0199a213-c2a4-7cf5-8f6b-d746fbb6e86c".into()), + session_file: Some("/sessions/rollout-date-0199a213-c2a4-7cf5-8f6b-d746fbb6e86c.jsonl".into()), + }; + assert!(!view.recovery.accepts(&crate::session::AgentSession { + source: "claude".into(), + ..selected.clone() + })); + let event = crate::ai_hook::parse_remote_envelope( + b"nebula-hook/1 source=codex codex_hooks=full\n{\"hook_event_name\":\"SessionStart\",\"session_id\":\"hook-group\",\"transcript_path\":\"/sessions/rollout-date-0199a213-c2a4-7cf5-8f6b-d746fbb6e86c.jsonl\"}", + Some(view.pane_id), + ).unwrap(); + let mut old_end = event.clone(); + old_end.kind = crate::ai_hook::AiHookKind::SessionEnd; + assert!(!view.handle_ai_hook(&old_end, cx), "a delayed session end is not the user's choice"); + let mut foreign_start = event.clone(); + foreign_start.source = "claude".into(); + foreign_start.session_id = None; + foreign_start.session_file = None; + assert!(!view.handle_ai_hook(&foreign_start, cx)); + assert!(view.recovery_pending()); + assert!(view.handle_ai_hook(&event, cx)); + assert_eq!(view.session_agent(), Some(selected)); + assert!(!view.recovery_pending()); + }); +} + +#[gpui::test] +fn unrelated_codex_failures_do_not_replace_the_saved_target(cx: &mut TestAppContext) { + for (message, exit_code) in [ + ("ERROR: No saved session found with ID somebody-else.", Some(1)), + ("ERROR: network unavailable", Some(1)), + ("ERROR: No saved session found with ID saved-missing.", Some(0)), + ("ERROR: network unavailable", None), + ] { + let (view, window, receiver) = open(cx); + view.update(window, |view, cx| { + let saved = submit_codex_restore(view, &receiver, cx); + feed(view, format!("\r\n{message}\r\n").as_bytes()); + view.process_event(Event::CommandDone { exit_code }, cx); + assert!(view.pending_shell_command.is_none(), "{message}: {exit_code:?}"); + assert_eq!(view.session_agent(), Some(saved)); + }); + } +} + +#[gpui::test] +fn cmd_prompt_completion_without_exit_code_still_offers_the_missing_conversation( + cx: &mut TestAppContext, +) { + let (view, window, receiver) = open(cx); + view.update(window, |view, cx| { + let saved = submit_codex_restore(view, &receiver, cx); + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + feed(view, b"\r\nERROR: No saved session found with ID saved-missing.\r\n"); + view.session.as_ref().unwrap().native_prompt.observe_prompt(); + view.apply_prompt_process_probe( + view.command_started, + view.prompt_input_epoch, + Ok(vec![crate::process_tree::ProcessEntry { + pid: 1, + parent_pid: 0, + executable: "cmd.exe".into(), + depth: 0, + }]), + cx, + ); + assert_eq!(view.session_agent(), Some(saved)); + assert!( + view.pending_shell_command.is_some(), + "the real CMD completion route queues the chooser" + ); + assert!(view.recovery_pending()); + }); +} + +#[gpui::test] +fn a_failed_restore_can_choose_a_conversation_without_recognizing_an_error_string( + cx: &mut TestAppContext, +) { + let (view, window, receiver) = open(cx); + view.update(window, |view, cx| { + let saved = submit_codex_restore(view, &receiver, cx); + assert!(!view.can_choose_recovery_session(), "cannot inject a chooser into a running CLI"); + feed(view, "\r\n未找到指定会话\r\n".as_bytes()); + view.finish_foreground_command(None, cx); + assert!(view.can_choose_recovery_session()); + view.choose_recovery_session(cx); + assert!(view.pending_shell_command.is_some()); + assert_eq!(view.session_agent(), Some(saved)); + assert!(view.recovery_pending()); + }); +} + +#[gpui::test] +fn lifecycle_only_hook_cannot_replace_a_verified_rollout_id(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + let id = "0199a213-c2a4-7cf5-8f6b-d746fbb6e86c"; + let target = crate::session::AgentSession { source: "codex".into(), session_id: Some(id.into()), + session_file: Some(format!("C:/sessions/rollout-date-{id}.jsonl")) }; + assert!(view.recovery.confirm(target.clone())); + view.ai_session = Some(crate::display::AiSessionIdentity { source: "codex".into(), session_id: id.into() }); + view.ai_session_from_probe = true; + view.running_program = Some("codex".into()); + view.agent_activity.begin_command(true); + let event = crate::ai_hook::parse_remote_envelope( + b"nebula-hook/1 source=codex codex_hooks=full\n{\"hook_event_name\":\"SessionStart\",\"session_id\":\"runtime-group\"}", Some(view.pane_id), + ).unwrap(); + assert!(view.handle_ai_hook(&event, cx)); + assert_eq!(view.session_agent(), Some(target)); + assert_eq!(view.ai_session.as_ref().unwrap().session_id, id); + }); +} + +#[gpui::test] +fn a_failed_codex_chooser_does_not_start_an_automatic_retry_loop(cx: &mut TestAppContext) { + let (view, window, receiver) = open(cx); + view.update(window, |view, cx| { + let saved = submit_codex_restore(view, &receiver, cx); + let error = b"\r\nERROR: No saved session found with ID saved-missing.\r\n"; + feed(view, error); + view.process_event(Event::CommandDone { exit_code: Some(1) }, cx); + feed(view, b"\x1b]133;A\x07user@host:~$ "); + view.flush_pending_shell_command(cx); + feed(view, b"codex resume"); + view.flush_pending_runtime_submit(cx); + view.process_event(Event::CommandStart, cx); + receiver.try_iter().for_each(drop); + feed(view, error); + view.process_event(Event::CommandDone { exit_code: Some(1) }, cx); + assert!(view.pending_shell_command.is_none()); + assert_eq!(view.session_agent(), Some(saved)); + assert!(view.can_retry_recovery()); + assert!(receiver.try_iter().all(|message| !matches!(message, Msg::Input(_)))); + }); +} diff --git a/nebula_app/src/gpui_shell/workspace.rs b/nebula_app/src/gpui_shell/workspace.rs index b53762b..241c375 100644 --- a/nebula_app/src/gpui_shell/workspace.rs +++ b/nebula_app/src/gpui_shell/workspace.rs @@ -758,10 +758,11 @@ pub struct NebulaWorkspace { /// 用户命令管理器贴在右侧覆盖显示,不占终端布局宽度,也不复用应用动作 /// 命令面板的状态,避免两种“命令”语义互相污染。 command_manager_open: bool, + command_manager_group: Option, command_group_menu: Option, command_manager_input: Entity, command_manager_selected: usize, - command_manager_scroll: gpui::ScrollHandle, + command_manager_scroll: gpui::UniformListScrollHandle, saved_commands: crate::saved_commands::SavedCommands, _command_manager_subscription: Subscription, /// Git/SVN 提交信息输入(GPUI 输入组件);提交动作直达共享模型 @@ -804,6 +805,7 @@ pub struct NebulaWorkspace { file_tree_scroll: gpui::UniformListScrollHandle, /// 文件树右键:画在 workspace 根上,不进抽屉子孙树。见 `file_tree.rs`。 file_tree_menu: Option, + file_tree_path: Option, /// 抽屉在 SSH pane 上的远端形态。与 `side_panel` 并存而不是替换它: /// 用户在远端 tab 和本地 tab 之间来回切时,两边的浏览位置都该留着。 remote_browser: remote_files::RemoteBrowser, @@ -975,27 +977,8 @@ impl NebulaWorkspace { } }, ); - let command_manager_subscription = cx.subscribe_in( - &command_manager_input, - window, - |this: &mut Self, - _: &Entity, - event: &InputEvent, - window: &mut Window, - cx: &mut Context<'_, Self>| { - match event { - InputEvent::Change => { - this.command_manager_selected = 0; - this.command_manager_scroll.scroll_to_item(0); - cx.notify(); - }, - InputEvent::PressEnter { .. } => { - this.run_selected_saved_command(window, cx); - }, - _ => {}, - } - }, - ); + let command_manager_subscription = + cx.subscribe_in(&command_manager_input, window, Self::on_command_manager_input_event); let file_tree_search_subscription = cx.subscribe_in(&file_tree_search_input, window, Self::on_file_tree_search_event); let sidebar_logo_target_px = @@ -1047,10 +1030,11 @@ impl NebulaWorkspace { command_palette_input, command_palette_selected: 0, command_manager_open: false, + command_manager_group: None, command_group_menu: None, command_manager_input, command_manager_selected: 0, - command_manager_scroll: gpui::ScrollHandle::new(), + command_manager_scroll: gpui::UniformListScrollHandle::new(), saved_commands: crate::saved_commands::SavedCommands::load().unwrap_or_default(), _command_manager_subscription: command_manager_subscription, git_commit_input, @@ -1072,6 +1056,7 @@ impl NebulaWorkspace { _file_tree_search_subscription: file_tree_search_subscription, file_tree_scroll: gpui::UniformListScrollHandle::new(), file_tree_menu: None, + file_tree_path: None, remote_browser: remote_files::RemoteBrowser::default(), remote_files_scroll: gpui::UniformListScrollHandle::new(), tab_menu: None, @@ -3129,6 +3114,7 @@ impl Render for NebulaWorkspace { // 变化时重建,普通 render 不重复解码 PNG。 self.sidebar_logo_images = sidebar_logo_images(sidebar_logo_target_px); self.sidebar_logo_target_px = sidebar_logo_target_px; + self.sync_settings_agent_logos(cx); } // Some tab-open/restore paths assign `active` directly. Clear a focus // record tied to a different entity before deriving layout booleans. diff --git a/nebula_app/src/gpui_shell/workspace/closing.rs b/nebula_app/src/gpui_shell/workspace/closing.rs index 199406f..d9fe80a 100644 --- a/nebula_app/src/gpui_shell/workspace/closing.rs +++ b/nebula_app/src/gpui_shell/workspace/closing.rs @@ -85,24 +85,54 @@ impl NebulaWorkspace { fn finish_close_window(&mut self, _window: &mut Window, cx: &mut Context) { self.window_close_pending = true; let panes = self.prepare_session_save(cx); + let approved_drafts: Vec<_> = self + .tabs + .iter() + .filter_map(|tab| tab.file_editor(cx)) + .map(|file| { + let draft = file.read(cx).draft(cx); + (file, draft) + }) + .collect(); let handle = self.window_handle; cx.spawn(async move |this, cx| { let ready = wait_for_session_ids(&panes, cx).await; + if !ready && !confirm_incomplete_session(handle, cx).await { + let _ = this.update(cx, |workspace, cx| { + workspace.window_close_pending = false; + cx.notify(); + }); + return; + } let _ = handle.update(cx, |_, window, cx| { let _ = this.update(cx, |workspace, cx| { - if !ready || workspace.save_clean_window_session(cx).is_err() { + if workspace.tabs.iter().filter_map(|tab| tab.file_editor(cx)).any(|file| { + let view = file.read(cx); + view.is_saving() + || (view.is_dirty() + && !approved_drafts.iter().any(|(approved, draft)| { + approved == &file && *draft == view.draft(cx) + })) + }) { workspace.window_close_pending = false; let language = crate::gpui_shell::config::ui_language(cx); - let message = if ready { - crate::i18n::Message::SessionSaveFailed - } else { - crate::i18n::Message::SessionIdentityPending - }; crate::gpui_shell::toast::banner( window, cx, crate::display::ToastKind::Warning, - language.text(message), + language.text(crate::i18n::Message::UpdateDraftChanged), + ); + cx.notify(); + return; + } + if workspace.save_clean_window_session(cx).is_err() { + workspace.window_close_pending = false; + let language = crate::gpui_shell::config::ui_language(cx); + crate::gpui_shell::toast::banner( + window, + cx, + crate::display::ToastKind::Warning, + language.text(crate::i18n::Message::SessionSaveFailed), ); cx.notify(); return; @@ -135,6 +165,28 @@ impl NebulaWorkspace { } } +/// Missing provider metadata must not make closing impossible. Consent covers +/// missing identities only: draft approval and durable-save failures still block. +pub(super) async fn confirm_incomplete_session( + handle: gpui::AnyWindowHandle, + cx: &mut gpui::AsyncApp, +) -> bool { + let prompt = handle.update(cx, |_, window, cx| { + use crate::i18n::Message; + let language = crate::gpui_shell::config::ui_language(cx); + window.activate_window(); + window.prompt( + gpui::PromptLevel::Warning, + language.text(Message::SessionExitIncompleteTitle), + Some(language.text(Message::SessionExitIncompleteBody)), + &[language.text(Message::EditorCancel), language.text(Message::SessionExitAnyway)], + cx, + ) + }); + let Ok(prompt) = prompt else { return false }; + matches!(prompt.await, Ok(1)) +} + pub(super) async fn wait_for_session_ids( panes: &[Entity], cx: &mut gpui::AsyncApp, @@ -152,3 +204,38 @@ pub(super) async fn wait_for_session_ids( cx.background_executor().timer(Duration::from_millis(25)).await; } } + +#[cfg(test)] +mod tests { + use super::*; + use gpui::TestAppContext; + + #[gpui::test] + async fn missing_identity_prompt_supports_cancel_and_explicit_close(cx: &mut TestAppContext) { + cx.update(|cx| { + cx.set_global(crate::gpui_shell::config::Settings::load( + nebula_settings::ThemeName::Nord, + )); + }); + let handle = cx.add_empty_window().update(|window, _| window.window_handle()); + for (message, expected) in [ + (crate::i18n::Message::EditorCancel, false), + (crate::i18n::Message::SessionExitAnyway, true), + ] { + let answer = cx.update(|cx| crate::gpui_shell::config::ui_language(cx).text(message)); + let task = + cx.spawn( + move |mut cx| async move { confirm_incomplete_session(handle, &mut cx).await }, + ); + cx.run_until_parked(); + assert!(cx.has_pending_prompt()); + cx.simulate_prompt_answer(answer); + assert_eq!(task.await, expected); + assert_eq!( + cx.windows(), + vec![handle], + "the prompt cannot stop a pane before durable saving" + ); + } + } +} diff --git a/nebula_app/src/gpui_shell/workspace/command_manager.rs b/nebula_app/src/gpui_shell/workspace/command_manager.rs index 0e20d60..218c9be 100644 --- a/nebula_app/src/gpui_shell/workspace/command_manager.rs +++ b/nebula_app/src/gpui_shell/workspace/command_manager.rs @@ -5,19 +5,23 @@ use super::*; mod groups; -use groups::CommandDrag; +mod rows; pub(super) use groups::GroupMenu; +use groups::{CommandDrag, ManagerRow}; const PANEL_MAX_WIDTH: f32 = 430.0; const PANEL_MAX_HEIGHT: f32 = 360.0; const PANEL_EMPTY_HEIGHT: f32 = 196.0; -const PANEL_FIXED_HEIGHT: f32 = 152.0; -const GROUP_HEADER_HEIGHT: f32 = 32.0; +// Search padding, list padding, two footer rows and the panel's two borders. +const PANEL_FIXED_HEIGHT: f32 = 146.0; +const GROUP_NAV_HEIGHT: f32 = 36.0; const PANEL_MARGIN: f32 = 8.0; // 覆盖层从自绘标题栏下沿开始;固定组件依赖当前将该区域定义为 34px。 const WINDOW_TITLE_BAR_HEIGHT: f32 = 34.0; const PANEL_FOOTER_HEIGHT: f32 = 44.0; const ROW_HEIGHT: f32 = 62.0; +const ROW_ICON_SIZE: f32 = 16.0; +const ROW_ICON_SLOT: f32 = 24.0; const EDITOR_DIALOG_HEIGHT: f32 = 430.0; const COMMAND_INPUT_HEIGHT: f32 = 150.0; const DELETE_DIALOG_HEIGHT: f32 = 230.0; @@ -60,6 +64,24 @@ fn dispatch_text(command: &crate::saved_commands::SavedCommand) -> String { } impl NebulaWorkspace { + pub(super) fn on_command_manager_input_event( + &mut self, + _: &Entity, + event: &InputEvent, + window: &mut Window, + cx: &mut Context, + ) { + match event { + InputEvent::Change => { + self.command_manager_selected = 0; + self.command_manager_scroll.scroll_to_item(0, gpui::ScrollStrategy::Top); + cx.notify(); + }, + InputEvent::PressEnter { .. } => self.run_selected_saved_command(window, cx), + _ => {}, + } + } + fn available_saved_commands(&self, cx: &App) -> Vec { use crate::saved_commands::builtins::CommandPlatform; let remote = self @@ -91,7 +113,17 @@ impl NebulaWorkspace { if query.len() > MAX_SEARCH_BYTES { return Vec::new(); } - let commands = self.available_saved_commands(cx); + let commands = self + .available_saved_commands(cx) + .into_iter() + .filter(|command| { + if let Some(group) = self.command_manager_group.as_deref() { + self.saved_commands.group_for(&command.id) == Some(group) + } else { + !query.is_empty() || self.saved_commands.group_for(&command.id).is_none() + } + }) + .collect::>(); if query.is_empty() { return commands; } @@ -130,8 +162,9 @@ impl NebulaWorkspace { ); } self.command_manager_open = true; + self.command_manager_group = None; self.command_manager_selected = 0; - self.command_manager_scroll.set_offset(gpui::point(px(0.0), px(0.0))); + self.command_manager_scroll.scroll_to_item_strict(0, gpui::ScrollStrategy::Top); self.command_manager_input.update(cx, |input, cx| { input.set_value("", window, cx); input.focus(window, cx); @@ -165,14 +198,14 @@ impl NebulaWorkspace { if !self.command_manager_open { return; } - let commands = self.filtered_saved_commands(cx); - let len = commands.len(); + let len = self.command_manager_rows(cx).len(); self.command_manager_selected = if len == 0 { 0 } else { (self.command_manager_selected as isize + delta).rem_euclid(len as isize) as usize }; - self.command_manager_scroll.scroll_to_item(self.command_scroll_index(&commands, cx)); + self.command_manager_scroll + .scroll_to_item(self.command_manager_selected, gpui::ScrollStrategy::Top); cx.notify(); } @@ -181,9 +214,10 @@ impl NebulaWorkspace { window: &mut Window, cx: &mut Context<'_, Self>, ) { - let command = self.filtered_saved_commands(cx).get(self.command_manager_selected).cloned(); - if let Some(command) = command { - self.dispatch_saved_command(command, window, cx); + match self.command_manager_rows(cx).get(self.command_manager_selected).cloned() { + Some(ManagerRow::Command(command)) => self.dispatch_saved_command(command, window, cx), + Some(ManagerRow::Folder { id, .. }) => self.enter_command_group(Some(id), window, cx), + None => {}, } } @@ -364,7 +398,12 @@ impl NebulaWorkspace { }, None => workspace .saved_commands - .insert(&name, &command, append_enter) + .insert_in_group( + &name, + &command, + append_enter, + workspace.command_manager_group.as_deref(), + ) .map(|_| ()), }; if result.is_ok() { @@ -507,18 +546,15 @@ impl NebulaWorkspace { window: &mut Window, cx: &mut Context<'_, Self>, ) -> gpui::AnyElement { - use crate::display::ui::tokens::{control, radius, space}; + use crate::display::ui::tokens::{control, radius}; let theme = cx.theme(); let panel_bg = theme.popover; let surface_bg = theme.muted; - let selected_bg = theme.list_active; let hover_bg = theme.list_hover; let foreground = theme.foreground; let muted = theme.muted_foreground; let border = theme.border; - let accent = theme.primary; - let mono_family = theme.mono_font_family.clone(); let language = crate::gpui_shell::config::ui_language(cx); let viewport = window.viewport_size(); let panel_width = @@ -526,243 +562,17 @@ impl NebulaWorkspace { let available_height = (f32::from(viewport.height) - WINDOW_TITLE_BAR_HEIGHT - PANEL_MARGIN * 2.0).max(0.0); - let commands = self.filtered_saved_commands(cx); - if self.command_manager_selected >= commands.len() { - self.command_manager_selected = commands.len().saturating_sub(1); - } - let selected_index = self.command_manager_selected; - let groups = self.visible_command_groups(&commands, cx); - let header_height = groups.len() as f32 * GROUP_HEADER_HEIGHT; - // 固定区域只保留搜索和新增入口;命令增多时仅滚动中间列表,避免退化成大面板。 - let desired_height = if commands.is_empty() { - PANEL_EMPTY_HEIGHT - } else { - PANEL_FIXED_HEIGHT + header_height + commands.len() as f32 * ROW_HEIGHT - }; + let rows = self.command_manager_rows(cx); + self.command_manager_selected = + self.command_manager_selected.min(rows.len().saturating_sub(1)); + let in_group = self.command_manager_group.is_some(); + let fixed_height = PANEL_FIXED_HEIGHT + + if in_group { GROUP_NAV_HEIGHT - PANEL_FOOTER_HEIGHT } else { 0.0 }; + let content_height = rows.len() as f32 * ROW_HEIGHT; + let desired_height = + if rows.is_empty() { PANEL_EMPTY_HEIGHT } else { fixed_height + content_height }; let panel_height = desired_height.min(PANEL_MAX_HEIGHT).min(available_height); - let list_scrollable = header_height + commands.len() as f32 * ROW_HEIGHT - > (panel_height - PANEL_FIXED_HEIGHT).max(0.0); - - let mut rows = Vec::with_capacity(commands.len()); - let mut command_iter = commands.into_iter().enumerate().peekable(); - for (group_id, group_name) in groups { - rows.push(self.render_command_group_header(group_id.clone(), group_name, cx)); - while command_iter.peek().is_some_and(|(_, command)| { - self.saved_commands.group_for(&command.id) == group_id.as_deref() - }) { - let (index, command) = command_iter.next().unwrap(); - let selected = index == selected_index; - let builtin = command.id.starts_with("builtin:"); - let hover_group = SharedString::from(format!("saved-command-row-hover-{index}")); - let preview = command - .command - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - .collect::>() - .join(" "); - let mode_label = if builtin { - language.text(crate::i18n::Message::CommandsBuiltinLabel) - } else if command.append_enter { - language.pick("运行", "Run") - } else { - language.pick("插入", "Insert") - }; - let run_tooltip = if command.append_enter { - language.pick("运行命令", "Run command") - } else { - language.pick("插入到当前终端", "Insert into current terminal") - }; - let run_icon = command_run_icon(builtin, command.append_enter); - let drag = CommandDrag::new(&command); - let menu_id = command.id.clone(); - let menu_button_id = command.id.clone(); - let run_command = command.clone(); - let row_command = command.clone(); - let copy_command = command.clone(); - let edit_id = command.id.clone(); - let delete_id = command.id.clone(); - - rows.push( - h_flex() - .id(SharedString::from(format!("saved-command-row-{index}"))) - .debug_selector(move || format!("saved-command-row-{index}").into()) - .on_drag(drag, |drag, _, _, cx| cx.new(|_| drag.clone())) - .on_mouse_down( - MouseButton::Right, - cx.listener(move |this, event: &MouseDownEvent, window, cx| { - cx.stop_propagation(); - this.open_command_group_menu( - menu_id.clone(), - event.position, - window, - cx, - ); - }), - ) - .group(hover_group.clone()) - .w_full() - .h(px(ROW_HEIGHT)) - .flex_shrink_0() - .items_center() - .gap(px(space::XS)) - .px_2() - .when(list_scrollable, |row| row.pr(px(18.0))) - .rounded(px(radius::CONTROL)) - .cursor_pointer() - .when(selected, |row| row.bg(selected_bg)) - .when(!selected, |row| { - row.group_hover(hover_group.clone(), |row| row.bg(hover_bg)) - }) - .on_click(cx.listener(move |this, _, window, cx| { - this.command_manager_selected = index; - this.dispatch_saved_command(row_command.clone(), window, cx); - })) - .child( - Button::new(SharedString::from(format!("saved-command-run-{index}"))) - .icon(run_icon) - .ghost() - .xsmall() - .tooltip(run_tooltip) - .on_click(cx.listener(move |this, _, window, cx| { - cx.stop_propagation(); - this.command_manager_selected = index; - this.dispatch_saved_command(run_command.clone(), window, cx); - })), - ) - .child( - v_flex() - .flex_1() - .min_w_0() - .gap(px(space::XXS)) - .child( - h_flex() - .w_full() - .min_w_0() - .gap_2() - .items_center() - .child( - div() - .min_w_0() - .truncate() - .text_sm() - .font_weight(FontWeight::MEDIUM) - .text_color(foreground) - .child(command.name), - ) - .child( - div() - .flex_shrink_0() - .rounded(px(radius::CHIP)) - .border_1() - .border_color(border) - .px_1() - .text_size(px(10.0)) - .text_color(if selected { accent } else { muted }) - .child(mode_label), - ), - ) - .child( - div() - .w_full() - .min_w_0() - .truncate() - .font_family(mono_family.clone()) - .text_size(px(11.0)) - .text_color(muted) - .child(preview), - ), - ) - .child( - h_flex() - .flex_shrink_0() - .items_center() - .gap_1() - .child( - Button::new(SharedString::from(format!( - "saved-command-group-menu-{index}" - ))) - .icon(IconName::EllipsisVertical) - .ghost() - .xsmall() - .tooltip( - language.text(crate::i18n::Message::CommandsMoveToGroup), - ) - .on_click(cx.listener( - move |this, _, window, cx| { - cx.stop_propagation(); - this.open_command_group_menu( - menu_button_id.clone(), - window.mouse_position(), - window, - cx, - ); - }, - )), - ) - .child( - Button::new(SharedString::from(format!( - "saved-command-copy-{index}" - ))) - .icon(IconName::Copy) - .ghost() - .xsmall() - .tooltip(language.pick("复制命令", "Copy command")) - .on_click(cx.listener( - move |this, _, window, cx| { - cx.stop_propagation(); - this.copy_saved_command(©_command, window, cx); - }, - )), - ) - .child( - Button::new(SharedString::from(format!( - "saved-command-edit-{index}" - ))) - .icon(custom_icon(crate::gpui_shell::assets::nav::PENCIL)) - .ghost() - .xsmall() - .tooltip(if builtin { - language.text(crate::i18n::Message::CommandsSaveCopy) - } else { - language.pick("编辑命令", "Edit command") - }) - .on_click(cx.listener( - move |this, _, window, cx| { - cx.stop_propagation(); - this.open_saved_command_editor( - Some(edit_id.clone()), - window, - cx, - ); - }, - )), - ) - .child( - Button::new(SharedString::from(format!( - "saved-command-delete-{index}" - ))) - .icon(custom_icon(crate::gpui_shell::assets::nav::TRASH)) - .debug_selector(move || format!("saved-command-delete-{index}")) - .ghost() - .xsmall() - .tooltip(language.pick("删除命令", "Delete command")) - .on_click(cx.listener( - move |this, _, window, cx| { - cx.stop_propagation(); - this.open_delete_saved_command_dialog( - delete_id.clone(), - window, - cx, - ); - }, - )), - ), - ) - .into_any_element(), - ); - } - } + let list_scrollable = content_height > (panel_height - fixed_height).max(0.0); let search_box = h_flex() .w_full() @@ -800,12 +610,25 @@ impl NebulaWorkspace { .into_any_element() } else { let scroll_handle = self.command_manager_scroll.clone(); - let list = v_flex() - .id("saved-command-results-scroll") - .size_full() - .overflow_y_scroll() - .track_scroll(&scroll_handle) - .children(rows); + let list = gpui::uniform_list( + "saved-command-results-scroll", + rows.len(), + cx.processor(move |this, range: std::ops::Range, _, cx| { + range + .map(|index| match rows[index].clone() { + ManagerRow::Command(command) => { + this.render_saved_command_row(index, command, list_scrollable, cx) + }, + ManagerRow::Folder { id, name, count } => { + this.render_command_folder(index, id, name, count, cx) + }, + }) + .collect() + }), + ) + .size_full() + .with_sizing_behavior(gpui::ListSizingBehavior::Auto) + .track_scroll(&scroll_handle); div() .relative() .size_full() @@ -814,8 +637,10 @@ impl NebulaWorkspace { .child(list) .when(list_scrollable, |list| { list.child( - gpui_component::scroll::Scrollbar::vertical(&scroll_handle) - .scrollbar_show(gpui_component::scroll::ScrollbarShow::Always), + div().absolute().top_0().right_0().bottom_0().w(px(16.0)).child( + gpui_component::scroll::Scrollbar::vertical(&scroll_handle) + .scrollbar_show(gpui_component::scroll::ScrollbarShow::Always), + ), ) }) .into_any_element() @@ -843,7 +668,18 @@ impl NebulaWorkspace { cx.stop_propagation(); }, "escape" => { - this.close_command_manager(window, cx); + if this.command_manager_group.is_some() { + this.enter_command_group(None, window, cx); + } else { + this.close_command_manager(window, cx); + } + cx.stop_propagation(); + }, + "left" | "backspace" + if this.command_manager_group.is_some() + && this.command_manager_input.read(cx).value().is_empty() => + { + this.enter_command_group(None, window, cx); cx.stop_propagation(); }, _ => {}, @@ -868,10 +704,15 @@ impl NebulaWorkspace { cx.listener(|_, _, _, cx| cx.stop_propagation()), ) .child(div().w_full().flex_shrink_0().p_2().child(search_box)) + .when(in_group, |panel| panel.child(self.render_command_group_navigation(cx))) .child(div().flex_1().min_h_0().px_2().pb_2().child(list_content)) .child( h_flex() .id("saved-command-add") + .debug_selector(|| "saved-command-add".into()) + .focusable() + .tab_stop(true) + .role(gpui::Role::Button) .w_full() .h(px(PANEL_FOOTER_HEIGHT)) .flex_shrink_0() @@ -884,25 +725,68 @@ impl NebulaWorkspace { .text_color(muted) .cursor_pointer() .hover(move |row| row.bg(hover_bg).text_color(foreground)) + .active(move |row| row.bg(hover_bg)) + .focus_visible(move |row| row.bg(hover_bg).text_color(foreground)) + .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { + if matches!(event.keystroke.key.as_str(), "enter" | "space") { + cx.stop_propagation(); + this.open_saved_command_editor(None, window, cx); + } + })) .on_click(cx.listener(|this, _, window, cx| { cx.stop_propagation(); this.open_saved_command_editor(None, window, cx); })) - .child(Icon::new(IconName::Plus).xsmall()) - .child(language.pick("新增命令", "Command")), + .child( + div() + .id("saved-command-add-icon") + .debug_selector(|| "saved-command-add-icon".into()) + .child(Icon::new(IconName::Plus).xsmall()), + ) + .child(language.text(crate::i18n::Message::CommandsAddCommand)), ) - .child( - Button::new("saved-command-add-group") - .w_full() - .h(px(PANEL_FOOTER_HEIGHT)) - .ghost() - .icon(IconName::Folder) - .label(language.text(crate::i18n::Message::CommandsAddGroup)) - .on_click(cx.listener(|this, _, window, cx| { - cx.stop_propagation(); - this.open_command_group_editor(window, cx); - })), - ), + .when(!in_group, |panel| { + panel.child( + h_flex() + .id("saved-command-add-group") + .debug_selector(|| "saved-command-add-group".into()) + .focusable() + .tab_stop(true) + .role(gpui::Role::Button) + .w_full() + .h(px(PANEL_FOOTER_HEIGHT)) + .flex_shrink_0() + .items_center() + .gap_2() + .px_3() + .text_sm() + .text_color(muted) + .cursor_pointer() + .hover(move |row| row.bg(hover_bg).text_color(foreground)) + .active(move |row| row.bg(hover_bg)) + .focus_visible(move |row| row.bg(hover_bg).text_color(foreground)) + .on_key_down(cx.listener( + |this, event: &KeyDownEvent, window, cx| { + if matches!(event.keystroke.key.as_str(), "enter" | "space") + { + cx.stop_propagation(); + this.open_command_group_editor(window, cx); + } + }, + )) + .on_click(cx.listener(|this, _, window, cx| { + cx.stop_propagation(); + this.open_command_group_editor(window, cx); + })) + .child( + div() + .id("saved-command-add-group-icon") + .debug_selector(|| "saved-command-add-group-icon".into()) + .child(Icon::new(IconName::Folder).xsmall()), + ) + .child(language.text(crate::i18n::Message::CommandsAddGroup)), + ) + }), ) .children(self.render_command_group_menu()) .into_any_element() diff --git a/nebula_app/src/gpui_shell/workspace/command_manager/group_tests.rs b/nebula_app/src/gpui_shell/workspace/command_manager/group_tests.rs index e5f0143..c77674c 100644 --- a/nebula_app/src/gpui_shell/workspace/command_manager/group_tests.rs +++ b/nebula_app/src/gpui_shell/workspace/command_manager/group_tests.rs @@ -78,9 +78,17 @@ fn grouping_drag_and_context_menu_keep_commands_and_keyboard_order(cx: &mut Test assert_eq!(reloaded.group_for(&command.id), Some(group.as_str())); workspace.read_with(&cx, |this, cx| { assert!(this.command_manager_open && this.tabs.is_empty()); - assert_eq!(this.filtered_saved_commands(cx)[0].id, command.id); - assert_eq!(this.command_scroll_index(&this.filtered_saved_commands(cx), cx), 2); + assert!( + this.filtered_saved_commands(cx).is_empty(), + "the moved command is inside its folder" + ); }); + let folder = + cx.debug_bounds(Box::leak(format!("command-group-{group}").into_boxed_str())).unwrap(); + cx.simulate_click(folder.origin + gpui::point(px(6.0), px(6.0)), Modifiers::default()); + draw(&mut cx); + workspace + .read_with(&cx, |this, cx| assert_eq!(this.filtered_saved_commands(cx)[0].id, command.id)); let row = cx.debug_bounds("saved-command-row-0").unwrap(); cx.simulate_mouse_down(row.center(), MouseButton::Right, Modifiers::default()); cx.simulate_mouse_up(row.center(), MouseButton::Right, Modifiers::default()); @@ -95,8 +103,13 @@ fn grouping_drag_and_context_menu_keep_commands_and_keyboard_order(cx: &mut Test ); workspace.read_with(&cx, |this, cx| { assert!(this.command_manager_open && this.tabs.is_empty()); - assert_eq!(this.command_scroll_index(&this.filtered_saved_commands(cx), cx), 1); + assert!(this.filtered_saved_commands(cx).is_empty()); }); + let back = cx.debug_bounds("saved-command-group-back").unwrap(); + cx.simulate_click(back.center(), Modifiers::default()); + draw(&mut cx); + workspace + .read_with(&cx, |this, cx| assert_eq!(this.filtered_saved_commands(cx)[0].id, command.id)); cx.simulate_keystrokes("down"); workspace.read_with(&cx, |this, _| assert_eq!(this.command_manager_selected, 1)); } @@ -107,6 +120,9 @@ fn builtin_delete_can_be_cancelled_and_stays_deleted_after_reopening(cx: &mut Te let path = dir.path().join("saved_commands.json"); let saved = crate::saved_commands::SavedCommands::load_from(&path).unwrap(); let (workspace, mut cx) = open_manager(saved, cx); + let folder = cx.debug_bounds("command-group-builtin").unwrap(); + cx.simulate_click(folder.center(), Modifiers::default()); + draw(&mut cx); let deleted_id = workspace.read_with(&cx, |this, cx| this.filtered_saved_commands(cx)[0].id.clone()); assert!(deleted_id.starts_with("builtin:")); @@ -132,6 +148,9 @@ fn builtin_delete_can_be_cancelled_and_stays_deleted_after_reopening(cx: &mut Te this.toggle_command_manager(window, cx); }); draw(&mut cx); + let folder = cx.debug_bounds("command-group-builtin").unwrap(); + cx.simulate_click(folder.center(), Modifiers::default()); + draw(&mut cx); workspace.read_with(&cx, |this, cx| { assert!(this.filtered_saved_commands(cx).iter().all(|row| row.id != deleted_id)); }); @@ -146,3 +165,107 @@ fn builtin_delete_can_be_cancelled_and_stays_deleted_after_reopening(cx: &mut Te .all(|row| row.id != deleted_id) ); } + +#[gpui::test] +fn root_has_commands_and_enterable_folders_with_aligned_creation_actions(cx: &mut TestAppContext) { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("saved_commands.json"); + let mut saved = crate::saved_commands::SavedCommands::load_from(&path).unwrap(); + let command = saved.insert("npm", "npm run dev", true).unwrap(); + let (workspace, mut cx) = open_manager(saved, cx); + assert!(cx.debug_bounds("command-group-ungrouped").is_none()); + let root_row = cx.debug_bounds("saved-command-row-0").unwrap(); + let folder = cx.debug_bounds("command-group-builtin").unwrap(); + assert!(folder.origin.y >= root_row.bottom()); + assert_eq!(folder.size.height, root_row.size.height); + let run_icon = cx.debug_bounds("saved-command-run-0").unwrap(); + let folder_icon = cx.debug_bounds("command-group-icon-1").unwrap(); + assert_eq!(folder_icon.size, run_icon.size); + assert_eq!(folder_icon.origin.x, run_icon.origin.x); + assert_eq!( + cx.debug_bounds("command-group-label-1").unwrap().origin.x, + cx.debug_bounds("saved-command-label-0").unwrap().origin.x + ); + let command_add = cx.debug_bounds("saved-command-add").unwrap(); + let group_add = cx.debug_bounds("saved-command-add-group").unwrap(); + assert!( + folder.bottom() <= command_add.origin.y, + "a fitting folder row must not be clipped by the footer" + ); + assert_eq!(command_add.origin.x, group_add.origin.x); + assert_eq!(command_add.size.height, group_add.size.height); + assert_eq!( + cx.debug_bounds("saved-command-add-icon").unwrap().origin.x, + cx.debug_bounds("saved-command-add-group-icon").unwrap().origin.x + ); + // The only next item at the root is the folder; Enter navigates, never runs. + cx.simulate_keystrokes("down enter"); + draw(&mut cx); + workspace.read_with(&cx, |this, cx| { + assert!(this.command_manager_open && this.tabs.is_empty()); + assert_eq!( + this.command_manager_group.as_deref(), + Some(crate::saved_commands::BUILTIN_GROUP_ID) + ); + assert!(this.filtered_saved_commands(cx).iter().all(|entry| entry.id != command.id)); + }); + assert!(cx.debug_bounds("saved-command-group-back").is_some()); + assert!(cx.debug_bounds("saved-command-add-group").is_none()); + cx.simulate_keystrokes("escape"); + draw(&mut cx); + workspace.read_with(&cx, |this, cx| { + assert!(this.command_manager_open && this.command_manager_group.is_none()); + assert_eq!(this.filtered_saved_commands(cx)[0].id, command.id); + }); +} + +#[gpui::test] +fn command_results_render_only_visible_rows_and_scroll_to_keyboard_selection( + cx: &mut TestAppContext, +) { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("saved_commands.json"); + let mut saved = crate::saved_commands::SavedCommands::load_from(&path).unwrap(); + for index in 0..40 { + saved.insert(&format!("Command {index}"), "echo visible", false).unwrap(); + } + let (workspace, mut cx) = open_manager(saved, cx); + assert!(cx.debug_bounds("saved-command-row-0").is_some()); + assert!(cx.debug_bounds("saved-command-row-39").is_none()); + for _ in 0..39 { + cx.simulate_keystrokes("down"); + } + draw(&mut cx); + assert!(cx.debug_bounds("saved-command-row-39").is_some()); + assert!(cx.debug_bounds("saved-command-row-0").is_none()); + workspace.read_with(&cx, |this, _| assert_eq!(this.command_manager_selected, 39)); +} + +#[gpui::test] +fn root_search_finds_nested_commands_while_folder_search_stays_in_that_folder( + cx: &mut TestAppContext, +) { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("saved_commands.json"); + let mut saved = crate::saved_commands::SavedCommands::load_from(&path).unwrap(); + saved.create_group("Work").unwrap(); + let group = saved.groups()[0].id.clone(); + let nested = + saved.insert_in_group("project-only-token", "echo nested", false, Some(&group)).unwrap(); + let (workspace, mut cx) = open_manager(saved, cx); + workspace.update_in(&mut cx, |this, window, cx| { + this.command_manager_input + .update(cx, |input, cx| input.set_value("project-only-token", window, cx)); + }); + draw(&mut cx); + workspace.read_with(&cx, |this, cx| { + assert_eq!(this.filtered_saved_commands(cx), vec![nested.clone()]) + }); + workspace.update_in(&mut cx, |this, window, cx| { + this.enter_command_group(Some(crate::saved_commands::BUILTIN_GROUP_ID.into()), window, cx); + this.command_manager_input + .update(cx, |input, cx| input.set_value("project-only-token", window, cx)); + }); + draw(&mut cx); + workspace.read_with(&cx, |this, cx| assert!(this.command_manager_rows(cx).is_empty())); +} diff --git a/nebula_app/src/gpui_shell/workspace/command_manager/groups.rs b/nebula_app/src/gpui_shell/workspace/command_manager/groups.rs index 7059599..b27519f 100644 --- a/nebula_app/src/gpui_shell/workspace/command_manager/groups.rs +++ b/nebula_app/src/gpui_shell/workspace/command_manager/groups.rs @@ -12,6 +12,12 @@ pub(in crate::gpui_shell::workspace) struct GroupMenu { _subscription: Subscription, } +#[derive(Clone)] +pub(super) enum ManagerRow { + Command(crate::saved_commands::SavedCommand), + Folder { id: String, name: String, count: usize }, +} + #[derive(Clone)] pub(super) struct CommandDrag { id: String, @@ -55,21 +61,100 @@ impl NebulaWorkspace { groups } - pub(super) fn visible_command_groups( - &self, - commands: &[crate::saved_commands::SavedCommand], - cx: &App, - ) -> Vec<(Option, String)> { - let searching = !self.command_manager_input.read(cx).value().trim().is_empty(); - self.command_groups(cx) + pub(super) fn command_manager_rows(&self, cx: &App) -> Vec { + let input = self.command_manager_input.read(cx).value(); + let query = input.trim(); + if query.len() > MAX_SEARCH_BYTES { + return Vec::new(); + } + let mut rows = self + .filtered_saved_commands(cx) .into_iter() - .filter(|(group, _)| { - !searching - || commands.iter().any(|command| { - self.saved_commands.group_for(&command.id) == group.as_deref() + .map(ManagerRow::Command) + .collect::>(); + if self.command_manager_group.is_none() { + let available = self.available_saved_commands(cx); + let mut search = nebula_completions::command_search::CommandQuery::new(query); + for (id, name) in self.command_groups(cx) { + let Some(id) = id else { continue }; + if !query.is_empty() && search.score_fields(&[&name]).is_none() { + continue; + } + let count = available + .iter() + .filter(|command| { + self.saved_commands.group_for(&command.id) == Some(id.as_str()) }) - }) - .collect() + .count(); + rows.push(ManagerRow::Folder { id, name, count }); + } + } + rows + } + + pub(super) fn enter_command_group( + &mut self, + id: Option, + window: &mut Window, + cx: &mut Context, + ) { + if id.as_ref().is_some_and(|id| { + !self.command_groups(cx).iter().any(|(group, _)| group.as_ref() == Some(id)) + }) { + return; + } + self.command_manager_group = id; + self.command_group_menu = None; + self.command_manager_selected = 0; + self.command_manager_scroll.scroll_to_item_strict(0, gpui::ScrollStrategy::Top); + self.command_manager_input.update(cx, |input, cx| { + input.set_value("", window, cx); + input.focus(window, cx); + }); + cx.notify(); + } + + pub(super) fn render_command_group_navigation(&self, cx: &mut Context) -> AnyElement { + let language = crate::gpui_shell::config::ui_language(cx); + let name = self + .command_groups(cx) + .into_iter() + .find(|(group, _)| *group == self.command_manager_group) + .map(|(_, name)| name) + .unwrap_or_default(); + let hover = cx.theme().list_hover; + h_flex() + .id("saved-command-group-back") + .debug_selector(|| "saved-command-group-back".into()) + .w_full() + .h(px(GROUP_NAV_HEIGHT)) + .flex_shrink_0() + .px_3() + .gap_2() + .items_center() + .text_sm() + .cursor_pointer() + .hover(move |row| row.bg(hover)) + .active(move |row| row.bg(hover)) + .role(gpui::Role::Button) + .aria_label(language.text(Message::CommandsBackToRoot)) + .on_click(cx.listener(|this, _, window, cx| this.enter_command_group(None, window, cx))) + .drag_over::(move |row, _, _, _| row.bg(hover)) + .on_drop(cx.listener(|this, command: &CommandDrag, window, cx| { + cx.stop_propagation(); + this.move_command_to_group(&command.id, None, window, cx); + })) + .child(Icon::new(IconName::ChevronLeft).xsmall()) + .child( + div() + .text_color(cx.theme().muted_foreground) + .child(language.text(Message::CommandsRoot)), + ) + .child( + Icon::new(IconName::ChevronRight).xsmall().text_color(cx.theme().muted_foreground), + ) + .child(div().flex_1().min_w_0().truncate().child(name)) + .into_any_element() } pub(super) fn sort_command_groups( @@ -85,21 +170,6 @@ impl NebulaWorkspace { }); } - pub(super) fn command_scroll_index( - &self, - commands: &[crate::saved_commands::SavedCommand], - cx: &App, - ) -> usize { - let Some(command) = commands.get(self.command_manager_selected) else { return 0 }; - let headers = self - .visible_command_groups(commands, cx) - .iter() - .position(|(group, _)| group.as_deref() == self.saved_commands.group_for(&command.id)) - .unwrap_or(0) - + 1; - self.command_manager_selected + headers - } - fn finish_group_change( &mut self, result: std::io::Result<()>, @@ -110,7 +180,7 @@ impl NebulaWorkspace { match result { Ok(()) => { self.command_manager_selected = 0; - self.command_manager_scroll.scroll_to_item(0); + self.command_manager_scroll.scroll_to_item_strict(0, gpui::ScrollStrategy::Top); cx.notify(); true }, @@ -137,41 +207,81 @@ impl NebulaWorkspace { self.finish_group_change(result, window, cx); } - pub(super) fn render_command_group_header( + pub(super) fn render_command_folder( &self, - id: Option, + index: usize, + id: String, name: String, + count: usize, cx: &mut Context, ) -> AnyElement { let hover = cx.theme().list_hover; - let selected = cx.theme().list_active; - let delete_id = id.clone().filter(|id| id != BUILTIN_GROUP_ID); + let active = cx.theme().list_active; + let muted = cx.theme().muted_foreground; + let selected = index == self.command_manager_selected; + let delete_id = (id != BUILTIN_GROUP_ID).then(|| id.clone()); + let click_id = id.clone(); let language = crate::gpui_shell::config::ui_language(cx); h_flex() - .id(SharedString::from(format!( - "command-group-{}", - id.as_deref().unwrap_or("ungrouped") - ))) + .id(SharedString::from(format!("command-group-{id}"))) .debug_selector({ let id = id.clone(); - move || format!("command-group-{}", id.as_deref().unwrap_or("ungrouped")).into() + move || format!("command-group-{id}") }) .w_full() - .h(px(GROUP_HEADER_HEIGHT)) + .h(px(ROW_HEIGHT)) .flex_shrink_0() .items_center() - .gap_2() + .gap(px(crate::display::ui::tokens::space::XS)) .px_2() - .text_xs() - .text_color(cx.theme().muted_foreground) + .pr(px(18.0)) .rounded_md() - .drag_over::(move |style, _, _, _| style.bg(selected)) + .cursor_pointer() + .role(gpui::Role::Button) + .aria_label(name.clone()) + .when(selected, |row| row.bg(active)) + .when(!selected, |row| row.hover(move |row| row.bg(hover))) + .active(move |row| row.bg(active)) + .drag_over::(move |row, _, _, _| row.bg(active)) .on_drop(cx.listener(move |this, command: &CommandDrag, window, cx| { cx.stop_propagation(); - this.move_command_to_group(&command.id, id.as_deref(), window, cx); + this.move_command_to_group(&command.id, Some(&id), window, cx); })) - .child(Icon::new(IconName::Folder).xsmall()) - .child(div().flex_1().truncate().child(name)) + .on_click(cx.listener(move |this, _, window, cx| { + this.enter_command_group(Some(click_id.clone()), window, cx); + })) + .child( + div() + .debug_selector(move || format!("command-group-icon-{index}")) + .size(px(ROW_ICON_SLOT)) + .flex_shrink_0() + .flex() + .items_center() + .justify_center() + .child(Icon::new(IconName::Folder).size(px(ROW_ICON_SIZE)).text_color(muted)), + ) + .child( + v_flex() + .flex_1() + .min_w_0() + .gap(px(crate::display::ui::tokens::space::XXS)) + .child( + div() + .debug_selector(move || format!("command-group-label-{index}")) + .truncate() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .child(name), + ) + .child( + div().text_size(px(11.0)).text_color(muted).child( + language.format( + Message::CommandsGroupCount, + &[("count", &count.to_string())], + ), + ), + ), + ) .when_some(delete_id, |row, id| { row.child( Button::new(SharedString::from(format!("delete-command-group-{id}"))) @@ -186,7 +296,7 @@ impl NebulaWorkspace { })), ) }) - .hover(move |style| style.bg(hover)) + .child(Icon::new(IconName::ChevronRight).xsmall().text_color(muted)) .into_any_element() } diff --git a/nebula_app/src/gpui_shell/workspace/command_manager/rows.rs b/nebula_app/src/gpui_shell/workspace/command_manager/rows.rs new file mode 100644 index 0000000..8f7c1b4 --- /dev/null +++ b/nebula_app/src/gpui_shell/workspace/command_manager/rows.rs @@ -0,0 +1,211 @@ +//! Visible command rows, shared by folder contents and global search. +use super::*; +use gpui::AnyElement; + +impl NebulaWorkspace { + pub(super) fn render_saved_command_row( + &self, + index: usize, + command: crate::saved_commands::SavedCommand, + list_scrollable: bool, + cx: &mut Context, + ) -> AnyElement { + use crate::display::ui::tokens::{radius, space}; + let theme = cx.theme(); + let selected_bg = theme.list_active; + let hover_bg = theme.list_hover; + let foreground = theme.foreground; + let muted = theme.muted_foreground; + let border = theme.border; + let accent = theme.primary; + let mono_family = theme.mono_font_family.clone(); + let language = crate::gpui_shell::config::ui_language(cx); + let selected = index == self.command_manager_selected; + let builtin = command.id.starts_with("builtin:"); + let hover_group = SharedString::from(format!("saved-command-row-hover-{index}")); + let preview = command + .command + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect::>() + .join(" "); + let mode_label = if builtin { + language.text(crate::i18n::Message::CommandsBuiltinLabel) + } else if command.append_enter { + language.pick("运行", "Run") + } else { + language.pick("插入", "Insert") + }; + let run_tooltip = if command.append_enter { + language.pick("运行命令", "Run command") + } else { + language.pick("插入到当前终端", "Insert into current terminal") + }; + let run_icon = command_run_icon(builtin, command.append_enter); + let drag = CommandDrag::new(&command); + let menu_id = command.id.clone(); + let menu_button_id = command.id.clone(); + let run_command = command.clone(); + let row_command = command.clone(); + let copy_command = command.clone(); + let edit_id = command.id.clone(); + let delete_id = command.id.clone(); + + h_flex() + .id(SharedString::from(format!("saved-command-row-{index}"))) + .debug_selector(move || format!("saved-command-row-{index}").into()) + .on_drag(drag, |drag, _, _, cx| cx.new(|_| drag.clone())) + .on_mouse_down( + MouseButton::Right, + cx.listener(move |this, event: &MouseDownEvent, window, cx| { + cx.stop_propagation(); + this.open_command_group_menu(menu_id.clone(), event.position, window, cx); + }), + ) + .group(hover_group.clone()) + .w_full() + .h(px(ROW_HEIGHT)) + .flex_shrink_0() + .items_center() + .gap(px(space::XS)) + .px_2() + .when(list_scrollable, |row| row.pr(px(18.0))) + .rounded(px(radius::CONTROL)) + .cursor_pointer() + .when(selected, |row| row.bg(selected_bg)) + .when(!selected, |row| row.group_hover(hover_group.clone(), |row| row.bg(hover_bg))) + .on_click(cx.listener(move |this, _, window, cx| { + this.command_manager_selected = index; + this.dispatch_saved_command(row_command.clone(), window, cx); + })) + .child( + Button::new(SharedString::from(format!("saved-command-run-{index}"))) + .debug_selector(move || format!("saved-command-run-{index}")) + .icon(Icon::new(run_icon).size(px(ROW_ICON_SIZE))) + .ghost() + .xsmall() + .size(px(ROW_ICON_SLOT)) + .flex_shrink_0() + .tooltip(run_tooltip) + .on_click(cx.listener(move |this, _, window, cx| { + cx.stop_propagation(); + this.command_manager_selected = index; + this.dispatch_saved_command(run_command.clone(), window, cx); + })), + ) + .child( + v_flex() + .flex_1() + .min_w_0() + .gap(px(space::XXS)) + .child( + h_flex() + .w_full() + .min_w_0() + .gap_2() + .items_center() + .child( + div() + .debug_selector(move || format!("saved-command-label-{index}")) + .min_w_0() + .truncate() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .text_color(foreground) + .child(command.name), + ) + .child( + div() + .flex_shrink_0() + .rounded(px(radius::CHIP)) + .border_1() + .border_color(border) + .px_1() + .text_size(px(10.0)) + .text_color(if selected { accent } else { muted }) + .child(mode_label), + ), + ) + .child( + div() + .w_full() + .min_w_0() + .truncate() + .font_family(mono_family.clone()) + .text_size(px(11.0)) + .text_color(muted) + .child(preview), + ), + ) + .child( + h_flex() + .flex_shrink_0() + .items_center() + .gap_1() + .child( + Button::new(SharedString::from(format!( + "saved-command-group-menu-{index}" + ))) + .icon(IconName::EllipsisVertical) + .ghost() + .xsmall() + .tooltip(language.text(crate::i18n::Message::CommandsMoveToGroup)) + .on_click(cx.listener( + move |this, _, window, cx| { + cx.stop_propagation(); + this.open_command_group_menu( + menu_button_id.clone(), + window.mouse_position(), + window, + cx, + ); + }, + )), + ) + .child( + Button::new(SharedString::from(format!("saved-command-copy-{index}"))) + .icon(IconName::Copy) + .ghost() + .xsmall() + .tooltip(language.pick("复制命令", "Copy command")) + .on_click(cx.listener(move |this, _, window, cx| { + cx.stop_propagation(); + this.copy_saved_command(©_command, window, cx); + })), + ) + .child( + Button::new(SharedString::from(format!("saved-command-edit-{index}"))) + .icon(custom_icon(crate::gpui_shell::assets::nav::PENCIL)) + .ghost() + .xsmall() + .tooltip(if builtin { + language.text(crate::i18n::Message::CommandsSaveCopy) + } else { + language.pick("编辑命令", "Edit command") + }) + .on_click(cx.listener(move |this, _, window, cx| { + cx.stop_propagation(); + this.open_saved_command_editor(Some(edit_id.clone()), window, cx); + })), + ) + .child( + Button::new(SharedString::from(format!("saved-command-delete-{index}"))) + .icon(custom_icon(crate::gpui_shell::assets::nav::TRASH)) + .debug_selector(move || format!("saved-command-delete-{index}")) + .ghost() + .xsmall() + .tooltip(language.pick("删除命令", "Delete command")) + .on_click(cx.listener(move |this, _, window, cx| { + cx.stop_propagation(); + this.open_delete_saved_command_dialog( + delete_id.clone(), + window, + cx, + ); + })), + ), + ) + .into_any_element() + } +} diff --git a/nebula_app/src/gpui_shell/workspace/details_panel.rs b/nebula_app/src/gpui_shell/workspace/details_panel.rs index 8a733a9..588468c 100644 --- a/nebula_app/src/gpui_shell/workspace/details_panel.rs +++ b/nebula_app/src/gpui_shell/workspace/details_panel.rs @@ -14,6 +14,7 @@ const DEFAULT_WIDTH: f32 = 320.0; const MIN_WIDTH: f32 = 240.0; const MAX_WIDTH: f32 = 560.0; const RESIZE_HIT_WIDTH: f32 = 8.0; +const HEADER_CONTROL_SIZE: f32 = 32.0; pub(super) struct DetailsPanelState { pub(super) section: Option, @@ -177,12 +178,15 @@ impl NebulaWorkspace { IconName::FolderClosed, )); tabs.push((3, "side-panel-git", vcs_name, IconName::Github)); - let label_limit = - (width - 16.0 - (tabs.len() + 1) as f32 * 36.0 - tabs.len() as f32 * 2.0).max(0.0); + let label_limit = (width + - 16.0 + - (tabs.len() + 1) as f32 * HEADER_CONTROL_SIZE + - tabs.len() as f32 * 2.0) + .max(0.0); let selected = self.details_tab(cx); let previous = self.details_panel.previous_tab; let serial = self.details_panel.transition; - let background = cx.theme().list_active; + let background = cx.theme().secondary; let mut header = h_flex() .id("workspace-details-header") .debug_selector(|| "workspace-details-header".to_owned()) @@ -218,8 +222,8 @@ impl NebulaWorkspace { ); let button = Button::new(id) .ghost() - .h(px(36.0)) - .min_w(px(36.0)) + .h(px(HEADER_CONTROL_SIZE)) + .min_w(px(HEADER_CONTROL_SIZE)) .px_0() .flex_shrink_0() .overflow_hidden() @@ -231,8 +235,8 @@ impl NebulaWorkspace { .gap_0() .child( div() - .w(px(36.0)) - .h(px(36.0)) + .w(px(HEADER_CONTROL_SIZE)) + .h(px(HEADER_CONTROL_SIZE)) .flex_shrink_0() .flex() .items_center() @@ -263,7 +267,9 @@ impl NebulaWorkspace { Animation::new(Duration::from_millis(180)).with_easing(ease_out_quint()), move |button, t| { let progress = from + (to - from) * t; - button.w(px(36.0 + extra * progress)).bg(background.opacity(progress)) + button + .w(px(HEADER_CONTROL_SIZE + extra * progress)) + .bg(background.opacity(progress)) }, ); header = header.child(button); @@ -272,8 +278,9 @@ impl NebulaWorkspace { .child(div().flex_1()) .child( Button::new("workspace-details-close") + .debug_selector(|| "workspace-details-close".to_owned()) .ghost() - .size(px(36.0)) + .size(px(HEADER_CONTROL_SIZE)) .flex_shrink_0() .icon(Icon::new(IconName::PanelRightClose).size(px(16.0))) .tooltip(language.text(Message::EditorDetailsClose)) diff --git a/nebula_app/src/gpui_shell/workspace/details_panel_tests.rs b/nebula_app/src/gpui_shell/workspace/details_panel_tests.rs index 6794745..c679fe2 100644 --- a/nebula_app/src/gpui_shell/workspace/details_panel_tests.rs +++ b/nebula_app/src/gpui_shell/workspace/details_panel_tests.rs @@ -49,6 +49,76 @@ fn width_limits_keep_room_for_the_document_without_losing_preference() { assert_eq!(panel_width(500.0, 1400.0), 500.0); } +#[gpui::test] +fn file_path_edit_navigates_and_keeps_the_last_directory_on_error_or_escape( + cx: &mut TestAppContext, +) { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("sidebar.md"); + let target = directory.path().join("中文 folder"); + std::fs::write(&path, "# Heading").unwrap(); + std::fs::create_dir(&target).unwrap(); + let process_cwd = std::env::current_dir().unwrap(); + let (workspace, mut cx) = open(path, cx); + workspace.update(&mut cx, |view, cx| { + view.details_panel.section = None; + view.side_panel.view = PanelView::Files; + view.side_panel.set_custom_root(directory.path().to_path_buf()); + cx.notify(); + }); + cx.run_until_parked(); + cx.update(|window, cx| window.draw(cx).clear(cx)); + let path_button = cx.debug_bounds("file-tree-path").unwrap(); + cx.simulate_click(path_button.center(), Modifiers::default()); + cx.simulate_keystrokes("ctrl-a"); + cx.simulate_input("中文 folder"); + cx.simulate_keystrokes("enter"); + cx.run_until_parked(); + workspace.read_with(&cx, |view, _| { + assert!(view.file_tree_path.is_none()); + assert_eq!( + view.side_panel.root().unwrap().canonicalize().unwrap(), + target.canonicalize().unwrap() + ); + }); + assert_eq!(std::env::current_dir().unwrap(), process_cwd); + cx.update(|window, cx| window.draw(cx).clear(cx)); + let empty = cx.debug_bounds("file-tree-empty").unwrap(); + let message = cx.debug_bounds("file-tree-empty-message").unwrap(); + assert!((f32::from(empty.center().x - message.center().x)).abs() <= 1.0); + assert!((f32::from(empty.center().y - message.center().y)).abs() <= 1.0); + let path_button = cx.debug_bounds("file-tree-path").unwrap(); + cx.simulate_click(path_button.center(), Modifiers::default()); + cx.update(|window, cx| window.draw(cx).clear(cx)); + assert!(cx.debug_bounds("file-tree-path-editor").is_some()); + cx.simulate_keystrokes("enter"); + cx.run_until_parked(); + workspace.read_with(&cx, |view, _| { + assert!( + view.file_tree_path.is_none(), + "submitting the current directory must succeed: {:?}", + view.file_tree_path_error() + ); + }); + cx.update(|window, cx| window.draw(cx).clear(cx)); + let path_button = cx.debug_bounds("file-tree-path").unwrap(); + cx.simulate_click(path_button.center(), Modifiers::default()); + cx.simulate_keystrokes("ctrl-a"); + cx.simulate_input("missing-directory"); + cx.simulate_keystrokes("enter"); + cx.run_until_parked(); + workspace.read_with(&cx, |view, _| { + assert!(view.file_tree_path_error().is_some()); + assert_eq!( + view.side_panel.root().unwrap().canonicalize().unwrap(), + target.canonicalize().unwrap() + ); + }); + cx.simulate_keystrokes("escape"); + cx.run_until_parked(); + workspace.read_with(&cx, |view, _| assert!(view.file_tree_path.is_none())); +} + #[gpui::test] fn shared_sidebar_drag_changes_actual_geometry_and_escape_cancels(cx: &mut TestAppContext) { let directory = tempfile::tempdir().unwrap(); @@ -95,10 +165,10 @@ fn sidebar_tabs_keep_square_hit_targets_and_files_precedes_git(cx: &mut TestAppC cx.update(|window, cx| { let _ = window.draw(cx); }); - for id in ["details-info", "side-panel-files", "side-panel-git"] { + for id in ["details-info", "side-panel-files", "side-panel-git", "workspace-details-close"] { let bounds = cx.debug_bounds(id).unwrap(); - assert_eq!(bounds.size.width, px(36.0), "{id}: {bounds:?}"); - assert_eq!(bounds.size.height, px(36.0), "{id}: {bounds:?}"); + assert_eq!(bounds.size.width, px(32.0), "{id}: {bounds:?}"); + assert_eq!(bounds.size.height, px(32.0), "{id}: {bounds:?}"); } let files = cx.debug_bounds("side-panel-files").unwrap(); assert!(files.right() <= cx.debug_bounds("side-panel-git").unwrap().left()); @@ -110,8 +180,8 @@ fn sidebar_tabs_keep_square_hit_targets_and_files_precedes_git(cx: &mut TestAppC let _ = window.draw(cx); }); assert!(workspace.read_with(&cx, |view, cx| view.active_document_section(cx).is_none())); - assert_eq!(cx.debug_bounds("details-outline").unwrap().size.width, px(36.0)); - assert!(cx.debug_bounds("side-panel-files").unwrap().size.width > px(36.0)); + assert_eq!(cx.debug_bounds("details-outline").unwrap().size.width, px(32.0)); + assert!(cx.debug_bounds("side-panel-files").unwrap().size.width > px(32.0)); let selected = cx.debug_bounds("side-panel-files").unwrap().center(); cx.simulate_mouse_down(selected, MouseButton::Left, Modifiers::default()); cx.simulate_mouse_up(selected, MouseButton::Left, Modifiers::default()); diff --git a/nebula_app/src/gpui_shell/workspace/file_tree.rs b/nebula_app/src/gpui_shell/workspace/file_tree.rs index 93f13d1..4528f46 100644 --- a/nebula_app/src/gpui_shell/workspace/file_tree.rs +++ b/nebula_app/src/gpui_shell/workspace/file_tree.rs @@ -20,6 +20,9 @@ use crate::gpui_shell::prelude::*; use super::NebulaWorkspace; +mod path_bar; +pub(super) use path_bar::PathEditor; + /// 行距(旧壳 `PanelLayout::row_h`)。 pub(super) const ROW_PITCH: f32 = 34.0; /// 行水洗高度(旧壳 `row_h - 4`)。行与行之间那条缝来自水洗比行距矮, @@ -309,6 +312,8 @@ impl NebulaWorkspace { // 滚动只由 uniform_list 承担。旧壳那套行粒度 `scroll` 不再参与,否则 // `click_row` 的 `scroll + index` 会把点击算到别的行上。 self.side_panel.scroll = 0; + let path_bar = self.render_file_tree_path(cx); + let editing_path = self.file_tree_path.is_some(); let theme = cx.theme(); let muted = theme.muted_foreground; let language = super::workspace_ui_language(); @@ -422,10 +427,6 @@ impl NebulaWorkspace { ) }); let root_dir = self.side_panel.root().map(std::path::Path::to_path_buf); - let root = root_dir - .as_ref() - .map(|path| path.display().to_string()) - .unwrap_or_else(|| "等待终端上报工作目录…".to_owned()); let row_count = self.side_panel.file_rows().len(); let empty = self.file_tree_empty_state(); let scroll_handle = self.file_tree_scroll.clone(); @@ -445,16 +446,9 @@ impl NebulaWorkspace { .items_center() .gap_1() .child( - div() - .flex_1() - .min_w_0() - .text_xs() - .text_color(muted) - .overflow_hidden() - .whitespace_nowrap() - .child(root), + div().flex_1().min_w_0().child(path_bar), ) - .child( + .when(!editing_path, |bar| bar.child( Button::new("file-tree-terminal-here") .icon(IconName::SquareTerminal) .ghost() @@ -524,8 +518,11 @@ impl NebulaWorkspace { this.sync_side_panel_to_active(true, cx); cx.notify(); })), - ), + )), ) + .when_some(self.file_tree_path_error(), |panel, error| { + panel.child(div().text_xs().text_color(theme.danger).child(error.clone())) + }) .when_some(self.side_panel.localized_root_notice(crate::gpui_shell::config::ui_language(cx)), |panel, notice| { panel.child(div().text_xs().text_color(theme.warning).child(notice.to_owned())) }) @@ -535,7 +532,8 @@ impl NebulaWorkspace { // 再套 `overflow_y_scrollbar` 滚剩下的,两套模型打架:滚动条滑块 // 按剩余行算长度,滚轮又同时动两边。改成 uniform_list 虚拟化 // ——它自己就是滚动容器,滑块交给组件库 Scrollbar 读同一个 handle。 - div() + v_flex() + .debug_selector(|| "file-tree-content".into()) .flex_1() .min_h_0() .relative() @@ -553,11 +551,11 @@ impl NebulaWorkspace { }), ) .w_full() - .flex_grow_1() - // 空态时让列表收缩到内容高度(通常只剩 `..` 一行),空态 - // 文案接在它下面——旧壳也是把文案画在 `..` 行之后。 + // 返回上级仍保留一行;空态使用剩余高度居中,不挤到左上角。 .when(empty.is_some(), |list| { - list.with_sizing_behavior(gpui::ListSizingBehavior::Infer) + list.h(px(row_count as f32 * ROW_PITCH)) + .flex_shrink_0() + .with_sizing_behavior(gpui::ListSizingBehavior::Infer) }) .when(empty.is_none(), |list| { list.size_full() @@ -582,13 +580,27 @@ impl NebulaWorkspace { .when_some(empty, |list, empty| { list.child( v_flex() + .debug_selector(|| "file-tree-empty".into()) + .flex_1() + .min_h_0() .w_full() - .px(px(DRAWER_TEXT_INSET + ROW_WASH_INSET)) - .py_2() - .gap_1() - .child(div().text_xs().text_color(theme.foreground).child(empty.title)) - .child(div().text_xs().text_color(muted).child(empty.reason)) - .child(div().text_xs().text_color(muted).child(empty.action)), + .px_4() + .py_3() + .items_center() + .justify_center() + .text_center() + .child( + v_flex() + .debug_selector(|| "file-tree-empty-message".into()) + .w_full() + .max_w(px(240.0)) + .gap_2() + .items_center() + .child(Icon::new(IconName::FolderOpen).size(px(24.0)).text_color(muted)) + .child(div().w_full().text_sm().font_semibold().text_color(theme.foreground).child(empty.title)) + .child(div().w_full().text_xs().text_color(muted).child(empty.reason)) + .child(div().w_full().text_xs().text_color(muted).child(empty.action)), + ), ) }), ) diff --git a/nebula_app/src/gpui_shell/workspace/file_tree/path_bar.rs b/nebula_app/src/gpui_shell/workspace/file_tree/path_bar.rs new file mode 100644 index 0000000..149ab4a --- /dev/null +++ b/nebula_app/src/gpui_shell/workspace/file_tree/path_bar.rs @@ -0,0 +1,285 @@ +//! The path field owns only its draft. Navigation still goes through SidePanel. + +use super::*; +use crate::i18n::Message; +use gpui::{EntityId, KeyDownEvent, Task}; + +#[derive(Clone, PartialEq)] +struct PathOrigin { + view: Option, + root: Option, + wsl: Option, +} + +pub(in crate::gpui_shell::workspace) struct PathEditor { + input: Entity, + origin: PathOrigin, + error: Option, + pending: Option>, + _subscription: Subscription, +} + +fn display_path(path: &std::path::Path) -> String { + let value = path.to_string_lossy(); + if let Some(unc) = value.strip_prefix(r"\\?\UNC\") { + format!(r"\\{unc}") + } else { + value.strip_prefix(r"\\?\").unwrap_or(&value).to_owned() + } +} + +fn resolve_directory( + origin: &PathOrigin, + text: &str, +) -> std::io::Result<(PathBuf, Option)> { + let text = text.trim(); + let text = text.strip_prefix('"').and_then(|s| s.strip_suffix('"')).unwrap_or(text); + if text.is_empty() { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "Empty directory path")); + } + let (path, guest) = if let Some(wsl) = &origin.wsl { + let guest = crate::ssh_sftp::normalize_remote_path(&wsl.guest, text); + (crate::shell_detect::wsl_unc_path(&wsl.distro, &guest), Some(guest)) + } else { + let path = if text == "~" || text.starts_with("~/") || text.starts_with("~\\") { + crate::platform::dirs::home_dir() + .ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::NotFound, "Home directory unavailable") + })? + .join(text.get(2..).unwrap_or("")) + } else { + PathBuf::from(text) + }; + let path = if path.is_absolute() { + path + } else { + origin + .root + .clone() + .or_else(crate::platform::dirs::home_dir) + .ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::NotFound, + "Enter an absolute directory path", + ) + })? + .join(path) + }; + (path, None) + }; + if !std::fs::metadata(&path)?.is_dir() { + return Err(std::io::Error::new(std::io::ErrorKind::NotADirectory, "Not a directory")); + } + // 本地路径先消解 .. 和链接,避免后续“返回上级”沿未规范化的拼写走错目录。 + let path = + if guest.is_none() { PathBuf::from(display_path(&path.canonicalize()?)) } else { path }; + Ok((path, guest)) +} + +impl NebulaWorkspace { + fn file_tree_path_origin(&self) -> PathOrigin { + use super::super::WorkspaceTab; + let view = self.tabs.get(self.active).and_then(|tab| match tab { + WorkspaceTab::Terminal { .. } => tab.focused_view().map(Entity::entity_id), + WorkspaceTab::Document { view, .. } => Some(view.entity_id()), + WorkspaceTab::Code { view, .. } => Some(view.entity_id()), + WorkspaceTab::Image { view } => Some(view.entity_id()), + WorkspaceTab::Settings { view, .. } => Some(view.entity_id()), + }); + PathOrigin { + view, + root: self.side_panel.root().map(std::path::Path::to_path_buf), + wsl: self.side_panel.file_wsl_root().cloned(), + } + } + + fn begin_file_tree_path_edit(&mut self, window: &mut Window, cx: &mut Context) { + let origin = self.file_tree_path_origin(); + let value = origin + .wsl + .as_ref() + .map(|wsl| wsl.guest.clone()) + .or_else(|| origin.root.as_deref().map(display_path)) + .unwrap_or_default(); + let language = super::super::workspace_ui_language(); + let input = cx.new(|cx| { + let mut input = InputState::new(window, cx) + .placeholder(language.text(Message::FilesPathPlaceholder)); + input.set_value(value.clone(), window, cx); + input.set_selected_range(0..value.len(), cx); + input + }); + let subscription = + cx.subscribe_in(&input, window, |this, _, event, window, cx| match event { + InputEvent::PressEnter { .. } => this.commit_file_tree_path(window, cx), + InputEvent::Change => { + if let Some(edit) = this.file_tree_path.as_mut() { + edit.error = None; + } + cx.notify(); + }, + _ => {}, + }); + self.file_tree_path = Some(PathEditor { + input: input.clone(), + origin, + error: None, + pending: None, + _subscription: subscription, + }); + input.update(cx, |input, cx| input.focus(window, cx)); + cx.notify(); + } + + fn cancel_file_tree_path_edit(&mut self, window: &mut Window, cx: &mut Context) { + self.file_tree_path = None; + self.focus_active(window, cx); + cx.notify(); + } + + fn commit_file_tree_path(&mut self, window: &mut Window, cx: &mut Context) { + let Some(edit) = self.file_tree_path.as_ref().filter(|edit| edit.pending.is_none()) else { + return; + }; + let input = edit.input.clone(); + let origin = edit.origin.clone(); + let text = input.read(cx).value().to_string(); + let checked_origin = origin.clone(); + // 磁盘/UNC 检查只在提交时后台执行,输入和正常绘制都不触碰文件系统。 + let check = cx + .background_executor() + .spawn(async move { resolve_directory(&checked_origin, &text) }); + let task = cx.spawn_in(window, async move |this, cx| { + let result = check.await; + let _ = this.update_in(cx, |this, window, cx| { + if !this.file_tree_path.as_ref().is_some_and(|edit| edit.input == input) { + return; + } + // 关闭、取消、切换目录/标签后,旧校验不得改变新的浏览上下文。 + if !this.side_panel.open || this.file_tree_path_origin() != origin { + this.file_tree_path = None; + cx.notify(); + return; + } + match result { + Ok((path, guest)) => { + // WSL 的树根是 guest 标识,不是校验目录用的宿主 UNC 路径。 + let already_open = match guest.as_deref() { + Some(guest) => this + .side_panel + .file_wsl_root() + .is_some_and(|root| root.guest == guest), + None => this.side_panel.root() == Some(path.as_path()), + }; + let changed = this.side_panel.browse_directory(path, guest); + if changed || already_open { + this.side_panel.set_file_search_query(String::new()); + this.file_tree_search_input + .update(cx, |input, cx| input.set_value("", window, cx)); + this.file_tree_scroll.scroll_to_item(0, gpui::ScrollStrategy::Top); + this.file_tree_path = None; + this.focus_active(window, cx); + } else if let Some(edit) = this.file_tree_path.as_mut() { + edit.pending = None; + edit.error = Some( + super::super::workspace_ui_language() + .text(Message::FilesPathUnavailable) + .into(), + ); + } + }, + Err(error) => { + let language = super::super::workspace_ui_language(); + let edit = this.file_tree_path.as_mut().unwrap(); + edit.pending = None; + edit.error = Some( + language + .format(Message::FilesPathFailed, &[("error", &error.to_string())]), + ); + }, + } + cx.notify(); + }); + }); + self.file_tree_path.as_mut().unwrap().pending = Some(task); + cx.notify(); + } + + pub(super) fn render_file_tree_path(&mut self, cx: &mut Context) -> gpui::AnyElement { + let origin = self.file_tree_path_origin(); + if self.file_tree_path.as_ref().is_some_and(|edit| edit.origin != origin) { + self.file_tree_path = None; + } + let language = super::super::workspace_ui_language(); + let Some(edit) = self.file_tree_path.as_ref() else { + let value = origin + .wsl + .as_ref() + .map(|wsl| wsl.guest.clone()) + .or_else(|| origin.root.as_deref().map(display_path)) + .unwrap_or_else(|| language.text(Message::FilesPathChoose).to_owned()); + return Button::new("file-tree-path") + .debug_selector(|| "file-tree-path".into()) + .ghost() + .small() + .h(px(28.0)) + .w_full() + .min_w_0() + .px_1() + .overflow_hidden() + .tooltip(language.text(Message::FilesPathEdit)) + .child(div().w_full().min_w_0().truncate().text_left().text_xs().child(value)) + .on_click( + cx.listener(|this, _, window, cx| this.begin_file_tree_path_edit(window, cx)), + ) + .into_any_element(); + }; + let busy = edit.pending.is_some(); + h_flex() + .id("file-tree-path-editor") + .debug_selector(|| "file-tree-path-editor".into()) + .w_full() + .min_w_0() + .gap_1() + // 单行输入框会冒泡 Enter;提交动作必须在这里结束,避免换行回写清空全选路径。 + .on_action(|_: &gpui_component::input::Enter, _, cx| cx.stop_propagation()) + .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { + if event.keystroke.key.as_str() == "escape" { + cx.stop_propagation(); + this.cancel_file_tree_path_edit(window, cx); + } + })) + .child( + div() + .flex_1() + .min_w_0() + .child(Input::new(&edit.input).h(px(28.0)).text_size(px(12.0)).disabled(busy)), + ) + .child( + Button::new("file-tree-path-go") + .icon(IconName::Check) + .ghost() + .xsmall() + .disabled(busy) + .tooltip(language.text(Message::FilesPathOpen)) + .on_click( + cx.listener(|this, _, window, cx| this.commit_file_tree_path(window, cx)), + ), + ) + .child( + Button::new("file-tree-path-cancel") + .icon(IconName::Close) + .ghost() + .xsmall() + .tooltip(language.text(Message::CommonCancel)) + .on_click(cx.listener(|this, _, window, cx| { + this.cancel_file_tree_path_edit(window, cx) + })), + ) + .into_any_element() + } + + pub(in crate::gpui_shell::workspace) fn file_tree_path_error(&self) -> Option<&String> { + self.file_tree_path.as_ref().and_then(|edit| edit.error.as_ref()) + } +} diff --git a/nebula_app/src/gpui_shell/workspace/settings_navigation.rs b/nebula_app/src/gpui_shell/workspace/settings_navigation.rs index 4761f45..8436707 100644 --- a/nebula_app/src/gpui_shell/workspace/settings_navigation.rs +++ b/nebula_app/src/gpui_shell/workspace/settings_navigation.rs @@ -36,6 +36,7 @@ impl NebulaWorkspace { let view = cx.new(|cx| SettingsPane::new(window, cx)); let subscription = cx.subscribe_in(&view, window, Self::on_settings_event); self.settings_surface = Some((view, subscription)); + self.sync_settings_agent_logos(cx); } self.settings_tab_open = true; @@ -69,6 +70,15 @@ impl NebulaWorkspace { } } + pub(super) fn sync_settings_agent_logos(&self, cx: &mut Context) { + if let Some((view, _)) = self.settings_surface.as_ref() { + view.update(cx, |pane, cx| { + pane.set_agent_logos(&self.sidebar_logo_images); + cx.notify(); + }); + } + } + pub(super) fn toggle_settings(&mut self, window: &mut Window, cx: &mut Context) { if self.settings_open { self.close_settings(window, cx); diff --git a/nebula_app/src/gpui_shell/workspace/tab_menu.rs b/nebula_app/src/gpui_shell/workspace/tab_menu.rs index 76c6810..bf90b0d 100644 --- a/nebula_app/src/gpui_shell/workspace/tab_menu.rs +++ b/nebula_app/src/gpui_shell/workspace/tab_menu.rs @@ -106,10 +106,25 @@ impl NebulaWorkspace { let tab_count = self.tabs.len(); let retry = self.tabs[ix].focused_view().filter(|view| view.read(cx).can_retry_recovery()).cloned(); + let choose_session = self.tabs[ix] + .focused_view() + .filter(|view| view.read(cx).can_choose_recovery_session()) + .cloned(); let copy_cwd = self.tabs[ix].focused_view().map(|view| copy_working_directory_item(view, cx)); let workspace = cx.entity().downgrade(); let menu = PopupMenu::build(window, cx, move |mut menu, _window, _cx| { + if let Some(view) = choose_session { + menu = menu.item( + PopupMenuItem::new( + super::workspace_ui_language() + .text(crate::i18n::Message::SessionChooseConversation), + ) + .on_click(move |_, _, cx| { + view.update(cx, |view, cx| view.choose_recovery_session(cx)) + }), + ); + } if let Some(view) = retry { menu = menu .item( diff --git a/nebula_app/src/gpui_shell/workspace/windowing/shutdown.rs b/nebula_app/src/gpui_shell/workspace/windowing/shutdown.rs index 934a054..ca68677 100644 --- a/nebula_app/src/gpui_shell/workspace/windowing/shutdown.rs +++ b/nebula_app/src/gpui_shell/workspace/windowing/shutdown.rs @@ -37,7 +37,24 @@ fn request_quit(update: Option, cx: &mut App) .collect::>() }); let ready = super::super::closing::wait_for_session_ids(&panes, cx).await; - let mut prepared = if ready && let Some(asset) = update { + let incomplete_approved = if ready { + false + } else { + let handle = cx.update(|cx| { + cx.global::().entries.first().map(|entry| entry.handle) + }); + let approved = if let Some(handle) = handle { + super::super::closing::confirm_incomplete_session(handle, cx).await + } else { + false + }; + if !approved { + cx.update(|cx| cx.global_mut::().quit_pending = false); + return; + } + true + }; + let mut prepared = if let Some(asset) = update { let result = cx .background_executor() .spawn(async move { crate::update_download::handoff::prepare(&asset) }) @@ -69,7 +86,7 @@ fn request_quit(update: Option, cx: &mut App) _ => true, }) }); - if !ready || !identities_ready { + if !identities_ready && !incomplete_approved { abort_quit(Message::SessionIdentityPending, cx); return; } diff --git a/nebula_app/src/input/terminal_input.rs b/nebula_app/src/input/terminal_input.rs index dc6344c..0b86bf1 100644 --- a/nebula_app/src/input/terminal_input.rs +++ b/nebula_app/src/input/terminal_input.rs @@ -307,6 +307,28 @@ pub(crate) fn build_runtime_text_sequence(text: &str, _mode: TermMode) -> Vec Vec { + let mut bytes = Vec::new(); + if !text.is_empty() { + if mode.contains(TermMode::BRACKETED_PASTE) { + bytes.extend_from_slice(b"\x1b[200~"); + let normalized = text.replace("\r\n", "\r").replace('\n', "\r"); + bytes.extend_from_slice(normalized.replace("\x1b[201~", "").as_bytes()); + bytes.extend_from_slice(b"\x1b[201~"); + } else { + bytes.extend_from_slice(text.as_bytes()); + } + bytes.extend(build_runtime_sequence(RuntimeKey::Right, Default::default(), 1, mode)); + } + bytes.extend(build_runtime_sequence(RuntimeKey::Enter, Default::default(), 1, mode)); + bytes +} + fn runtime_modifiers(modifiers: RuntimeKeyModifiers) -> ModifiersState { let mut state = ModifiersState::empty(); state.set(ModifiersState::SHIFT, modifiers.shift); @@ -1196,6 +1218,45 @@ mod vt_tests { assert!(records[1].contains(";27;0;0;1_")); } + #[test] + fn runtime_submission_codex_flushes_burst_before_enter() { + assert_eq!( + build_runtime_codex_submission("继续", TermMode::empty()), + "继续\x1b[C\r".as_bytes() + ); + assert_eq!(build_runtime_codex_submission("", TermMode::empty()), b"\r"); + assert_eq!( + build_runtime_codex_submission("first\nsecond", TermMode::BRACKETED_PASTE), + b"\x1b[200~first\rsecond\x1b[201~\x1b[C\r", + ); + assert_eq!( + build_runtime_codex_submission("prompt", TermMode::APP_CURSOR), + b"prompt\x1bOC\r", + ); + } + + #[test] + fn runtime_submission_codex_large_paste_has_one_boundary_and_one_submit() { + let text = "中文\n".repeat(4096); + let bytes = build_runtime_codex_submission(&text, TermMode::BRACKETED_PASTE); + assert!(bytes.starts_with(b"\x1b[200~")); + assert!(bytes.ends_with(b"\x1b[201~\x1b[C\r")); + assert_eq!(bytes.windows(6).filter(|part| *part == b"\x1b[201~").count(), 1); + } + + #[cfg(target_os = "windows")] + #[test] + fn runtime_submission_codex_native_keys_flush_before_pressing_enter() { + let bytes = build_runtime_codex_submission("继续", TermMode::WIN32_INPUT_MODE); + let sequence = String::from_utf8(bytes).unwrap(); + let records: Vec<_> = sequence.strip_prefix("继续").unwrap().split_inclusive('_').collect(); + assert_eq!(records.len(), 4, "Right press/release then Enter press/release"); + assert!(records[0].starts_with("\x1b[39;")); + assert!(records[1].starts_with("\x1b[39;")); + assert!(records[2].starts_with("\x1b[13;")); + assert!(records[3].starts_with("\x1b[13;")); + } + #[cfg(target_os = "windows")] #[test] fn runtime_win32_text_uses_conpty_native_text_path() { diff --git a/nebula_app/src/main.rs b/nebula_app/src/main.rs index 0199ab6..eca34d0 100644 --- a/nebula_app/src/main.rs +++ b/nebula_app/src/main.rs @@ -166,6 +166,9 @@ use crate::macos::locale; use crate::polling::{IoListener, ipc}; fn main() -> Result<(), Box> { + if let Some(code) = platform::ai_session_identity::run_helper_if_requested() { + std::process::exit(code); + } // No worker threads exist yet; import the new override names for legacy readers. unsafe { platform::environment::import_environment_aliases() }; // OpenSSH AskPass reuses the GUI executable as a credential helper. It diff --git a/nebula_app/src/platform/agent_integrations.rs b/nebula_app/src/platform/agent_integrations.rs new file mode 100644 index 0000000..01d5f92 --- /dev/null +++ b/nebula_app/src/platform/agent_integrations.rs @@ -0,0 +1,210 @@ +//! Settings-facing discovery and commands. File I/O runs on a background executor. + +use std::path::{Path, PathBuf}; + +use nebula_settings::AgentHook; + +use crate::ai_agents::AgentKind; + +pub(crate) const AGENTS: [AgentKind; 9] = [ + AgentKind::Claude, + AgentKind::Codex, + AgentKind::OpenCode, + AgentKind::Cursor, + AgentKind::Kimi, + AgentKind::Pi, + AgentKind::OhMyPi, + AgentKind::Copilot, + AgentKind::Grok, +]; + +#[derive(Clone, Debug, Default)] +pub(crate) struct HookInspection { + pub config_path: Option, + pub available: bool, + pub installed: bool, + pub needs_repair: bool, + pub enabled: bool, + pub helper_missing: bool, + pub error: Option, +} + +#[derive(Clone, Debug)] +pub(crate) struct AgentIntegration { + pub agent: AgentKind, + pub executable: Option, + pub hook: Option, + pub inspection: HookInspection, +} + +pub(crate) fn hook_for(agent: AgentKind) -> Option { + match agent { + AgentKind::Claude => Some(AgentHook::Claude), + AgentKind::Codex => Some(AgentHook::Codex), + AgentKind::OpenCode => Some(AgentHook::OpenCode), + AgentKind::Pi => Some(AgentHook::Pi), + AgentKind::Copilot => Some(AgentHook::Copilot), + AgentKind::Grok => Some(AgentHook::Grok), + AgentKind::OhMyPi => Some(AgentHook::OhMyPi), + AgentKind::Cursor => Some(AgentHook::Cursor), + AgentKind::Kimi => Some(AgentHook::Kimi), + _ => None, + } +} + +pub(crate) fn inspect() -> Vec { + let dirs = executable_directories(); + AGENTS + .into_iter() + .map(|agent| { + let hook = hook_for(agent); + AgentIntegration { + agent, + executable: find_executable(agent, &dirs), + hook, + inspection: hook.map(inspect_hook).unwrap_or_default(), + } + }) + .collect() +} + +#[cfg(windows)] +fn inspect_hook(hook: AgentHook) -> HookInspection { + super::win::settings::inspect(hook) +} + +#[cfg(not(windows))] +fn inspect_hook(_: AgentHook) -> HookInspection { + HookInspection::default() +} + +#[cfg(windows)] +pub(crate) fn set_enabled(hook: AgentHook, enabled: bool) -> Result<(), String> { + super::win::settings::set_enabled(hook, enabled).map_err(|error| error.to_string()) +} + +#[cfg(not(windows))] +pub(crate) fn set_enabled(_: AgentHook, _: bool) -> Result<(), String> { + Err("Automatic hook integration is currently available on Windows only.".into()) +} + +fn executable_directories() -> Vec { + let mut paths: Vec<_> = std::env::var_os("PATH") + .map(|value| std::env::split_paths(&value).filter(|path| path.is_absolute()).collect()) + .unwrap_or_default(); + if let Some(home) = crate::platform::dirs::home_dir() { + paths.extend([ + home.join(".local/bin"), + home.join(".cargo/bin"), + home.join(".bun/bin"), + home.join(".grok/bin"), + ]); + } + if let Some(appdata) = std::env::var_os("APPDATA") { + paths.push(PathBuf::from(appdata).join("npm")); + } + if let Some(local) = std::env::var_os("LOCALAPPDATA") { + paths.push(PathBuf::from(local).join("cursor-agent")); + } + paths +} + +fn find_executable(agent: AgentKind, directories: &[PathBuf]) -> Option { + let extensions: &[&str] = + if cfg!(windows) { &[".exe", ".cmd", ".bat", ".ps1", ""] } else { &[""] }; + directories.iter().find_map(|directory| { + agent + .aliases() + .iter() + .copied() + .chain((agent == AgentKind::Grok).then_some("agent")) + .find_map(|alias| { + extensions.iter().find_map(|extension| { + let path = directory.join(format!("{alias}{extension}")); + (executable_file(&path) && executable_matches(agent, alias, &path)) + .then_some(path) + }) + }) + }) +} + +fn executable_matches(agent: AgentKind, alias: &str, path: &Path) -> bool { + if agent == AgentKind::Cursor && alias == "cursor" { + // 桌面编辑器的启动器不能证明 Cursor Agent CLI 已安装。 + return false; + } + if alias != "agent" { + return true; + } + // 多个工具使用 agent 这个通用文件名;按安装路径(包括符号链接目标)归属, + // 不能把 .grok/bin/agent 误报为 Cursor。 + let resolved = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); + let belongs = |name: &str| { + resolved + .components() + .any(|part| part.as_os_str().to_string_lossy().eq_ignore_ascii_case(name)) + }; + match agent { + AgentKind::Cursor => belongs("cursor-agent") || belongs(".cursor"), + AgentKind::Grok => belongs(".grok"), + _ => false, + } +} + +fn executable_file(path: &Path) -> bool { + let Ok(metadata) = path.metadata() else { return false }; + if !metadata.is_file() { + return false; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + metadata.permissions().mode() & 0o111 != 0 + } + #[cfg(not(unix))] + { + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn executable(directory: &Path, name: &str) -> PathBuf { + std::fs::create_dir_all(directory).unwrap(); + let path = + directory.join(if cfg!(windows) { format!("{name}.exe") } else { name.to_owned() }); + std::fs::write(&path, b"fixture").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + path + } + + #[test] + fn generic_agent_binaries_require_a_matching_installation() { + let dir = tempfile::tempdir().unwrap(); + let grok_dir = dir.path().join(".grok/bin"); + let grok = executable(&grok_dir, "agent"); + let cursor_dir = dir.path().join("cursor-agent/versions/current"); + let cursor = executable(&cursor_dir, "agent"); + let dirs = vec![grok_dir, cursor_dir]; + assert_eq!(find_executable(AgentKind::Cursor, &dirs), Some(cursor)); + assert_eq!(find_executable(AgentKind::Grok, &dirs), Some(grok)); + executable(dir.path(), "agent"); + executable(dir.path(), "cursor"); + assert_eq!(find_executable(AgentKind::Cursor, &[dir.path().to_path_buf()]), None); + let cli = executable(dir.path(), "cursor-agent"); + assert_eq!(find_executable(AgentKind::Cursor, &[dir.path().to_path_buf()]), Some(cli)); + } + + #[test] + fn configuration_directories_do_not_count_as_executables() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join("claude")).unwrap(); + assert_eq!(find_executable(AgentKind::Claude, &[dir.path().to_path_buf()]), None); + } +} diff --git a/nebula_app/src/platform/ai_session_identity.rs b/nebula_app/src/platform/ai_session_identity.rs index d0051ea..fb569b6 100644 --- a/nebula_app/src/platform/ai_session_identity.rs +++ b/nebula_app/src/platform/ai_session_identity.rs @@ -12,6 +12,17 @@ use std::path::Path; use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; +#[cfg(windows)] +mod windows; + +/// A short-lived native probe must finish before GUI, hooks or CLI setup. +pub(crate) fn run_helper_if_requested() -> Option { + #[cfg(windows)] + return windows::run_helper_if_requested(); + #[cfg(not(windows))] + None +} + const PROBE_TIMEOUT: Duration = Duration::from_secs(2); const MAX_PROBE_OUTPUT: usize = 64 * 1024; const MAX_PROBE_LINE: usize = 64 * 1024; @@ -66,6 +77,7 @@ struct ProbeRecord { #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct CodexSession { pub session_id: String, + pub session_file: String, pub cwd: Option, } @@ -73,11 +85,12 @@ pub(crate) struct CodexSession { /// /// WSL panes are inspected inside the guest because Windows Toolhelp cannot /// see Linux processes or their open session files. Linux hosts use the same -/// process metadata directly. Native Windows and macOS sessions continue to -/// use hook-reported identities; this fallback requires Linux procfs. +/// process metadata directly. Windows inspects only the pane shell's verified +/// descendants in a disposable helper, bounding potentially blocking file APIs. pub(crate) fn probe_codex_session( pane_id: u64, exec_context: Option<&crate::runtime_exec::PaneExecContext>, + shell_pid: Option, ) -> Option { let pane_id = pane_id.to_string(); let context = exec_context?; @@ -88,12 +101,13 @@ pub(crate) fn probe_codex_session( #[cfg(unix)] { + let _ = shell_pid; return probe_local_proc(&pane_id, instance); } - #[cfg(not(unix))] + #[cfg(windows)] { let _ = (pane_id, instance); - None + windows::probe(shell_pid?) } } @@ -127,10 +141,11 @@ fn parse_probe_context(output: &str) -> Option { } let context = CodexSession { session_id: id.to_owned(), + session_file: record.link, cwd: payload .get("cwd") .and_then(serde_json::Value::as_str) - .filter(|cwd| cwd.starts_with('/') && !cwd.chars().any(char::is_control)) + .filter(|cwd| valid_cwd(cwd)) .map(str::to_owned), }; if candidate.as_ref().is_some_and(|existing| existing != &context) { @@ -144,6 +159,15 @@ fn parse_probe_context(output: &str) -> Option { candidate } +fn valid_cwd(cwd: &str) -> bool { + !cwd.chars().any(char::is_control) + && (cwd.starts_with('/') + || cwd.starts_with(r"\\") + || (cwd.as_bytes().first().is_some_and(u8::is_ascii_alphabetic) + && cwd.as_bytes().get(1) == Some(&b':') + && matches!(cwd.as_bytes().get(2), Some(b'/' | b'\\')))) +} + #[cfg(test)] fn parse_probe_records(output: &str) -> Option { parse_probe_context(output).map(|context| context.session_id) @@ -378,10 +402,13 @@ fn is_rollout_link(link: &Path, codex_home: &Path) -> bool { }) } -#[cfg(unix)] fn read_first_line(path: &Path) -> std::io::Result { - use std::io::{BufRead as _, BufReader}; let file = std::fs::File::open(path)?; + read_metadata_line(file) +} + +fn read_metadata_line(file: impl Read) -> std::io::Result { + use std::io::{BufRead as _, BufReader}; let mut line = String::new(); BufReader::new(file).take(MAX_PROBE_LINE as u64).read_line(&mut line)?; if !line.ends_with(['\r', '\n']) && line.len() >= MAX_PROBE_LINE { diff --git a/nebula_app/src/platform/ai_session_identity/windows.rs b/nebula_app/src/platform/ai_session_identity/windows.rs new file mode 100644 index 0000000..19c7da6 --- /dev/null +++ b/nebula_app/src/platform/ai_session_identity/windows.rs @@ -0,0 +1,259 @@ +//! Native handle inspection is confined to a disposable copy of this process. +//! Never seek/read a duplicated live handle: it shares Codex's file position. + +use super::*; +use std::collections::HashMap; +use std::mem; +use std::os::windows::io::AsRawHandle; +use std::ptr; + +use windows_sys::Wdk::System::Threading::{NtQueryInformationProcess, ProcessHandleInformation}; +use windows_sys::Win32::Foundation::{ + CloseHandle, DUPLICATE_SAME_ACCESS, DuplicateHandle, HANDLE, STATUS_BUFFER_TOO_SMALL, + STATUS_INFO_LENGTH_MISMATCH, +}; +use windows_sys::Win32::Storage::FileSystem::{ + BY_HANDLE_FILE_INFORMATION, FILE_NAME_OPENED, FILE_TYPE_DISK, GetFileInformationByHandle, + GetFileType, GetFinalPathNameByHandleW, +}; +use windows_sys::Win32::System::Threading::{ + GetCurrentProcess, GetProcessTimes, OpenProcess, PROCESS_DUP_HANDLE, PROCESS_QUERY_INFORMATION, +}; + +const HELPER_ARG: &str = "--internal-codex-session-probe"; +const MAX_HANDLE_BYTES: usize = 2 * 1024 * 1024; + +struct OwnedHandle(HANDLE); +impl Drop for OwnedHandle { + fn drop(&mut self) { + unsafe { CloseHandle(self.0) }; + } +} + +fn open_process(pid: u32, access: u32) -> Option { + let handle = unsafe { OpenProcess(access, 0, pid) }; + (!handle.is_null()).then_some(OwnedHandle(handle)) +} + +fn creation_time(process: HANDLE) -> Option { + let mut times = [unsafe { mem::zeroed() }; 4]; + let [created, exited, kernel, user] = &mut times; + (unsafe { GetProcessTimes(process, created, exited, kernel, user) } != 0) + .then_some(u64::from(created.dwHighDateTime) << 32 | u64::from(created.dwLowDateTime)) +} + +pub(super) fn probe(shell_pid: u32) -> Option { + // Keep the actual shell object alive across helper startup. A recycled PID + // cannot silently bind this pane to a different process tree. + let shell = open_process(shell_pid, PROCESS_QUERY_INFORMATION)?; + let created = creation_time(shell.0)?; + let mut command = helper_command(shell_pid, created)?; + crate::platform::process::hidden_command(&mut command); + run_probe_command(command) +} + +fn helper_command(shell_pid: u32, created: u64) -> Option { + let mut command = Command::new(std::env::current_exe().ok()?); + #[cfg(not(test))] + command.args([HELPER_ARG, &shell_pid.to_string(), &created.to_string()]); + #[cfg(test)] + command + .args([ + "--exact", + "platform::ai_session_identity::windows::tests::probe_helper", + "--nocapture", + ]) + .env("PEBREL_TEST_PROBE_ROOT", format!("{shell_pid}:{created}")); + Some(command) +} + +pub(super) fn run_helper_if_requested() -> Option { + let mut args = std::env::args(); + args.next(); + if args.next().as_deref() != Some(HELPER_ARG) { + return None; + } + let parameters = args + .next() + .and_then(|pid| Some((pid.parse::().ok()?, args.next()?.parse::().ok()?))); + let Some((pid, created)) = parameters.filter(|_| args.next().is_none()) else { + return Some(2); + }; + Some(emit_records(pid, created)) +} + +fn emit_records(shell_pid: u32, created: u64) -> i32 { + match collect_records(shell_pid, created) { + Some(output) => { + use std::io::Write as _; + if std::io::stdout().write_all(output.as_bytes()).is_ok() { 0 } else { 1 } + }, + None => 1, + } +} + +fn collect_records(shell_pid: u32, created: u64) -> Option { + let rows = crate::platform::process_snapshot::snapshot().ok()?; + let times: HashMap<_, _> = rows.iter().map(|row| (row.pid, row.created)).collect(); + if created == 0 || times.get(&shell_pid) != Some(&created) { + return None; + } + let processes = crate::process_tree::descendants_from_rows(shell_pid, rows).ok()?; + let deadline = Instant::now() + PROBE_TIMEOUT; + let mut output = String::new(); + let mut records = 0; + for row in processes { + let name = row.executable.to_ascii_lowercase(); + if name != "codex.exe" && !(name.starts_with("codex-") && name.ends_with(".exe")) { + continue; + } + let process = open_process(row.pid, PROCESS_QUERY_INFORMATION | PROCESS_DUP_HANDLE)?; + if creation_time(process.0) != times.get(&row.pid).copied() { + return None; + } + for handle in process_handles(process.0)? { + if Instant::now() >= deadline { + return None; + } + let mut copied = ptr::null_mut(); + if unsafe { + DuplicateHandle( + process.0, + handle, + GetCurrentProcess(), + &mut copied, + 0, + 0, + DUPLICATE_SAME_ACCESS, + ) + } == 0 + { + continue; + } + let copied = OwnedHandle(copied); + if unsafe { GetFileType(copied.0) } != FILE_TYPE_DISK { + continue; + } + let Some(path) = file_path(copied.0) else { continue }; + if crate::session::codex_rollout_id(&path).is_none() { + continue; + } + // Open a separate file object, checking it still denotes the same + // file after resolving the live handle's path (rename/replacement). + let file = std::fs::File::open(&path).ok()?; + if file_identity(copied.0)? != file_identity(file.as_raw_handle())? { + return None; + } + let line = read_metadata_line(file).ok()?; + let meta: serde_json::Value = serde_json::from_str(&line).ok()?; + if meta.get("type").and_then(serde_json::Value::as_str) != Some("session_meta") { + continue; + } + // Transport identity metadata only, even if a provider adds other + // fields to its first record in a future version. + let payload = meta.get("payload").unwrap_or(&meta); + let mut safe = serde_json::Map::new(); + for key in ["id", "cwd", "source", "thread_source"] { + if let Some(value) = payload.get(key).or_else(|| meta.get(key)) { + safe.insert(key.to_owned(), value.clone()); + } + } + let metadata = serde_json::json!({"type": "session_meta", "payload": safe}); + use std::fmt::Write as _; + writeln!(output, "{}\t0\t{path}\t{metadata}", row.pid).ok()?; + records += 1; + if records >= MAX_PROBE_RECORDS || output.len() >= MAX_PROBE_OUTPUT { + return None; + } + } + } + Some(output) +} + +#[repr(C)] +#[derive(Clone, Copy)] +struct HandleEntry { + value: HANDLE, + handle_count: usize, + pointer_count: usize, + granted_access: u32, + object_type_index: u32, + attributes: u32, + reserved: u32, +} + +fn process_handles(process: HANDLE) -> Option> { + let mut storage = vec![0usize; 1024]; + loop { + let capacity = mem::size_of_val(storage.as_slice()); + let mut needed = 0; + let status = unsafe { + NtQueryInformationProcess( + process, + ProcessHandleInformation, + storage.as_mut_ptr().cast(), + capacity as u32, + &mut needed, + ) + }; + if matches!(status, STATUS_INFO_LENGTH_MISMATCH | STATUS_BUFFER_TOO_SMALL) { + let next = (needed as usize).max(capacity * 2); + if next > MAX_HANDLE_BYTES { + return None; + } + storage.resize(next.div_ceil(mem::size_of::()), 0); + continue; + } + if status < 0 + || needed as usize > capacity + || (needed as usize) < 2 * mem::size_of::() + { + return None; + } + let count = storage[0]; + let bytes = (needed as usize) - 2 * mem::size_of::(); + if count > bytes / mem::size_of::() { + return None; + } + let entries = unsafe { storage.as_ptr().add(2).cast::() }; + return Some( + (0..count) + .map(|index| unsafe { ptr::read_unaligned(entries.add(index)).value }) + .collect(), + ); + } +} + +fn file_path(handle: HANDLE) -> Option { + let mut buffer = vec![0u16; 4096]; + let length = unsafe { + GetFinalPathNameByHandleW( + handle, + buffer.as_mut_ptr(), + buffer.len() as u32, + FILE_NAME_OPENED, + ) + } as usize; + if length == 0 || length >= buffer.len() { + return None; + } + let path = String::from_utf16(&buffer[..length]).ok()?; + // Keep network paths valid while making local saved paths readable. + Some(if let Some(unc) = path.strip_prefix(r"\\?\UNC\") { + format!(r"\\{unc}") + } else { + path.strip_prefix(r"\\?\").unwrap_or(&path).to_owned() + }) +} + +fn file_identity(handle: HANDLE) -> Option<(u32, u32, u32)> { + let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { mem::zeroed() }; + (unsafe { GetFileInformationByHandle(handle, &mut info) } != 0).then_some(( + info.dwVolumeSerialNumber, + info.nFileIndexHigh, + info.nFileIndexLow, + )) +} + +#[cfg(test)] +mod tests; diff --git a/nebula_app/src/platform/ai_session_identity/windows/tests.rs b/nebula_app/src/platform/ai_session_identity/windows/tests.rs new file mode 100644 index 0000000..a63a67e --- /dev/null +++ b/nebula_app/src/platform/ai_session_identity/windows/tests.rs @@ -0,0 +1,186 @@ +use super::*; +use std::io::{Read as _, Seek as _, SeekFrom, Write as _}; +use std::os::windows::process::CommandExt as _; +use std::process::Child; + +const ID: &str = "0199a213-c2a4-7cf5-8f6b-d746fbb6e86c"; +const OTHER: &str = "0199a213-c2a4-7cf5-8f6b-d746fbb6e86d"; +const HOLDER: &str = "platform::ai_session_identity::windows::tests::hold_rollouts"; + +#[test] +fn probe_helper() { + let Ok(parameters) = std::env::var("PEBREL_TEST_PROBE_ROOT") else { return }; + if parameters == "stall" { + std::thread::sleep(Duration::from_secs(30)); + return; + } + let (pid, created) = parameters.split_once(':').unwrap(); + // Exit directly so the production parent also observes helper failure. + std::process::exit(emit_records(pid.parse().unwrap(), created.parse().unwrap())); +} + +#[test] +fn hold_rollouts() { + let Ok(paths) = std::env::var("PEBREL_TEST_ROLLOUT_FILES") else { return }; + let paths: Vec = serde_json::from_str(&paths).unwrap(); + let mut files: Vec<_> = paths + .iter() + .map(|path| { + let mut file = std::fs::File::open(path).unwrap(); + file.seek(SeekFrom::Start(7)).unwrap(); + file + }) + .collect(); + std::fs::write( + std::env::var_os("PEBREL_TEST_ROLLOUT_READY").unwrap(), + std::process::id().to_string(), + ) + .unwrap(); + let mut input = [0]; + std::io::stdin().read_exact(&mut input).unwrap(); + for file in &mut files { + assert_eq!(file.stream_position().unwrap(), 7, "probing changed the live file cursor"); + } +} + +struct Fixture { + child: Child, + worker: OwnedHandle, +} + +impl Fixture { + fn launch(directory: &Path, executable: &Path, paths: &[&Path], powershell: bool) -> Self { + let ready = directory.join("ready"); + let mut command = if powershell { + let mut command = Command::new("powershell.exe"); + command.args([ + "-NoProfile", + "-NonInteractive", + "-Command", + &format!("& $env:PEBREL_TEST_ROLLOUT_EXE --exact {HOLDER} --nocapture"), + ]); + command + } else { + let mut command = Command::new("cmd.exe"); + command + .args(["/d", "/s", "/c"]) + .raw_arg(format!("\"\"{}\" --exact {HOLDER} --nocapture\"", executable.display())); + command + }; + command + .env("PEBREL_TEST_ROLLOUT_EXE", executable) + .env("PEBREL_TEST_ROLLOUT_FILES", serde_json::to_string(paths).unwrap()) + .env("PEBREL_TEST_ROLLOUT_READY", &ready) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + crate::platform::process::hidden_command(&mut command); + let mut child = command.spawn().unwrap(); + let deadline = Instant::now() + Duration::from_secs(10); + let worker = loop { + if let Ok(pid) = std::fs::read_to_string(&ready) { + if let Ok(pid) = pid.parse() { + break open_process( + pid, + windows_sys::Win32::System::Threading::PROCESS_TERMINATE, + ) + .unwrap(); + } + } + if child.try_wait().unwrap().is_some() || Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + panic!("owned rollout fixture did not become ready"); + } + std::thread::sleep(Duration::from_millis(10)); + }; + Self { child, worker } + } + + fn finish(mut self) { + self.child.stdin.take().unwrap().write_all(b"x\n").unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if let Some(status) = self.child.try_wait().unwrap() { + assert!(status.success(), "the live file cursor must be unchanged"); + return; + } + assert!(Instant::now() < deadline, "fixture did not exit"); + std::thread::sleep(Duration::from_millis(10)); + } + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + unsafe { windows_sys::Win32::System::Threading::TerminateProcess(self.worker.0, 1) }; + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +fn rollout( + directory: &Path, + filename_id: &str, + metadata_id: &str, + source: &str, +) -> std::path::PathBuf { + let path = directory.join(format!("rollout-date-{filename_id}.jsonl")); + let meta = serde_json::json!({"type":"session_meta", "payload": { + "id": metadata_id, "cwd": directory, "source": source, + "thread_source": "user", "private_context": "do not transport", + }}); + std::fs::write(&path, format!("{meta}\n{{\"conversation\":\"do not read\"}}\n")).unwrap(); + path +} + +#[test] +fn native_shells_bind_only_their_own_open_rollout_and_keep_the_id_and_file_cursor() { + let temp = tempfile::tempdir_in(std::env::current_exe().unwrap().parent().unwrap()).unwrap(); + let executable = temp.path().join("codex.exe"); + std::fs::hard_link(std::env::current_exe().unwrap(), &executable).unwrap(); + let file = rollout(temp.path(), ID, ID, "cli"); + let second = tempfile::tempdir().unwrap(); + let other = rollout(second.path(), OTHER, OTHER, "cli"); + let unrelated = Fixture::launch(second.path(), &executable, &[&other], false); + for powershell in [false, true] { + let root = tempfile::tempdir().unwrap(); + let fixture = Fixture::launch(root.path(), &executable, &[&file], powershell); + let found = probe(fixture.child.id()).expect("read the owned CMD/PowerShell child rollout"); + assert_eq!(found.session_id, ID); + assert_eq!(Path::new(&found.session_file), file); + assert_eq!(found.cwd.as_deref(), temp.path().to_str()); + let shell = open_process(fixture.child.id(), PROCESS_QUERY_INFORMATION).unwrap(); + let output = collect_records(fixture.child.id(), creation_time(shell.0).unwrap()).unwrap(); + assert!(!output.contains("do not")); + assert_eq!(collect_records(fixture.child.id(), 1), None, "reject recycled shell identity"); + fixture.finish(); + } + unrelated.finish(); +} + +#[test] +fn native_probe_rejects_ambiguous_and_mismatched_rollouts_without_changing_them() { + let temp = tempfile::tempdir_in(std::env::current_exe().unwrap().parent().unwrap()).unwrap(); + let executable = temp.path().join("codex.exe"); + std::fs::hard_link(std::env::current_exe().unwrap(), &executable).unwrap(); + let first = rollout(temp.path(), ID, ID, "cli"); + let second = rollout(temp.path(), OTHER, OTHER, "cli"); + let fixture = Fixture::launch(temp.path(), &executable, &[&first, &second], false); + assert!(probe(fixture.child.id()).is_none()); + fixture.finish(); + std::fs::remove_file(temp.path().join("ready")).unwrap(); + rollout(temp.path(), ID, OTHER, "cli"); + let fixture = Fixture::launch(temp.path(), &executable, &[&first], false); + assert!(probe(fixture.child.id()).is_none()); + fixture.finish(); +} + +#[test] +fn native_helper_is_terminated_at_the_probe_deadline() { + let mut command = helper_command(0, 0).unwrap(); + command.env("PEBREL_TEST_PROBE_ROOT", "stall"); + let started = Instant::now(); + assert!(run_probe_command(command).is_none()); + assert!(started.elapsed() < Duration::from_secs(5)); +} diff --git a/nebula_app/src/process_tree.rs b/nebula_app/src/process_tree.rs index b4287f0..7cdd438 100644 --- a/nebula_app/src/process_tree.rs +++ b/nebula_app/src/process_tree.rs @@ -153,11 +153,18 @@ fn verified_parentage(rows: Vec) -> std::collections::HashMap Result, String> { + descendants_from_rows(root_pid, process_snapshot::snapshot()?) +} + +pub(crate) fn descendants_from_rows( + root_pid: u32, + rows: Vec, +) -> Result, String> { if root_pid == 0 { return Err("the pane does not own a local shell process".to_owned()); } - descendants_from_snapshot(root_pid, &snapshot()?) + descendants_from_snapshot(root_pid, &verified_parentage(rows)) } fn descendants_from_snapshot( diff --git a/nebula_app/src/runtime_api/cli.rs b/nebula_app/src/runtime_api/cli.rs index ea2267c..0c3394f 100644 --- a/nebula_app/src/runtime_api/cli.rs +++ b/nebula_app/src/runtime_api/cli.rs @@ -25,6 +25,15 @@ pub(super) fn request_once( let endpoint = read_endpoint() .ok_or_else(|| CliError::new("runtime_unavailable", "no resident Pebrel runtime found"))?; let request = ApiRequest::new(endpoint.token.clone(), method, params); + read_response(&endpoint, &request, timeout) + .map_err(|error| input_transport_error(method, error)) +} + +fn read_response( + endpoint: &Endpoint, + request: &ApiRequest, + timeout: Duration, +) -> Result> { let stream = client_stream(&endpoint, &request, Some(timeout))?; let mut line = String::new(); BufReader::new(stream).read_line(&mut line)?; @@ -38,6 +47,34 @@ pub(super) fn request_once( Ok(serde_json::from_str(&line)?) } +fn input_transport_error(method: &str, error: Box) -> Box { + if matches!( + method, + "pane.prompt" | "pane.paste" | "agent.prompt" | "agent.paste" | "agent.delegate" + ) { + CliError::new( + "submission_outcome_unknown", + format!( + "{method} did not return a confirmed response: {error}. Input may already have \ + reached the target; read its state and output before retrying." + ), + ) + .into() + } else { + error + } +} + +pub(super) fn require_submission_baseline(baseline: Option) -> Result { + baseline.filter(|seq| *seq > 0).ok_or_else(|| { + CliError::new( + "runtime_no_response", + "input was accepted but the response has no valid state baseline; completion is \ + unconfirmed. Read the target before retrying; do not resend automatically.", + ) + }) +} + pub(super) fn print_response(response: &ApiResponse, pretty: bool) -> Result<(), Box> { write_cli_response(&mut std::io::stdout().lock(), response, pretty) } @@ -341,8 +378,16 @@ fn run_cli_inner(options: ControlOptions) -> Result<(), Box> { // The prompt response carries the snapshot taken immediately after // submission. Using its counter as the baseline is what makes the // follow-up wait mean "settled again", not "already settled". - let baseline = pane_state_change_seq(&response, window, pane); - wait_cli(window, pane, wait.expect("checked above"), baseline, timeout, options.pretty) + let baseline = + require_submission_baseline(pane_state_change_seq(&response, window, pane))?; + wait_cli( + window, + pane, + wait.expect("checked above"), + Some(baseline), + timeout, + options.pretty, + ) }, CliCommand::Paste { window, pane, text, no_submit, wait } => { let response = request_once( @@ -358,8 +403,16 @@ fn run_cli_inner(options: ControlOptions) -> Result<(), Box> { if !response.ok || wait.is_none() { return print_response(&response, options.pretty); } - let baseline = pane_state_change_seq(&response, window, pane); - wait_cli(window, pane, wait.expect("checked above"), baseline, timeout, options.pretty) + let baseline = + require_submission_baseline(pane_state_change_seq(&response, window, pane))?; + wait_cli( + window, + pane, + wait.expect("checked above"), + Some(baseline), + timeout, + options.pretty, + ) }, CliCommand::Read { window, pane, lines } => { let response = request_once( @@ -539,6 +592,33 @@ impl Error for CliError {} mod output_tests { use super::*; + #[test] + fn runtime_submission_missing_or_zero_baseline_cannot_wait_on_old_idle() { + for baseline in [None, Some(0)] { + let error = require_submission_baseline(baseline).unwrap_err(); + assert_eq!(error.code(), "runtime_no_response"); + assert!(error.to_string().contains("do not resend automatically")); + } + assert_eq!(require_submission_baseline(Some(42)).unwrap(), 42); + } + + #[test] + fn runtime_submission_transport_failure_is_not_a_safe_retry_signal() { + for method in ["pane.prompt", "pane.paste", "agent.prompt", "agent.paste", "agent.delegate"] + { + let error = + input_transport_error(method, IoError::from(std::io::ErrorKind::TimedOut).into()); + assert_eq!( + error.downcast_ref::().unwrap().code(), + "submission_outcome_unknown" + ); + assert!(error.to_string().contains("read its state and output before retrying")); + } + let error = + input_transport_error("pane.read", IoError::from(std::io::ErrorKind::TimedOut).into()); + assert_eq!(error.downcast_ref::().unwrap().kind(), std::io::ErrorKind::TimedOut); + } + struct FailingOutput { error: Option, fail_flush: bool, diff --git a/nebula_app/src/runtime_api/shortcuts.rs b/nebula_app/src/runtime_api/shortcuts.rs index 248236a..5953e33 100644 --- a/nebula_app/src/runtime_api/shortcuts.rs +++ b/nebula_app/src/runtime_api/shortcuts.rs @@ -29,7 +29,10 @@ //! 同一个 `request_once`、同一套 `ApiResponse` 信封、同样的 generation / //! `after_seq` 竞态保护。短的只是命令行,不是语义。 -use super::cli::{CliError, PrintedCliError, print_response, request_once, wait_state_name}; +use super::cli::{ + CliError, PrintedCliError, print_response, request_once, require_submission_baseline, + wait_state_name, +}; use super::*; use crate::cli::{ AgentCommand, AgentDelegateOptions, AgentOptions, AgentPasteOptions, AgentReadOptions, @@ -504,6 +507,7 @@ fn pane_read(options: PaneReadOptions) -> Result<(), Box> { fn pane_send(options: PaneSendOptions) -> Result<(), Box> { let timeout = validated_timeout(options.timeout_ms)?; + let wait_timeout = submission_wait_timeout(options.wait, options.wait_timeout_ms, timeout)?; let response = request_once( "pane.prompt", json!({ @@ -517,19 +521,14 @@ fn pane_send(options: PaneSendOptions) -> Result<(), Box> { if !response.ok || !options.wait { return print_response(&response, options.output.pretty); } - let wait_timeout = validated_timeout(options.wait_timeout_ms)?; // 基线取自提交后的那张快照。用它当 `after_seq` 才让随后的等待意味着 // "又静下来了",而不是"本来就是静的"——这正是把提交前的 idle 误判成 // "已完成"的那个经典竞态。 - let Some(baseline) = dispatched_state_change_seq(&response, options.window, Some(options.pane)) - else { - return Err(CliError::new( - "runtime_no_response", - "the line was submitted but the response carried no pane state baseline; retry the \ - wait with the state_change_seq from `pebrel pane list`", - ) - .into()); - }; + let baseline = require_submission_baseline(dispatched_state_change_seq( + &response, + options.window, + Some(options.pane), + ))?; let response = request_once( "pane.wait", json!({ @@ -546,6 +545,7 @@ fn pane_send(options: PaneSendOptions) -> Result<(), Box> { fn pane_paste(options: PanePasteOptions) -> Result<(), Box> { let timeout = validated_timeout(options.timeout_ms)?; + let wait_timeout = submission_wait_timeout(options.wait, options.wait_timeout_ms, timeout)?; let text = read_paste_source(options.source)?; let response = request_once( "pane.paste", @@ -560,16 +560,11 @@ fn pane_paste(options: PanePasteOptions) -> Result<(), Box> { if !response.ok || !options.wait { return print_response(&response, options.output.pretty); } - let wait_timeout = validated_timeout(options.wait_timeout_ms)?; - let Some(baseline) = dispatched_state_change_seq(&response, options.window, Some(options.pane)) - else { - return Err(CliError::new( - "runtime_no_response", - "the paste was submitted but the response carried no pane state baseline; retry the \ - wait with the state_change_seq from `pebrel pane list`", - ) - .into()); - }; + let baseline = require_submission_baseline(dispatched_state_change_seq( + &response, + options.window, + Some(options.pane), + ))?; let response = request_once( "pane.wait", json!({ @@ -608,6 +603,7 @@ fn agent_list(options: ListOptions) -> Result<(), Box> { fn agent_send(options: AgentSendOptions) -> Result<(), Box> { let timeout = validated_timeout(options.timeout_ms)?; + let wait_timeout = submission_wait_timeout(options.wait, options.wait_timeout_ms, timeout)?; let response = request_once( "agent.prompt", json!({ @@ -621,15 +617,7 @@ fn agent_send(options: AgentSendOptions) -> Result<(), Box> { if !response.ok || !options.wait { return print_response(&response, options.output.pretty); } - let wait_timeout = validated_timeout(options.wait_timeout_ms)?; - let Some(baseline) = dispatched_state_change_seq(&response, None, None) else { - return Err(CliError::new( - "runtime_no_response", - "the task was delivered but the response carried no agent state baseline; re-resolve \ - the target with `pebrel agent list` before waiting", - ) - .into()); - }; + let baseline = require_submission_baseline(dispatched_state_change_seq(&response, None, None))?; // `agent.wait` 要求显式代际——这是"别把另一个会话的静默当成这次任务完成"的 // 那道锁。派发成功的响应正常都带它;真取不到时明确报错,而不是送一个 `null` // 让服务端回一句不知所云的 `invalid_params`。 @@ -681,6 +669,7 @@ fn agent_delegate(options: AgentDelegateOptions) -> Result<(), Box> { fn agent_paste(options: AgentPasteOptions) -> Result<(), Box> { let timeout = validated_timeout(options.timeout_ms)?; + let wait_timeout = submission_wait_timeout(options.wait, options.wait_timeout_ms, timeout)?; let text = read_paste_source(options.source)?; let response = request_once( "agent.paste", @@ -695,15 +684,7 @@ fn agent_paste(options: AgentPasteOptions) -> Result<(), Box> { if !response.ok || !options.wait { return print_response(&response, options.output.pretty); } - let wait_timeout = validated_timeout(options.wait_timeout_ms)?; - let Some(baseline) = dispatched_state_change_seq(&response, None, None) else { - return Err(CliError::new( - "runtime_no_response", - "the paste was delivered but the response carried no agent state baseline; \ - re-resolve the target with `pebrel agent list` before waiting", - ) - .into()); - }; + let baseline = require_submission_baseline(dispatched_state_change_seq(&response, None, None))?; let Some(generation) = options.generation.or_else(|| dispatched_generation(&response)) else { return Err(CliError::new( "runtime_no_response", @@ -872,6 +853,16 @@ fn dispatched_generation(response: &ApiResponse) -> Option { result.get("action").unwrap_or(result).get("agent")?.get("generation")?.as_u64() } +/// Validate before writing input (and before reading a potentially blocking stdin +/// paste). An invalid wait option must not leave a submitted task behind. +fn submission_wait_timeout( + wait: bool, + timeout_ms: u64, + request_timeout: Duration, +) -> Result> { + if wait { validated_timeout(timeout_ms) } else { Ok(request_timeout) } +} + fn validated_timeout(timeout_ms: u64) -> Result> { if timeout_ms == 0 || Duration::from_millis(timeout_ms) > MAX_WAIT { return Err(CliError::new( @@ -949,6 +940,37 @@ mod tests { assert!(validated_timeout(1).is_ok()); } + #[test] + fn runtime_submission_rejects_invalid_wait_before_opening_paste_source() { + use clap::Parser as _; + for resource in ["pane", "agent"] { + let options = crate::cli::Options::try_parse_from([ + "pebrel", + resource, + "paste", + "17", + "--from-file", + "pebrel-test-missing-paste-file", + "--wait", + "--wait-timeout-ms", + "0", + ]) + .unwrap(); + let error = match options.subcommands.unwrap() { + crate::cli::Subcommands::Pane(options) => match options.command { + PaneCommand::Paste(options) => pane_paste(options).unwrap_err(), + _ => panic!("expected pane paste"), + }, + crate::cli::Subcommands::Agent(options) => match options.command { + AgentCommand::Paste(options) => agent_paste(options).unwrap_err(), + _ => panic!("expected agent paste"), + }, + _ => panic!("expected resource command"), + }; + assert_eq!(error.downcast_ref::().unwrap().code(), "invalid_params"); + } + } + #[test] fn snapshot_extraction_accepts_direct_and_action_results() { let snapshot = snapshot_with_pane(17, 41); diff --git a/nebula_app/src/saved_commands.rs b/nebula_app/src/saved_commands.rs index 440f76b..689c256 100644 --- a/nebula_app/src/saved_commands.rs +++ b/nebula_app/src/saved_commands.rs @@ -122,6 +122,16 @@ impl SavedCommands { name: &str, command: &str, append_enter: bool, + ) -> io::Result { + self.insert_in_group(name, command, append_enter, None) + } + + pub(crate) fn insert_in_group( + &mut self, + name: &str, + command: &str, + append_enter: bool, + group: Option<&str>, ) -> io::Result { let path = self.path.clone(); let (next, inserted) = mutate_store(&path, |store| { @@ -139,6 +149,9 @@ impl SavedCommands { saved.id = new_id(&saved.name, &saved.command); } commands.push(saved.clone()); + if group.is_some() { + store.assign_group(&saved.id, group)?; + } Ok(saved) })?; *self = next; diff --git a/nebula_app/src/saved_commands/groups.rs b/nebula_app/src/saved_commands/groups.rs index b76d0b3..0be4b1a 100644 --- a/nebula_app/src/saved_commands/groups.rs +++ b/nebula_app/src/saved_commands/groups.rs @@ -64,7 +64,7 @@ impl SavedCommands { Ok(()) } - fn assign_group(&mut self, command_id: &str, group: Option<&str>) -> io::Result<()> { + pub(super) fn assign_group(&mut self, command_id: &str, group: Option<&str>) -> io::Result<()> { if !self.has_command(command_id) || !self.has_group(group) { return Err(io::Error::new( io::ErrorKind::NotFound, @@ -137,6 +137,24 @@ impl SavedCommands { mod tests { use super::*; + #[test] + fn inserting_inside_a_folder_is_atomic_and_rejects_a_deleted_folder() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(STORE_FILE); + let mut saved = SavedCommands::load_from(&path).unwrap(); + saved.create_group("Work").unwrap(); + let group = saved.groups()[0].id.clone(); + let command = saved.insert_in_group("Build", "cargo build", true, Some(&group)).unwrap(); + assert_eq!( + SavedCommands::load_from(&path).unwrap().group_for(&command.id), + Some(group.as_str()) + ); + saved.remove_group(&group).unwrap(); + let before = std::fs::read(&path).unwrap(); + assert!(saved.insert_in_group("Lost", "echo lost", false, Some(&group)).is_err()); + assert_eq!(std::fs::read(&path).unwrap(), before); + } + #[test] fn old_store_defaults_and_explicit_builtin_removal_survive_reload() { let dir = tempfile::tempdir().unwrap(); diff --git a/nebula_app/src/window_context/runtime.rs b/nebula_app/src/window_context/runtime.rs index 100d75c..1c126a3 100644 --- a/nebula_app/src/window_context/runtime.rs +++ b/nebula_app/src/window_context/runtime.rs @@ -391,10 +391,16 @@ impl WindowContext { "the pane is still committing previous runtime input", )); } - let recognized_agent = submit && runtime_agent(pane).is_some(); + let agent = runtime_agent(pane); + let recognized_agent = submit && agent.is_some(); + let codex_submit = submit && agent.as_ref().is_some_and(|agent| agent.kind == "codex"); let mode = *pane.terminal.lock().mode(); - let mut bytes = crate::input::terminal_input::build_runtime_text_sequence(&text, mode); - if submit { + let mut bytes = if codex_submit { + crate::input::terminal_input::build_runtime_codex_submission(&text, mode) + } else { + crate::input::terminal_input::build_runtime_text_sequence(&text, mode) + }; + if submit && !codex_submit { let submit_bytes = runtime_key_sequence(pane, RuntimeKey::Enter, RuntimeKeyModifiers::default(), 1)?; if text.is_empty() { @@ -475,7 +481,10 @@ impl WindowContext { let mut bytes = b"\x1b[200~".to_vec(); bytes.extend_from_slice(normalized.replace("\x1b[201~", "").as_bytes()); bytes.extend_from_slice(b"\x1b[201~"); - if submit { + let codex_submit = submit && runtime_agent(pane).is_some_and(|agent| agent.kind == "codex"); + if codex_submit { + bytes = crate::input::terminal_input::build_runtime_codex_submission(&text, mode); + } else if submit { let submit_bytes = runtime_key_sequence(pane, RuntimeKey::Enter, RuntimeKeyModifiers::default(), 1)?; pane.nebula_state.runtime_submit_barrier = diff --git a/nebula_hook/src/main.rs b/nebula_hook/src/main.rs index 4f53de6..1c6b9f6 100644 --- a/nebula_hook/src/main.rs +++ b/nebula_hook/src/main.rs @@ -61,6 +61,7 @@ const FOREIGN_HOOK_RUNNERS: &[&str] = &[ // 了早期的 GROK_SESSION_ID:后者已不再导出,只在被插值进 hook 命令时才 // 解析得到——如果当初的门写在那个变量上,今天就是一扇不会响的门。 "GROK_HOOK_NAME", + "GROK_HOOK_EVENT", ]; /// 当前进程是否由别家 agent 的 hook runner 启动。 @@ -165,7 +166,14 @@ fn log_outcome(source: &str, pane: &str, bytes: usize, outcome: &Outcome) { fn main() { // Constraint 1: never leak a failure to the calling CLI. - let _ = std::panic::catch_unwind(run); + let args: Vec = std::env::args().skip(1).collect(); + let _ = std::panic::catch_unwind(|| run(&args)); + // Cursor 的提交前 Hook 有响应合同;传输失败也不能阻止用户提交。 + if args.first().is_some_and(|source| source == "cursor") + && native_event(&args) == Some("prompt") + { + let _ = std::io::stdout().lock().write_all(b"{\"continue\":true}\n"); + } } fn read_payload(mut reader: impl Read) -> std::io::Result>> { @@ -182,7 +190,10 @@ fn read_payload(mut reader: impl Read) -> std::io::Result>> { /// 已知调用方白名单。不在名单里的第一参数视为误调用:约束 2 要求在 Nebula 之外 /// 也必须是无声 no-op。新增 provider 时同步在 `payload_on_stdin` 声明载荷通道。 fn known_source(source: &str) -> bool { - matches!(source, "claude" | "codex" | "opencode" | "pi" | "kimi") + matches!( + source, + "claude" | "codex" | "opencode" | "pi" | "kimi" | "omp" | "copilot" | "grok" | "cursor" + ) } /// 载荷通道。claude 与 kimi 都把事件 JSON 写到我们的 stdin(kimi 的 `[[hooks]]` @@ -190,7 +201,29 @@ fn known_source(source: &str) -> bool { /// 无论如何都抽干管道(约束 1:未读的管道会在 CLI 侧变成 hook write error)。 /// 其余 CLI 把载荷追加为末位参数。 fn payload_on_stdin(source: &str) -> bool { - matches!(source, "claude" | "kimi") + matches!(source, "claude" | "kimi" | "copilot" | "grok" | "cursor") +} + +fn native_event(args: &[String]) -> Option<&str> { + if args.len() != 3 + || !matches!(args[0].as_str(), "copilot" | "grok" | "cursor") + || args[1] != "--event" + { + return None; + } + let event = args[2].as_str(); + matches!( + event, + "session-start" + | "session-end" + | "prompt" + | "tool-complete" + | "done" + | "failed" + | "error" + | "notification" + ) + .then_some(event) } /// 侧信道信封:一行 `nebula-hook/1 source= pane=

` 头加原始载荷。helper 不重 @@ -201,8 +234,7 @@ fn envelope(source: &str, pane: &str, contract: &str, payload: &[u8]) -> Vec message } -fn run() { - let args: Vec = std::env::args().skip(1).collect(); +fn run(args: &[String]) { let Some(source) = args.first().filter(|s| known_source(s)) else { return; }; @@ -225,9 +257,8 @@ fn run() { // 串台门。放在读完 stdin 之后:约束 1 要求 stdin 模式的 source 无论如何都把 // stdin 抽干(未读的管道会在 CLI 侧变成 hook write error),所以先读再退。 - // kimi 的 hook 只装在它自己的 config.toml 里,别家 runner 不读那份配置, - // 没有串台路径,这道门保持 claude 专属。 - if source == "claude" && foreign_hook_runner().is_some() { + // Grok 同时读取 Claude 与 Cursor 配置;这些借用的入口不能认领 Grok 会话。 + if matches!(source.as_str(), "claude" | "cursor") && foreign_hook_runner().is_some() { log_outcome(source, "", payload.len(), &Outcome::ForeignRunner); return; } @@ -235,6 +266,9 @@ fn run() { let pane = hook_env("PANE_ID").and_then(|value| value.into_string().ok()).unwrap_or_default(); let contract = if native_codex { format!(" codex_hooks={}", args[1].strip_prefix("--hooks=").unwrap()) + } else if matches!(source.as_str(), "copilot" | "grok" | "cursor") { + let Some(event) = native_event(args) else { return }; + format!(" event={event}") } else { String::new() }; @@ -378,6 +412,19 @@ mod tests { assert_eq!(base64_encode(b"a"), "YQ=="); } + #[test] + fn native_event_contract_accepts_only_known_sources_and_single_header_fields() { + let args = |source: &str, event: &str| vec![source.into(), "--event".into(), event.into()]; + for source in ["copilot", "grok", "cursor"] { + assert!(super::known_source(source) && super::payload_on_stdin(source)); + assert_eq!(super::native_event(&args(source, "prompt")), Some("prompt")); + assert_eq!(super::native_event(&args(source, "done\npane=9")), None); + assert_eq!(super::native_event(&args(source, "unknown")), None); + } + assert_eq!(super::native_event(&args("kimi", "done")), None); + assert!(super::known_source("omp") && !super::payload_on_stdin("omp")); + } + /// 这道门是承重的:命中任一别家 runner 的变量就必须闭合。空值不算命中—— /// 有些 runner 会把变量导出成空串。 /// diff --git a/nebula_settings/src/agent_hooks.rs b/nebula_settings/src/agent_hooks.rs new file mode 100644 index 0000000..a47931b --- /dev/null +++ b/nebula_settings/src/agent_hooks.rs @@ -0,0 +1,117 @@ +//! Per-agent hook preferences. Missing keys inherit the historical global switch. + +use crate::RawSettings; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AgentHook { + Claude, + Codex, + OpenCode, + Pi, + Copilot, + Grok, + OhMyPi, + Cursor, + Kimi, +} + +impl AgentHook { + pub const DEFAULT: [Self; 2] = [Self::Claude, Self::Codex]; + + pub const ALL: [Self; 9] = [ + Self::Claude, + Self::Codex, + Self::OpenCode, + Self::Pi, + Self::Copilot, + Self::Grok, + Self::OhMyPi, + Self::Cursor, + Self::Kimi, + ]; + + pub const fn settings_key(self) -> &'static str { + match self { + Self::Claude => "ai_hooks_claude", + Self::Codex => "ai_hooks_codex", + Self::OpenCode => "ai_hooks_opencode", + Self::Pi => "ai_hooks_pi", + Self::Copilot => "ai_hooks_copilot", + Self::Grok => "ai_hooks_grok", + Self::OhMyPi => "ai_hooks_omp", + Self::Cursor => "ai_hooks_cursor", + Self::Kimi => "ai_hooks_kimi", + } + } + + pub fn enabled(self, raw: &RawSettings) -> bool { + let legacy_default = Self::DEFAULT.contains(&self); + raw.bool_on(self.settings_key()) + .unwrap_or_else(|| legacy_default && raw.bool_on("ai_hooks").unwrap_or(true)) + } + + /// Explicit CLI setup/removal applies to all agents, including UI overrides. + pub fn all_updates(enabled: bool) -> Vec<(&'static str, String)> { + let value = if enabled { "1" } else { "0" }; + std::iter::once(("ai_hooks", value.to_owned())) + .chain(Self::ALL.map(|agent| (agent.settings_key(), value.to_owned()))) + .collect() + } + + /// 安装器/CLI 恢复默认的两个入口;其他 Agent 保留用户在菜单中的选择。 + pub fn setup_updates() -> Vec<(&'static str, String)> { + std::iter::once(("ai_hooks", "1".to_owned())) + .chain(Self::DEFAULT.map(|agent| (agent.settings_key(), "1".to_owned()))) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::apply_updates; + + #[test] + fn legacy_global_setting_and_individual_overrides_round_trip() { + for agent in AgentHook::ALL { + assert_eq!(agent.enabled(&RawSettings::default()), AgentHook::DEFAULT.contains(&agent)); + assert!(!agent.enabled(&RawSettings::from_text("ai_hooks=0\n"))); + } + let original = "# keep\nai_hooks=0\nshell=zsh\ncustom=untouched\n"; + let changed = apply_updates(original, &[(AgentHook::Codex.settings_key(), "1".into())]); + let raw = RawSettings::from_text(&changed); + assert!(AgentHook::Codex.enabled(&raw)); + assert!(!AgentHook::Claude.enabled(&raw)); + assert!(changed.starts_with(original)); + let disabled = apply_updates(&changed, &AgentHook::all_updates(false)); + for agent in AgentHook::ALL { + assert!(!agent.enabled(&RawSettings::from_text(&disabled))); + } + let enabled = apply_updates(&disabled, &AgentHook::all_updates(true)); + for agent in AgentHook::ALL { + assert!(agent.enabled(&RawSettings::from_text(&enabled))); + } + } + + #[test] + fn disabling_one_agent_preserves_the_others() { + let raw = RawSettings::from_text("ai_hooks_claude=off\nai_hooks_codex=invalid\n"); + assert!(!AgentHook::Claude.enabled(&raw)); + assert!(AgentHook::Codex.enabled(&raw)); + for agent in [AgentHook::OpenCode, AgentHook::Pi, AgentHook::Kimi] { + assert!(!agent.enabled(&raw)); + } + } + + #[test] + fn explicit_setup_enables_only_defaults_and_preserves_optional_choices() { + let text = apply_updates( + "ai_hooks=0\nai_hooks_grok=1\nai_hooks_pi=0\n", + &AgentHook::setup_updates(), + ); + let raw = RawSettings::from_text(&text); + assert!(AgentHook::Claude.enabled(&raw) && AgentHook::Codex.enabled(&raw)); + assert!(AgentHook::Grok.enabled(&raw)); + assert!(!AgentHook::Pi.enabled(&raw) && !AgentHook::Kimi.enabled(&raw)); + } +} diff --git a/nebula_settings/src/lib.rs b/nebula_settings/src/lib.rs index 2df4bc6..f4d4eb1 100644 --- a/nebula_settings/src/lib.rs +++ b/nebula_settings/src/lib.rs @@ -15,6 +15,8 @@ use std::collections::HashMap; +mod agent_hooks; +pub use agent_hooks::AgentHook; mod app_icon; pub use app_icon::{AppIconName, AppIconPalette}; mod custom_theme; @@ -55,9 +57,16 @@ pub struct RawSettings { impl RawSettings { pub fn load() -> Self { - std::fs::read_to_string(settings_path()) - .map(|text| Self::from_text(&text)) - .unwrap_or_default() + Self::try_load().unwrap_or_default() + } + + /// 安装/卸载等授权路径不能把读取失败当成默认开启。 + pub fn try_load() -> std::io::Result { + match std::fs::read_to_string(settings_path()) { + Ok(text) => Ok(Self::from_text(&text)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()), + Err(error) => Err(error), + } } pub fn from_text(text: &str) -> Self { @@ -96,7 +105,11 @@ impl RawSettings { /// 写并存时后写者胜——与旧壳多窗口的既有语义一致。 pub fn persist_keys(updates: &[(&str, String)]) -> std::io::Result<()> { let path = settings_path(); - let text = std::fs::read_to_string(&path).unwrap_or_default(); + let text = match std::fs::read_to_string(&path) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(error) => return Err(error), + }; let updated = apply_updates(&text, updates); std::fs::create_dir_all(settings_dir())?; std::fs::write(&path, updated) diff --git a/nebula_settings/src/reset.rs b/nebula_settings/src/reset.rs index cad23d8..494818d 100644 --- a/nebula_settings/src/reset.rs +++ b/nebula_settings/src/reset.rs @@ -129,6 +129,16 @@ mod tests { use super::*; use crate::{RawSettings, RuntimeSettings}; + #[test] + fn reset_preserves_explicit_hook_authorization_and_opt_out() { + let text = "ai_hooks=0\nai_hooks_claude=0\nai_hooks_codex=1\nai_hooks_grok=1\n"; + let restored = RawSettings::from_text(&default_settings_text(text)); + let original = RawSettings::from_text(text); + for agent in crate::AgentHook::ALL { + assert_eq!(agent.enabled(&original), agent.enabled(&restored)); + } + } + #[test] fn reset_restores_scrolling_defaults_and_preserves_unknown_keys() { let restored = diff --git a/scripts/tests/pi-notification-bridge.test.mjs b/scripts/tests/pi-notification-bridge.test.mjs index 58ad17f..52ac865 100644 --- a/scripts/tests/pi-notification-bridge.test.mjs +++ b/scripts/tests/pi-notification-bridge.test.mjs @@ -27,7 +27,7 @@ async function bridge() { loadPiModule: async () => ({ VERSION: "0.85.1" }), spawn: (_exe, args) => { sent.push(JSON.parse(args[1])); - return { unref() {} }; + return { on() {}, unref() {} }; }, }); return { diff --git a/scripts/tests/test_pi_bridge.mjs b/scripts/tests/test_pi_bridge.mjs index 9409116..0fe1af0 100644 --- a/scripts/tests/test_pi_bridge.mjs +++ b/scripts/tests/test_pi_bridge.mjs @@ -5,6 +5,7 @@ import { join } from 'node:path'; import { stripTypeScriptTypes } from 'node:module'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { test, after } from 'node:test'; +import { spawnSync } from 'node:child_process'; const root = new URL('../../', import.meta.url); const bridgeSource = new URL('nebula_app/src/ai_hook/bridges.rs', root); @@ -14,7 +15,7 @@ const embedded = readFileSync(new URL(bridgePath, bridgeSource), 'utf8'); const events = []; globalThis.__pebrelBridgeSpawn = (_hook, args) => { events.push(JSON.parse(args[1])); - return { unref() {} }; + return { on() {}, unref() {} }; }; globalThis.__pebrelPiVersion = '0.85.1'; globalThis.__pebrelLoadPiModule = async () => ({ VERSION: globalThis.__pebrelPiVersion }); @@ -29,6 +30,25 @@ const bridge = await import('data:text/javascript;base64,' + Buffer.from(executa mkdirSync(new URL('tmp/', root), { recursive: true }); const directory = mkdtempSync(fileURLToPath(new URL('tmp/pi-bridge-', root))); after(() => rmSync(directory, { recursive: true })); + +test('a missing helper fails silently without terminating the agent process', () => { + const asset = join(directory, 'missing-helper-bridge.mjs'); + writeFileSync(asset, stripTypeScriptTypes(embedded)); + const child = spawnSync(process.execPath, ['--input-type=module', '-e', ` + const bridge = await import(${JSON.stringify(pathToFileURL(asset).href)}); + const callbacks = new Map(); + await bridge.default({ on: (event, callback) => callbacks.set(event, callback) }); + await callbacks.get('agent_start')({}, {}); + await new Promise(resolve => setTimeout(resolve, 20)); + console.log('agent-alive'); + `], { + encoding: 'utf8', windowsHide: true, + env: { ...process.env, PEBREL_HOOK_EXE: join(directory, 'missing-helper.exe') }, + }); + assert.equal(child.status, 0, child.stderr); + assert.match(child.stdout, /agent-alive/); + assert.doesNotMatch(child.stderr, /Unhandled|ENOENT/); +}); const context = (id, file) => ({ sessionManager: { getSessionId: () => id, getSessionFile: () => file }, });