diff --git a/architecture/notes/nebula_app/platform/notifications/2026-09-20-actionable-notification-lifetime.md b/architecture/notes/nebula_app/platform/notifications/2026-09-20-actionable-notification-lifetime.md new file mode 100644 index 0000000..87eb585 --- /dev/null +++ b/architecture/notes/nebula_app/platform/notifications/2026-09-20-actionable-notification-lifetime.md @@ -0,0 +1,76 @@ +# Notification actions share live confirmation identity + +## Status + +Proposed for review with implementation. + +## Context + +Completion notifications covered more cases than questions and failures. The +shell exit code was discarded before notification selection. Confirmation buttons +recognized only a trailing Y/n prompt and wrote text plus bare CR in one batch. +Native Windows notifications had no answer actions and did not retain their native +object beyond the delivery call. + +## Evidence + +`terminal/view/runtime.rs` already receives the shell exit code. Codex 0.154.0's +request_user_input handler submits the current question when a numbered option is +pressed; an extra Enter can submit the next question. Its approval UI advertises +configured one-key shortcuts. The terminal's shared keyboard encoder already +supports legacy VT, kitty and ConPTY control-key protocols. + +## Decision + +Retain up to 64 native notifications on one MTA worker with a bounded request +queue. Native action callbacks enqueue pane/request/choice events onto the UI +thread. Both in-app and native buttons resolve the same live confirmation before +writing input. Source pane, session, complete visible prompt, terminal mode and +request generation prevent old buttons from answering a different request. + +A short bounded retry allows hook delivery to precede the interactive form's +render. Completion, new requests, manual input and view destruction cancel stale +work. Numbered Codex questions send a single advertised choice; approval buttons +use their displayed shortcut. Binary text and Enter are separate writes; a +lifecycle change or intervening input cancels the pending Enter. Multi-question +forms can publish the next question once a new frame is visible. + +Short nonzero command exits produce a failure notification. Successful commands +retain the duration threshold, and hooked Agents retain their completion owner. +Each actionable request has its own throttle identity so answering one question +does not suppress a different question from the same pane. + +## Rejected alternatives + +- Unconditionally send `Y\r`: wrong for numbered choices and negotiated input. +- Add Enter after a Codex digit: can act on the next question. +- Trust only the original notification's text: stale OS notifications can survive + a session, prompt or terminal-mode change. +- Keep one worker per toast: duplicates thread and COM lifetime management. +- Assume showing a toast proves its click callback survives: separate lifetimes. + +## Consequences + +Unknown/freeform forms open the source pane without guessing an answer. Native +Windows toasts display at most five actions. The bounded retained set limits +activation support for very old Action Center entries; terminal state validation +is still required for every delivered callback. UI queue acceptance is not proof +that an arbitrary third-party CLI accepted an answer. + +## Validation + +State tests cover stale requests, displayed shortcuts, unsupported forms and +single-question submission. GPUI terminal tests inspect actual PTY writes under +legacy and negotiated keyboard modes, cancellation and repeated clicks. Native +XML tests reject terminal controls/markup injection. Native Windows notification +click, foreground policy, visual layout and packaged-runtime acceptance remain +platform integration checks and are not established by compilation alone. + +## Supersedes + +None. + +## Revisit when + +Providers expose a documented structured answer transport or the platform offers +an owned cross-platform notification activation service. diff --git a/architecture/notes/nebula_app/platform/wsl_hooks/2026-09-20-wsl-hook-transport.md b/architecture/notes/nebula_app/platform/wsl_hooks/2026-09-20-wsl-hook-transport.md new file mode 100644 index 0000000..8499dcb --- /dev/null +++ b/architecture/notes/nebula_app/platform/wsl_hooks/2026-09-20-wsl-hook-transport.md @@ -0,0 +1,72 @@ +# WSL command hooks use the guest home and authenticated pane transport + +## Status + +Proposed for review with implementation. + +## Context + +A Windows terminal can launch Codex inside WSL. Windows hook installation does +not configure the guest home, discover its CLI version, or deliver native Linux +hook events through a Windows named pipe. Completion-only installation and screen +observations therefore remained active even when Windows reported native hooks. + +## Evidence + +`ai_hook/win.rs` installs host configuration. `terminal/session.rs` launches WSL +as a local PTY. The shared lifecycle already gives full native hooks precedence +and has no authority to infer that a separate host's installation covers WSL. +Codex can render compaction and queued input above its ordinary prompt row. + +## Decision + +Reuse `ai_hook/remote.rs` file planning and its POSIX adapter for WSL command hooks. +A bounded worker targets the original distribution and user without replacing the +interactive shell. Each PTY receives a fresh random token through WSLENV; its +stream parser checks that token before forwarding the existing OSC hook envelope. +Linux process identity remains separate from host PIDs. + +Guest CLI discovery first respects PATH, then probes bounded conventional user +installation locations. Discovery can select a fallback NVM version when shell +initialization is unavailable; it does not claim to execute arbitrary shell +configuration. Explicit `setup-ai --wsl` permits repair/removal through the same +planner. Provider opt-out, unrelated hooks and notify chains remain preserved. + +Claude question tools and terminal errors enter the shared lifecycle. Failed, +incomplete and cancelled outcomes do not display successful completion. Screen +fallback recognizes Codex queue/compaction chrome only where lifecycle coverage +is absent; it cannot override a native running turn. + +## Rejected alternatives + +- Reuse Windows hook paths inside WSL: wrong executable and configuration domain. +- Infer completion from output silence: tools and compaction can be silent. +- Overwrite guest settings: loses custom hooks, trust and user opt-out. +- Start a poller per pane: unbounded process and thread cost. +- Install plugin adapters without their startup environment: incomplete delivery. + +## Consequences + +One worker owns guest setup with an eight-second budget, bounded queue and retry +cache. Installation is asynchronous; an already-running CLI does not gain hooks +retroactively. Codex may require review in `/hooks`, and a fresh PTY is necessary +for its channel token. A CLI launched before initial setup finishes may require +restart. Unsupported/missing Python or customized owned assets cause a logged +failure while preserving configuration and screen fallback. + +## Validation + +Planner tests cover guest paths, custom notify preservation and repeated setup. +Python tests exercise PATH/NVM discovery, actual PTY delivery and transactional +writes. Stream tests check token rejection and every frame split. Lifecycle tests +cover queued input, compaction, question/tool-error/end-error distinctions. +Windows WSL delivery and hook trust still require integration acceptance. + +## Supersedes + +None; extends the existing SSH installation and native Codex contracts. + +## Revisit when + +WSL supplies a provider-neutral guest integration service, native hooks gain a +portable host transport, or plugin adapters can use the same command transport. diff --git a/nebula_app/Cargo.toml b/nebula_app/Cargo.toml index 563a69f..32a886a 100644 --- a/nebula_app/Cargo.toml +++ b/nebula_app/Cargo.toml @@ -193,7 +193,7 @@ objc2-app-kit = { version = "0.3.1", default-features = false, features = [ [target.'cfg(windows)'.dependencies] windows-core = "=0.61.2" -windows = { version = "=0.61.3", features = ["Data_Pdf", "Storage", "Storage_Streams", "Foundation", "Win32_System_WinRT", "Win32_Graphics_Gdi", "Win32_System_Com", "Win32_System_Com_StructuredStorage", "Win32_System_DataExchange", "Win32_System_Memory", "Win32_System_Ole", "Win32_System_SystemServices", "Win32_UI_Shell", "Win32_UI_Shell_PropertiesSystem", "Win32_System_Variant", "Win32_Storage_FileSystem", "Win32_UI_Input_KeyboardAndMouse"] } +windows = { version = "=0.61.3", features = ["Data_Xml_Dom", "UI_Notifications", "Data_Pdf", "Storage", "Storage_Streams", "Foundation", "Win32_System_WinRT", "Win32_Graphics_Gdi", "Win32_System_Com", "Win32_System_Com_StructuredStorage", "Win32_System_DataExchange", "Win32_System_Memory", "Win32_System_Ole", "Win32_System_SystemServices", "Win32_UI_Shell", "Win32_UI_Shell_PropertiesSystem", "Win32_System_Variant", "Win32_Storage_FileSystem", "Win32_UI_Input_KeyboardAndMouse"] } dirs = "6.0.0" dwrote = "0.11.5" # Registry reads for installed-shell detection (WSL distros under Lxss, Git / diff --git a/nebula_app/i18n/en-US.json b/nebula_app/i18n/en-US.json index 6ccb62d..0fddf7b 100644 --- a/nebula_app/i18n/en-US.json +++ b/nebula_app/i18n/en-US.json @@ -861,5 +861,7 @@ }, "ai_hooks": { "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" } diff --git a/nebula_app/i18n/zh-CN.json b/nebula_app/i18n/zh-CN.json index acc63c9..e328de9 100644 --- a/nebula_app/i18n/zh-CN.json +++ b/nebula_app/i18n/zh-CN.json @@ -861,5 +861,7 @@ }, "ai_hooks": { "announced": "已接入 AI 回合通知(Claude / Codex / Kimi / Pi / opencode)。撤销:pebrel setup-ai --remove" - } + }, + "notification.command_finished": "命令结束,用时 {seconds} 秒", + "notification.command_failed": "命令失败(退出码 {code}),用时 {seconds} 秒" } diff --git a/nebula_app/i18n/zh-TW.json b/nebula_app/i18n/zh-TW.json index eb43c10..9955aa1 100644 --- a/nebula_app/i18n/zh-TW.json +++ b/nebula_app/i18n/zh-TW.json @@ -176,5 +176,7 @@ }, "ai_hooks": { "announced": "已接入 AI 回合通知(Claude / Codex / Kimi / Pi / opencode)。撤銷:pebrel setup-ai --remove" - } + }, + "notification.command_finished": "命令結束,耗時 {seconds} 秒", + "notification.command_failed": "命令失敗(結束代碼 {code}),耗時 {seconds} 秒" } diff --git a/nebula_app/res/hooks/remote_bridge.py b/nebula_app/res/hooks/remote_bridge.py index f5db893..7a55f8d 100644 --- a/nebula_app/res/hooks/remote_bridge.py +++ b/nebula_app/res/hooks/remote_bridge.py @@ -147,8 +147,8 @@ def send(source, native, mode, payload, token): if len(envelope) > MAX_ENVELOPE: # Lifecycle must still arrive when a tool or answer is huge. Keep # protocol identities and drop content, without inventing a result. - keep = ("hook_event_name", "type", "kind", "session_id", "thread-id", "turn_id", "turn-id", "source", "bridge_sequence", "event_id", "notification_type", "permission_mode", "stop_reason", "background_tasks", "agent_id", "agent_type") - question_input = payload.get("tool_input") if payload.get("tool_name") == "request_user_input" else None + keep = ("hook_event_name", "type", "kind", "session_id", "thread-id", "turn_id", "turn-id", "source", "bridge_sequence", "event_id", "notification_type", "permission_mode", "stop_reason", "error", "background_tasks", "agent_id", "agent_type") + question_input = payload.get("tool_input") if payload.get("tool_name") in ("request_user_input", "AskUserQuestion") else None tool_name = payload.get("tool_name") payload = {key: payload[key] for key in keep if key in payload} if question_input is not None: diff --git a/nebula_app/res/hooks/remote_files.py b/nebula_app/res/hooks/remote_files.py index b3fd8fe..dd1023e 100644 --- a/nebula_app/res/hooks/remote_files.py +++ b/nebula_app/res/hooks/remote_files.py @@ -9,6 +9,7 @@ import hashlib import json import os from pathlib import Path +import re import shutil import subprocess import sys @@ -39,15 +40,44 @@ def capture(program, *args): return "" try: with tempfile.TemporaryFile() as output: + env = dict(os.environ) + # Node-managed launchers use /usr/bin/env node. The interpreter + # beside the discovered launcher must be reachable too. + env["PATH"] = str(Path(program).parent) + os.pathsep + env.get("PATH", "") subprocess.run([program, *args], stdin=subprocess.DEVNULL, stdout=output, stderr=subprocess.DEVNULL, - timeout=2, check=True) + timeout=2, check=True, env=env) output.seek(0) return output.read(65536).decode("utf-8", "replace") except (OSError, subprocess.SubprocessError): return "" +def find_program(name): + found = shutil.which(name) + if found: + return found + home = Path.home() + candidates = [home / ".local/bin" / name, home / ".npm-global/bin" / name] + nvm = Path(os.environ.get("NVM_DIR", home / ".nvm")) + candidates.append(nvm / "current/bin" / name) + # Noninteractive login shells commonly skip nvm initialization. Probe + # executable files, not shell output, with a bounded version-directory scan. + versions = nvm / "versions/node" + try: + entries = [] + for i, entry in enumerate(versions.iterdir()): + if i >= 128: + break + version = re.fullmatch(r"v(\d+)\.(\d+)\.(\d+)", entry.name) + if version: + entries.append((tuple(map(int, version.groups())), entry)) + candidates.extend(entry / "bin" / name for _, entry in sorted(entries, reverse=True)) + except OSError: + pass + return next((str(path) for path in candidates if path.is_file() and os.access(path, os.X_OK)), None) + + def locations(): home = Path.home() root = Path(os.environ.get("XDG_DATA_HOME", home / ".local/share")) / "pebrel/ai" @@ -76,7 +106,7 @@ def snapshot(): "path": str(path), "sha256": digest(content), "content": content.decode("utf-8") if content is not None else None, } - codex = shutil.which("codex") + codex = find_program("codex") return { "version": 1, "root": str(root), "python": sys.executable, "shell": os.environ.get("SHELL", "/bin/sh"), "files": files, diff --git a/nebula_app/src/agent_detection/codex.toml b/nebula_app/src/agent_detection/codex.toml index 96108fb..9dc3711 100644 --- a/nebula_app/src/agent_detection/codex.toml +++ b/nebula_app/src/agent_detection/codex.toml @@ -30,8 +30,11 @@ any = [ id = "working_status" state = "working" priority = 400 -region = "bottom_non_empty_lines(4)" -line_regex = ['^[•◦]\s+Working \([^)]*esc to interrupt\)(?: · .*)?$'] +region = "bottom_non_empty_lines(16)" +any = [ + { line_regex = ['^[•◦]\s+(?:Working|Compacting context) \([^)]*esc to interrupt\)(?: · .*)?$'] }, + { line_regex = ['^[•◦]\s+Messages to be submitted after next tool call \(press esc to interrupt and send immediately\)$'] }, +] [[rules]] id = "prompt_idle" diff --git a/nebula_app/src/ai_agents.rs b/nebula_app/src/ai_agents.rs index 5f78343..ecfbf1c 100644 --- a/nebula_app/src/ai_agents.rs +++ b/nebula_app/src/ai_agents.rs @@ -1060,6 +1060,24 @@ mod tests { assert_eq!(detection.status, AgentStatus::Blocked, "rule={}", detection.rule_id); } + #[test] + fn codex_queue_and_compaction_are_live_work_only_in_the_chrome_tail() { + for chrome in [ + "• Working (12s · esc to interrupt)\n• Messages to be submitted after next tool call (press esc to interrupt and send immediately)\n ↳ Continue the task", + "• Compacting context (1m 41s · esc to interrupt)\n └ Making room to continue.", + ] { + let frame = format!("{chrome}\n› Ask Codex to do anything\ngpt-6 max · /project"); + assert_eq!(detect("codex", &frame).unwrap().status, AgentStatus::Working); + let stale = format!( + "{chrome}\n{}› Ask Codex to do anything\ngpt-6 max · /project", + "completed output\n".repeat(20) + ); + assert_eq!(detect("codex", &stale).unwrap().status, AgentStatus::Idle); + } + let quoted = "The log said Compacting context; Messages to be submitted after next tool call\n› Ask Codex to do anything\ngpt-6 max · /project"; + assert_eq!(detect("codex", quoted).unwrap().status, AgentStatus::Idle); + } + #[test] fn real_codex_idle_chrome_reads_idle() { // codex 早已答完,屏幕上就是空闲输入框。这块屏幕当初根本没人去匹: diff --git a/nebula_app/src/ai_hook.rs b/nebula_app/src/ai_hook.rs index e179e01..d50fc39 100644 --- a/nebula_app/src/ai_hook.rs +++ b/nebula_app/src/ai_hook.rs @@ -63,13 +63,16 @@ const HELPER_ARGS: [&str; 1] = ["claude"]; /// Claude hook events we subscribe to. Session boundaries carry the id needed /// for resume/fork; PostToolUse lets a stale permission state return to working /// before the whole turn completes. -const CLAUDE_EVENTS: [&str; 7] = [ +const CLAUDE_EVENTS: [&str; 10] = [ "SessionStart", "UserPromptSubmit", "Notification", "PermissionRequest", + "PreToolUse", "PostToolUse", + "PostToolUseFailure", "Stop", + "StopFailure", "SessionEnd", ]; diff --git a/nebula_app/src/ai_hook/lifecycle.rs b/nebula_app/src/ai_hook/lifecycle.rs index 60f730d..3b2719c 100644 --- a/nebula_app/src/ai_hook/lifecycle.rs +++ b/nebula_app/src/ai_hook/lifecycle.rs @@ -280,11 +280,13 @@ impl AgentActivity { }, AiHookKind::TurnDone => { self.screen_armed = false; - if event.source == "pi" - && !matches!( - event.turn_outcome, - super::AiTurnOutcome::Succeeded | super::AiTurnOutcome::Unspecified - ) + if matches!( + event.turn_outcome, + super::AiTurnOutcome::Failed + | super::AiTurnOutcome::Incomplete + | super::AiTurnOutcome::Cancelled + ) || (event.source == "pi" + && event.turn_outcome == super::AiTurnOutcome::Unknown) { AgentStatus::Idle } else { diff --git a/nebula_app/src/ai_hook/native_tests.rs b/nebula_app/src/ai_hook/native_tests.rs index 499e48a..5f5505a 100644 --- a/nebula_app/src/ai_hook/native_tests.rs +++ b/nebula_app/src/ai_hook/native_tests.rs @@ -77,7 +77,7 @@ fn native_codex_owns_start_permission_recovery_interrupt_and_end() { ("UserPromptSubmit", AgentStatus::Working), ("PermissionRequest", AgentStatus::Blocked), ("PostToolUse", AgentStatus::Working), - ("Interrupt", AgentStatus::Done), + ("Interrupt", AgentStatus::Idle), ] { let hook = codex(event, "native", Some("turn1")); assert!(hook.capabilities().lifecycle && hook.capabilities().attention_events); @@ -245,3 +245,65 @@ fn kimi_failure_and_interrupt_do_not_announce_success() { } } } + +#[test] +fn queued_input_and_compaction_keep_native_turn_running() { + let screens = [ + "• Working (12s · esc to interrupt)\n• Messages to be submitted after next tool call (press esc to interrupt and send immediately)\n ↳ Continue the task\n› Ask Codex to do anything\ngpt-6 max · /project", + "• Compacting context (1m 41s · esc to interrupt)\n └ Making room to continue.\n› Ask Codex to do anything\ngpt-6 max · /project", + "› Ask Codex to do anything\ngpt-6 max · /project", + ]; + let mut activity = AgentActivity::default(); + activity.apply_hook(&codex("UserPromptSubmit", "main", Some("turn"))); + activity.input_sent(); + for screen in screens { + for _ in 0..8 { + assert!(!activity.observe_screen(crate::ai_agents::detect("codex", screen))); + assert_eq!(activity.status(), AgentStatus::Working); + } + } + let compact = native( + "full", + None, + json!({ + "hook_event_name":"SessionStart", "session_id":"main", "source":"compact" + }), + ); + activity.apply_hook(&compact); + assert_eq!(activity.status(), AgentStatus::Working); + activity.apply_hook(&codex("Stop", "main", Some("turn"))); + assert_eq!(activity.status(), AgentStatus::Done); +} + +#[test] +fn claude_question_tool_failure_and_terminal_failure_have_distinct_effects() { + let mut activity = AgentActivity::default(); + let parse = |payload: Value| { + parse_remote_envelope(format!("nebula-hook/1 source=claude\n{payload}").as_bytes(), Some(1)) + .unwrap() + }; + let question = parse(json!({"hook_event_name":"PreToolUse", "tool_name":"AskUserQuestion", + "tool_input":{"questions":[{"question":"Choose a scope","options":[{"label":"Current"}]}]}})); + assert_eq!(question.kind, AiHookKind::NeedsAttention); + activity.apply_hook(&question); + assert_eq!(activity.status(), AgentStatus::Blocked); + let tool_failure = parse( + json!({"hook_event_name":"PostToolUseFailure", "tool_name":"Bash", "error":"exit 1"}), + ); + activity.apply_hook(&tool_failure); + assert_eq!( + activity.status(), + AgentStatus::Working, + "the agent can recover from a tool failure" + ); + let failed = parse(json!({"hook_event_name":"StopFailure", "error":"rate_limit"})); + activity.apply_hook(&failed); + assert_eq!(activity.status(), AgentStatus::Idle); + assert_eq!(failed.turn_outcome, AiTurnOutcome::Failed); + let notification = + crate::notify::Notification::from_ai_hook(&failed, failed.message.clone(), false).unwrap(); + assert!(notification.is_failure()); + let success = parse(json!({"hook_event_name":"Stop"})); + activity.apply_hook(&success); + assert_eq!(activity.status(), AgentStatus::Done); +} diff --git a/nebula_app/src/ai_hook/protocol.rs b/nebula_app/src/ai_hook/protocol.rs index 975cbae..5da0a34 100644 --- a/nebula_app/src/ai_hook/protocol.rs +++ b/nebula_app/src/ai_hook/protocol.rs @@ -98,8 +98,16 @@ pub(super) fn parse_envelope(bytes: &[u8]) -> Option { "claude" => match payload.get("hook_event_name").and_then(Value::as_str) { Some("SessionStart") => (AiHookKind::SessionStart, None), Some("UserPromptSubmit") => (AiHookKind::PromptSubmit, None), - Some("PostToolUse") => (AiHookKind::ToolComplete, None), + Some("PreToolUse") + if payload.get("tool_name").and_then(Value::as_str) == Some("AskUserQuestion") => + { + (AiHookKind::NeedsAttention, attention_message(&payload)) + }, + Some("PreToolUse" | "PostToolUse" | "PostToolUseFailure") => { + (AiHookKind::ToolComplete, None) + }, Some("Stop") => (AiHookKind::TurnDone, None), + Some("StopFailure") => (AiHookKind::TurnDone, context_string(&payload, &["error"])), Some("SessionEnd") => (AiHookKind::SessionEnd, None), // `Notification` 覆盖「权限询问」和「idle 提醒」两类,将来也可能 // 用来传别的东西。类型不可操作时丢掉,读不到类型时照常上报。 @@ -180,7 +188,9 @@ pub(super) fn parse_envelope(bytes: &[u8]) -> Option { if source == "codex" && kind == AiHookKind::TurnDone && event_id.is_none() { event_id = turn_id.as_ref().map(|id| format!("codex:turn:{id}:done")); } - let turn_outcome = if source == "kimi" && kind == AiHookKind::TurnDone { + let turn_outcome = if matches!(source.as_str(), "claude" | "kimi") + && kind == AiHookKind::TurnDone + { match payload.get("hook_event_name").and_then(Value::as_str) { Some("Stop") => AiTurnOutcome::Succeeded, Some("Interrupt") => AiTurnOutcome::Cancelled, @@ -299,11 +309,11 @@ fn unix_time_ms() -> u64 { .map_or(0, |duration| duration.as_millis().min(u128::from(u64::MAX)) as u64) } -/// 远端会话只能提交事件语义,Pane 身份始终由本地 SSH 通道覆盖, +/// 远端会话只能提交事件语义,Pane 身份始终由本地 PTY 通道覆盖, /// 防止远端载荷把通知路由到同一窗口中的其他标签页。 pub(crate) fn parse_remote_envelope(bytes: &[u8], pane: Option) -> Option { let mut event = parse_envelope(bytes)?; - // Only this entrypoint is reached after SSH token verification. Ignore + // Only this entrypoint is reached after SSH/WSL token verification. Ignore // claimed process metadata on the local named-pipe path. let header = std::str::from_utf8(bytes.split(|byte| *byte == b'\n').next()?).ok()?; event.remote_process = header.split_whitespace().find_map(|field| { diff --git a/nebula_app/src/ai_hook/remote.rs b/nebula_app/src/ai_hook/remote.rs index 8e50c53..c865abc 100644 --- a/nebula_app/src/ai_hook/remote.rs +++ b/nebula_app/src/ai_hook/remote.rs @@ -1,4 +1,4 @@ -//! SSH installation policy. This module plans owned file edits; it never opens +//! POSIX hook installation policy. This module plans owned file edits; it never opens //! a connection, changes a terminal, or interprets screen output. use std::collections::BTreeMap; @@ -12,6 +12,18 @@ use super::{CLAUDE_EVENTS, bridges, installation}; pub(crate) const FILE_ADAPTER: &str = include_str!("../../res/hooks/remote_files.py"); const BRIDGE: &str = include_str!("../../res/hooks/remote_bridge.py"); const SHELL: &str = include_str!("../../res/hooks/remote_shell.py"); +pub(crate) const TOKEN_ENV: &str = "PEBREL_REMOTE_HOOK_TOKEN"; + +pub(crate) fn new_token() -> std::io::Result { + use std::fmt::Write as _; + let mut bytes = [0u8; 16]; + getrandom::fill(&mut bytes).map_err(|error| std::io::Error::other(error.to_string()))?; + let mut token = String::with_capacity(32); + for byte in bytes { + let _ = write!(token, "{byte:02x}"); + } + Ok(token) +} #[derive(Debug, Deserialize)] pub(crate) struct Snapshot { @@ -99,6 +111,13 @@ pub(crate) fn response(raw: &str) -> Result { } impl Snapshot { + /// WSL keeps its normal login shell. Only command hooks have a complete + /// transport there; plugin adapters currently need the SSH bootstrap env. + pub(crate) fn for_wsl(mut self) -> Self { + self.providers.retain(|name, _| matches!(name.as_str(), "claude" | "codex")); + self + } + fn file(&self, name: &str) -> Result<&File, String> { self.files.get(name).ok_or_else(|| format!("missing integration file: {name}")) } diff --git a/nebula_app/src/ai_hook/remote/tests.rs b/nebula_app/src/ai_hook/remote/tests.rs index 0fd701b..60660e9 100644 --- a/nebula_app/src/ai_hook/remote/tests.rs +++ b/nebula_app/src/ai_hook/remote/tests.rs @@ -172,3 +172,49 @@ fn export_isolated_ssh_acceptance_plan() { ) .unwrap(); } + +#[test] +fn wsl_installs_guest_command_hooks_and_preserves_custom_notify() { + let mut snapshot = snapshot().for_wsl(); + put(&mut snapshot, "codex_config", "notify = ['user-notifier']\nmodel = 'kept'\n"); + let edits = snapshot.plan(Action::Install).unwrap().unwrap(); + assert!(!edits.iter().any(|edit| matches!(edit.name.as_str(), "opencode" | "pi"))); + apply(&mut snapshot, edits); + let hooks: Value = serde_json::from_str(snapshot.raw("codex").unwrap().unwrap()).unwrap(); + for name in [ + "SessionStart", + "UserPromptSubmit", + "PermissionRequest", + "PreToolUse", + "PostToolUse", + "Stop", + "Interrupt", + "SessionEnd", + ] { + let group = hooks["hooks"][name].to_string(); + assert!(group.contains("--hooks=full"), "{name}: {group}"); + assert!(group.contains("/home/")); + assert!(!group.contains(".exe")); + } + assert!(snapshot.raw("codex_config").unwrap().unwrap().contains("user-notifier")); + assert!(snapshot.plan(Action::Automatic).unwrap().unwrap().is_empty()); +} + +#[test] +fn wsl_replaces_the_legacy_windows_helper_without_chaining_a_dead_executable() { + let mut snapshot = snapshot().for_wsl(); + put( + &mut snapshot, + "codex_config", + "notify = ['D:/Program Files/Nebula Terminal/runtime/nebula-hook.exe', 'codex']\n", + ); + let edits = snapshot.plan(Action::Install).unwrap().unwrap(); + apply(&mut snapshot, edits); + let config = + snapshot.raw("codex_config").unwrap().unwrap().parse::().unwrap(); + let argv = notify(&config).unwrap().unwrap(); + assert_eq!(argv.len(), 2); + assert!(argv[0].starts_with("/home/")); + assert_eq!(argv[1], "codex"); + assert!(!argv[0].contains(".exe")); +} diff --git a/nebula_app/src/cli.rs b/nebula_app/src/cli.rs index 8629854..1ebea03 100644 --- a/nebula_app/src/cli.rs +++ b/nebula_app/src/cli.rs @@ -918,8 +918,14 @@ pub struct SetupAiOptions { #[clap(long)] pub remove: bool, /// Install or remove hooks on an SSH host (alias or user@host). - #[clap(long, value_name = "DESTINATION")] + #[clap(long, value_name = "DESTINATION", conflicts_with = "wsl")] pub ssh: Option, + /// Install or remove hooks in a WSL distribution's own user configuration. + #[clap(long, value_name = "DISTRIBUTION")] + pub wsl: Option, + /// WSL user; defaults to that distribution's configured user. + #[clap(long, value_name = "USER", requires = "wsl")] + pub wsl_user: Option, } /// Options for the `ssh` subcommand: every token after `ssh` is captured raw diff --git a/nebula_app/src/gpui_shell/mod.rs b/nebula_app/src/gpui_shell/mod.rs index 29dd9a9..b2d8a95 100644 --- a/nebula_app/src/gpui_shell/mod.rs +++ b/nebula_app/src/gpui_shell/mod.rs @@ -64,6 +64,7 @@ pub(crate) fn try_write_stderr(args: std::fmt::Arguments<'_>) { pub(crate) enum GpuiShellEvent { TrayFocus(Option), NotificationFocus(Option), + NotificationChoice { pane_id: u64, request_id: u64, choice: usize }, TrayQuit, MuxAttach, RuntimeControl(std::sync::Arc), diff --git a/nebula_app/src/gpui_shell/terminal/confirmation.rs b/nebula_app/src/gpui_shell/terminal/confirmation.rs index a8c1905..1a9978b 100644 --- a/nebula_app/src/gpui_shell/terminal/confirmation.rs +++ b/nebula_app/src/gpui_shell/terminal/confirmation.rs @@ -1,3 +1,5 @@ +mod choices; + use std::sync::OnceLock; use std::sync::atomic::{AtomicU64, Ordering}; @@ -5,9 +7,10 @@ use nebula_terminal::term::TermMode; use regex::Regex; #[derive(Clone)] -pub(crate) struct BinaryConfirmation { +pub(crate) struct Confirmation { pub id: u64, pub question: String, + pub choices: Vec, } #[derive(Clone, PartialEq, Eq)] @@ -19,10 +22,11 @@ struct Fingerprint { } struct Pending { - public: BinaryConfirmation, + public: Confirmation, fingerprint: Fingerprint, allow: String, deny: String, + replies: Vec, } #[derive(Default)] @@ -30,6 +34,8 @@ pub(super) struct ConfirmationState { pending: Option, consumed: Option, provider_request: Option, + waiting: bool, + generation: u64, } fn describe( @@ -39,7 +45,7 @@ fn describe( mode: TermMode, waiting: bool, ) -> Option<(Fingerprint, String, String, String)> { - if !waiting || program.is_empty() || mode.contains(TermMode::REPORT_ALL_KEYS_AS_ESC) { + if !waiting || program.is_empty() { return None; } let context: Vec = screen @@ -79,7 +85,15 @@ fn describe( } impl ConfirmationState { + pub fn generation(&self) -> u64 { + self.generation + } + pub fn observe_waiting(&mut self, waiting: bool) { + if self.waiting != waiting { + self.generation = self.generation.wrapping_add(1); + } + self.waiting = waiting; if !waiting { self.pending = None; self.consumed = None; @@ -89,6 +103,7 @@ impl ConfirmationState { pub fn observe_input(&mut self, bytes: &[u8]) { if !bytes.is_empty() { + self.generation = self.generation.wrapping_add(1); self.invalidate(); } } @@ -100,6 +115,7 @@ impl ConfirmationState { self.pending = None; self.consumed = None; self.provider_request = Some(request.to_owned()); + self.generation = self.generation.wrapping_add(1); } } @@ -116,10 +132,19 @@ impl ConfirmationState { session_id: Option<&str>, mode: TermMode, waiting: bool, - ) -> Option { - let Some((fingerprint, question, allow, deny)) = - describe(screen, program, session_id, mode, waiting) - else { + ) -> Option { + let described = describe(screen, program, session_id, mode, waiting) + .map(|(fingerprint, question, allow, deny)| { + (fingerprint, question, allow, deny, Vec::new(), Vec::new()) + }) + .or_else(|| { + choices::describe(screen, program, session_id, mode, waiting).map( + |(fingerprint, question, labels, replies)| { + (fingerprint, question, String::new(), String::new(), labels, replies) + }, + ) + }); + let Some((fingerprint, question, allow, deny, choices, replies)) = described else { self.invalidate(); return None; }; @@ -132,8 +157,8 @@ impl ConfirmationState { return Some(pending.public.clone()); } static NEXT: AtomicU64 = AtomicU64::new(1); - let public = BinaryConfirmation { id: NEXT.fetch_add(1, Ordering::Relaxed), question }; - self.pending = Some(Pending { public: public.clone(), fingerprint, allow, deny }); + let public = Confirmation { id: NEXT.fetch_add(1, Ordering::Relaxed), question, choices }; + self.pending = Some(Pending { public: public.clone(), fingerprint, allow, deny, replies }); Some(public) } @@ -147,18 +172,50 @@ impl ConfirmationState { mode: TermMode, waiting: bool, ) -> Option> { - if self.pending.as_ref()?.public.id != request_id { + self.answer_choice( + request_id, + usize::from(!allow), + screen, + program, + session_id, + mode, + waiting, + ) + } + + pub fn answer_choice( + &mut self, + request_id: u64, + choice: usize, + screen: &str, + program: &str, + session_id: Option<&str>, + mode: TermMode, + waiting: bool, + ) -> Option> { + let pending = self.pending.as_ref()?; + if pending.public.id != request_id { return None; } - let pending = self.pending.take()?; - self.consumed = Some(pending.fingerprint.clone()); - let (current, _, _, _) = describe(screen, program, session_id, mode, waiting)?; - if current != pending.fingerprint { - return None; - } - let mut reply = if allow { pending.allow } else { pending.deny }.into_bytes(); - reply.push(b'\r'); - Some(reply) + let reply = if pending.replies.is_empty() { + let (current, _, _, _) = describe(screen, program, session_id, mode, waiting)?; + if current != pending.fingerprint { + return None; + } + match choice { + 0 => format!("{}\r", pending.allow), + 1 => format!("{}\r", pending.deny), + _ => return None, + } + } else { + let (current, _, _, _) = choices::describe(screen, program, session_id, mode, waiting)?; + if current != pending.fingerprint { + return None; + } + pending.replies.get(choice)?.clone() + }; + self.consumed = self.pending.take().map(|pending| pending.fingerprint); + Some(reply.into_bytes()) } } diff --git a/nebula_app/src/gpui_shell/terminal/confirmation/choices.rs b/nebula_app/src/gpui_shell/terminal/confirmation/choices.rs new file mode 100644 index 0000000..4f298ba --- /dev/null +++ b/nebula_app/src/gpui_shell/terminal/confirmation/choices.rs @@ -0,0 +1,130 @@ +//! Codex's displayed one-key choices. A question digit submits the current +//! answer itself; appending Enter could accidentally submit the next question. +use super::*; + +type Choices = (Fingerprint, String, Vec, Vec); + +pub(super) fn describe( + screen: &str, + program: &str, + session_id: Option<&str>, + mode: TermMode, + waiting: bool, +) -> Option { + if !waiting + || crate::ai_agents::AgentKind::parse(program) != Some(crate::ai_agents::AgentKind::Codex) + { + return None; + } + let mut context: Vec = screen + .lines() + .rev() + .filter(|line| !line.trim().is_empty()) + .take(24) + .map(|line| line.trim().to_owned()) + .collect(); + context.reverse(); + let footer = context.last()?.to_ascii_lowercase(); + let question = footer.contains("tab to add notes") && footer.contains("enter to submit"); + let approval = footer == "press enter to confirm or esc to cancel"; + if !question && !approval { + return None; + } + static ROW: OnceLock = OnceLock::new(); + let row = ROW.get_or_init(|| Regex::new(r"^(?:[›❯>]\s*)?([1-9])\.\s+(.+)$").unwrap()); + static KEY: OnceLock = OnceLock::new(); + let key = KEY.get_or_init(|| Regex::new(r"\(([a-z])\)$").unwrap()); + let mut labels = Vec::new(); + let mut replies = Vec::new(); + let mut first_row = None; + for (ix, line) in context[..context.len() - 1].iter().enumerate() { + let Some(captures) = row.captures(line) else { continue }; + let number = captures[1].parse::().ok()?; + if number != labels.len() + 1 || labels.len() >= 9 { + return None; + } + first_row.get_or_insert(ix); + let label = captures[2].trim(); + if label.chars().count() > 240 { + return None; + } + if question { + // Codex's Other/notes path needs free text and cannot be answered + // by selecting an advertised option. Keep that path in the terminal. + if label.starts_with("Other") || label.starts_with("None of the above") { + continue; + } + replies.push(number.to_string()); + labels.push(label.to_owned()); + } else { + let shortcut = key.captures(label)?; + replies.push(shortcut[1].to_owned()); + labels.push(label[..shortcut.get(0)?.start()].trim().to_owned()); + } + } + if labels.is_empty() { + return None; + } + let first = first_row?; + let question = + context[..first].iter().rev().find(|line| !line.starts_with("Question "))?.clone(); + Some(( + Fingerprint { + context, + program: program.into(), + session_id: session_id.map(str::to_owned), + mode, + }, + question, + labels, + replies, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + const QUESTION: &str = "Question 1/2 (2 unanswered)\nChoose a scope.\n› 1. Current Selected directory\n 2. All Whole workspace\ntab to add notes | enter to submit answer | ←/→ to navigate questions | esc to interrupt"; + + #[test] + fn a_number_submits_only_the_current_question_and_old_buttons_expire() { + let mut state = ConfirmationState::default(); + let first = state.capture(QUESTION, "codex", Some("s"), TermMode::empty(), true).unwrap(); + assert_eq!(first.choices.len(), 2); + assert_eq!( + state.answer_choice(first.id, 1, QUESTION, "codex", Some("s"), TermMode::empty(), true), + Some(b"2".to_vec()) + ); + assert!( + state + .answer_choice(first.id, 1, QUESTION, "codex", Some("s"), TermMode::empty(), true) + .is_none() + ); + let second = QUESTION.replace("Question 1/2", "Question 2/2"); + let current = state.capture(&second, "codex", Some("s"), TermMode::empty(), true).unwrap(); + assert_ne!(first.id, current.id); + assert!( + state + .answer_choice(first.id, 0, &second, "codex", Some("s"), TermMode::empty(), true) + .is_none() + ); + } + + #[test] + fn approval_uses_the_advertised_shortcut_and_rejects_unknown_controls() { + let prompt = "Grant permissions?\n› 1. For this turn (y)\n2. Deny (d)\nPress enter to confirm or esc to cancel"; + let (_, _, labels, replies) = + describe(prompt, "codex", None, TermMode::empty(), true).unwrap(); + assert_eq!(labels, ["For this turn", "Deny"]); + assert_eq!(replies, ["y", "d"]); + for invalid in [ + prompt.replace("(d)", "(ctrl+d)"), + prompt.replace("2. Deny", "4. Deny"), + prompt.replace("Press enter", "Documentation: press enter"), + ] { + assert!(describe(&invalid, "codex", None, TermMode::empty(), true).is_none()); + } + assert!(describe(prompt, "claude", None, TermMode::empty(), true).is_none()); + assert!(describe(prompt, "codex", None, TermMode::empty(), false).is_none()); + } +} diff --git a/nebula_app/src/gpui_shell/terminal/keymap.rs b/nebula_app/src/gpui_shell/terminal/keymap.rs index ed48292..d119c0f 100644 --- a/nebula_app/src/gpui_shell/terminal/keymap.rs +++ b/nebula_app/src/gpui_shell/terminal/keymap.rs @@ -93,6 +93,23 @@ fn use_win32_input_mode(mode: &TermMode) -> bool { crate::input::terminal_input::use_win32_input_mode(*mode) } +/// Notification choices are synthetic keystrokes, not an IME composition. +/// Encode printable keys when the client explicitly requests every key as CSI-u. +pub(super) fn encode_choice_text(text: &str, mode: &TermMode) -> Vec { + let mut bytes = Vec::new(); + for ch in text.chars() { + let key = Keystroke { + modifiers: Default::default(), + key: ch.to_string(), + key_char: Some(ch.to_string()), + }; + bytes.extend( + kitty_sequence(&key, mode, true).unwrap_or_else(|| ch.to_string().into_bytes()), + ); + } + bytes +} + /// 子进程是否请求过 kitty 键盘协议(三位标志任一)。kitty 是线上合同, /// 压过 DECSET 9001——口径同旧壳 `input/terminal_input.rs`。 fn kitty_keyboard_active(mode: &TermMode) -> bool { @@ -165,7 +182,7 @@ pub(super) fn trace_enter(ks: &Keystroke, mode: &TermMode, bytes: &[u8]) { ); } -fn kitty_sequence(ks: &Keystroke, mode: &TermMode) -> Option> { +fn kitty_sequence(ks: &Keystroke, mode: &TermMode, synthetic: bool) -> Option> { use crate::input::terminal_input::{KeyInput, build_sequence}; use winit::event::ElementState; use winit::keyboard::{Key, KeyLocation, ModifiersState, NamedKey}; @@ -182,7 +199,9 @@ fn kitty_sequence(ks: &Keystroke, mode: &TermMode) -> Option> { (Key::Named(NamedKey::Enter), Key::Named(NamedKey::Enter)) } else { if !mode.intersects(TermMode::DISAMBIGUATE_ESC_CODES | TermMode::REPORT_ALL_KEYS_AS_ESC) - || !(ks.modifiers.control || ks.modifiers.alt) + || !(ks.modifiers.control + || ks.modifiers.alt + || synthetic && mode.contains(TermMode::REPORT_ALL_KEYS_AS_ESC)) { return None; } @@ -197,7 +216,9 @@ fn kitty_sequence(ks: &Keystroke, mode: &TermMode) -> Option> { _ => return None, } }; - let character = if ks.modifiers.shift { + let character = if synthetic { + ks.key_char.as_deref().and_then(|text| text.chars().next()).unwrap_or(base) + } else if ks.modifiers.shift { ks.key_char .as_deref() .filter(|text| text.len() == 1 && text.as_bytes()[0].is_ascii_graphic()) @@ -208,7 +229,7 @@ fn kitty_sequence(ks: &Keystroke, mode: &TermMode) -> Option> { }; (Key::Character(character.to_string().into()), Key::Character(base.to_string().into())) }; - let input = KeyInput { + let mut input = KeyInput { logical_key, state: ElementState::Pressed, location: KeyLocation::Standard, @@ -230,7 +251,12 @@ fn kitty_sequence(ks: &Keystroke, mode: &TermMode) -> Option> { modifiers.set(ModifiersState::ALT, ks.modifiers.alt); modifiers.set(ModifiersState::CONTROL, ks.modifiers.control); modifiers.set(ModifiersState::SUPER, ks.modifiers.platform); - Some(build_sequence(&input, modifiers, *mode)) + let mut bytes = build_sequence(&input, modifiers, *mode); + if synthetic && mode.contains(TermMode::REPORT_EVENT_TYPES) { + input.state = ElementState::Released; + bytes.extend(build_sequence(&input, modifiers, *mode)); + } + Some(bytes) } /// These Windows chords must reach DefWindowProc so the existing close guard @@ -279,7 +305,7 @@ pub fn encode(ks: &Keystroke, mode: &TermMode) -> Option> { if let Some(bytes) = kitty_escape(ks, mode) { return Some(bytes); } - if let Some(bytes) = kitty_sequence(ks, mode) { + if let Some(bytes) = kitty_sequence(ks, mode, false) { return Some(bytes); } diff --git a/nebula_app/src/gpui_shell/terminal/session.rs b/nebula_app/src/gpui_shell/terminal/session.rs index 45041b3..4d0870c 100644 --- a/nebula_app/src/gpui_shell/terminal/session.rs +++ b/nebula_app/src/gpui_shell/terminal/session.rs @@ -158,6 +158,7 @@ pub(super) fn local_options( // 身份契约必须最后写:它以环境表里 `WSLENV` 的现值为基准合并,才能同时 // 保住上面那段的 cwd 上报条目。见 [`crate::agent_env`]。 crate::agent_env::apply(&mut options.env, pane_id); + crate::platform::wsl_hooks::prepare(&mut options); options } @@ -184,7 +185,11 @@ pub fn spawn( // 用于 resize 锚定问题的字节级取证。 let record = std::env::var_os("NEBULA_PTY_RECORD").is_some(); let native_prompt = proxy.events.native_prompt.clone(); - let event_loop = EventLoop::new(Arc::clone(&term), proxy, pty, options.drain_on_exit, record)?; + let mut event_loop = + EventLoop::new(Arc::clone(&term), proxy, pty, options.drain_on_exit, record)?; + if let Some(token) = options.env.get(crate::ai_hook::remote::TOKEN_ENV) { + event_loop.set_remote_hook_token(token.clone()); + } let notifier = Notifier(event_loop.channel()); let _io_thread = event_loop.spawn(); diff --git a/nebula_app/src/gpui_shell/terminal/view.rs b/nebula_app/src/gpui_shell/terminal/view.rs index 9078599..2670fb7 100644 --- a/nebula_app/src/gpui_shell/terminal/view.rs +++ b/nebula_app/src/gpui_shell/terminal/view.rs @@ -654,7 +654,7 @@ impl TerminalView { } }, TermEvent::AiHookEnvelope(envelope) => { - // SSH pane 里的 agent 靠私有 OSC 把 hook 信封带回本地(本地 + // SSH/WSL pane 里的 agent 靠私有 OSC 把 hook 信封带回本地(原生本地 // agent 走 workspace 的 ai_events 通道)。信封在 event_loop 里 // 已核过通道令牌,这里解析出来喂进同一个应用路径。 // 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 eb7d085..76df0a0 100644 --- a/nebula_app/src/gpui_shell/terminal/view/agent_activity.rs +++ b/nebula_app/src/gpui_shell/terminal/view/agent_activity.rs @@ -41,6 +41,22 @@ impl TerminalView { return false; } self.agent_activity.apply_hook(event); + if matches!( + event.kind, + AiHookKind::SessionStart + | AiHookKind::NeedsAttention + | AiHookKind::PromptSubmit + | AiHookKind::ToolComplete + | AiHookKind::TurnDone + ) { + self.last_command_failed = event.kind == AiHookKind::TurnDone + && event.active_background_tasks() == 0 + && matches!( + event.turn_outcome, + crate::ai_hook::AiTurnOutcome::Failed + | crate::ai_hook::AiTurnOutcome::Incomplete + ); + } if let Some(target) = target { let previous = self.recovery.target.clone(); self.recovery.confirm(target); @@ -83,7 +99,11 @@ impl TerminalView { let status = self.agent_activity.status(); self.confirmation.observe_waiting(status == AgentStatus::Blocked); if event.kind == AiHookKind::NeedsAttention { - self.confirmation.set_provider_request(event.event_id.as_deref()); + let request = event + .event_id + .clone() + .unwrap_or_else(|| format!("{}:{}", event.source, event.received_sequence)); + self.confirmation.set_provider_request(Some(&request)); if let Some(mut attention) = event.attention.clone() { attention.pane_id = Some(self.pane_id); cx.emit(TerminalViewEvent::AiAttention(attention)); diff --git a/nebula_app/src/gpui_shell/terminal/view/confirmation.rs b/nebula_app/src/gpui_shell/terminal/view/confirmation.rs index 89af335..e1d1b37 100644 --- a/nebula_app/src/gpui_shell/terminal/view/confirmation.rs +++ b/nebula_app/src/gpui_shell/terminal/view/confirmation.rs @@ -5,7 +5,7 @@ use super::{TerminalView, TerminalViewEvent}; impl TerminalView { pub(crate) fn capture_confirmation( &mut self, - ) -> Option { + ) -> Option { let (screen, mode) = self.runtime_screen_state()?; let program = self.running_program.as_deref().unwrap_or_default(); let waiting = self.confirmation_waiting(); @@ -18,7 +18,7 @@ impl TerminalView { ) } - fn confirmation_waiting(&self) -> bool { + pub(crate) fn confirmation_waiting(&self) -> bool { self.marked_text.is_none() && self .running_program @@ -38,11 +38,27 @@ impl TerminalView { allow: bool, cx: &mut Context, ) -> bool { + self.answer_choice(request_id, usize::from(!allow), cx) + } + + pub(crate) fn confirmation_generation(&self) -> u64 { + self.confirmation.generation() + } + + pub(crate) fn answer_choice( + &mut self, + request_id: u64, + choice: usize, + cx: &mut Context, + ) -> bool { + if self.session.is_none() || self.pending_runtime_submit.is_some() { + return false; + } let Some((screen, mode)) = self.runtime_screen_state() else { return false }; let waiting = self.confirmation_waiting(); - let reply = self.confirmation.answer( + let reply = self.confirmation.answer_choice( request_id, - allow, + choice, &screen, self.running_program.as_deref().unwrap_or_default(), self.ai_session.as_ref().map(|identity| identity.session_id.as_str()), @@ -50,9 +66,178 @@ impl TerminalView { waiting, ); let Some(reply) = reply else { return false }; - self.write_input(reply, cx); - self.agent_activity.submitted(); + let submit = reply.last() == Some(&b'\r'); + let text = + std::str::from_utf8(if submit { &reply[..reply.len() - 1] } else { &reply }).unwrap(); + let bytes = super::super::keymap::encode_choice_text(text, &mode); + self.write_input(bytes, cx); + let epoch = self.prompt_input_epoch; + let generation = self.confirmation_generation(); + let executor = cx.background_executor().clone(); + cx.spawn(async move |this, cx| { + // Separate line submission from text so TUIs cannot treat the pair + // as one pasted chunk. Manual input or a new lifecycle cancels it. + executor.timer(std::time::Duration::from_millis(75)).await; + if submit { + let _ = this.update(cx, |view, cx| { + if view.prompt_input_epoch != epoch + || view.confirmation_generation() != generation + || !view.confirmation_waiting() + { + return; + } + let bytes = crate::input::terminal_input::build_runtime_sequence_for_program( + crate::runtime_api::RuntimeKey::Enter, + Default::default(), + 1, + view.term_mode(), + view.running_program.as_deref(), + ); + view.write_input(bytes, cx); + }); + return; + } + // A numbered answer may advance a multi-question form without a + // new hook. Advertise the next live question once its frame arrives. + for _ in 0..8 { + let keep_waiting = this + .update(cx, |view, cx| { + if view.prompt_input_epoch != epoch || !view.confirmation_waiting() { + return false; + } + if let Some(next) = view.capture_confirmation() { + cx.emit(TerminalViewEvent::Notification( + crate::notify::Notification::AiTurn { + program: view.running_program.clone().unwrap_or_default(), + message: Some(next.question), + attention: true, + }, + )); + false + } else { + true + } + }) + .unwrap_or(false); + if !keep_waiting { + return; + } + executor.timer(std::time::Duration::from_millis(75)).await; + } + }) + .detach(); cx.emit(TerminalViewEvent::TitleChanged); true } } + +#[cfg(all(test, feature = "gpui-test-support"))] +mod tests { + use super::*; + use crate::gpui_shell::terminal::view::startup_tests::{feed, open}; + use gpui::TestAppContext; + use nebula_terminal::event_loop::Msg; + use std::time::Duration; + + fn waiting( + view: &mut TerminalView, + program: &str, + prompt: &str, + session: &str, + cx: &mut Context, + ) { + view.session.as_ref().unwrap().term.lock().set_options(nebula_terminal::term::Config { + kitty_keyboard: true, + ..Default::default() + }); + let header = if program == "codex" { + "nebula-hook/1 source=codex codex_hooks=full" + } else { + "nebula-hook/1 source=claude" + }; + let wire = format!( + "{header}\n{}", + serde_json::json!({"hook_event_name":"PermissionRequest", "session_id":session}) + ); + let event = crate::ai_hook::parse_remote_envelope(wire.as_bytes(), Some(42)).unwrap(); + view.handle_ai_hook(&event, cx); + feed(view, prompt.replace('\n', "\r\n").as_bytes()); + } + + fn input(receiver: &std::sync::mpsc::Receiver) -> Vec> { + receiver + .try_iter() + .filter_map(|msg| match msg { + Msg::Input(bytes) => Some(bytes.to_vec()), + _ => None, + }) + .collect() + } + + #[gpui::test] + fn binary_answers_separate_legacy_enter_and_cancel_after_manual_input(cx: &mut TestAppContext) { + let (view, window, receiver) = open(cx); + view.update(window, |view, cx| { + waiting(view, "claude", "Allow operation? [Y/n]", "confirmation-test-legacy", cx); + let request = view.capture_confirmation().unwrap(); + assert!(view.answer_choice(request.id, 0, cx)); + }); + assert_eq!(input(&receiver), [b"Y".to_vec()]); + window.run_until_parked(); + window.background_executor.advance_clock(Duration::from_millis(80)); + window.run_until_parked(); + assert_eq!(input(&receiver), [b"\r".to_vec()]); + view.update(window, |view, cx| { + view.confirmation.observe_waiting(false); + view.confirmation.observe_waiting(true); + let request = view.capture_confirmation().unwrap(); + assert!(view.answer_choice(request.id, 1, cx)); + view.write_input(b"x".to_vec(), cx); + }); + assert_eq!(input(&receiver), [b"n".to_vec(), b"x".to_vec()]); + window.run_until_parked(); + window.background_executor.advance_clock(Duration::from_millis(80)); + window.run_until_parked(); + assert!( + input(&receiver).is_empty(), + "a delayed Enter cannot follow intervening keyboard input" + ); + } + + #[gpui::test] + fn binary_confirmation_releases_text_and_enter_when_key_events_are_requested( + cx: &mut TestAppContext, + ) { + let (view, window, receiver) = open(cx); + view.update(window, |view, cx| { + waiting(view, "claude", "Allow operation? [Y/n]", "confirmation-test-key-events", cx); + feed(view, b"\x1b[>10u"); + let request = view.capture_confirmation().unwrap(); + assert!(view.answer_choice(request.id, 0, cx)); + }); + assert_eq!(input(&receiver), [b"\x1b[89u\x1b[89;1:3u".to_vec()]); + window.run_until_parked(); + window.background_executor.advance_clock(Duration::from_millis(80)); + window.run_until_parked(); + assert_eq!(input(&receiver), [b"\x1b[13u\x1b[13;1:3u".to_vec()]); + } + + #[gpui::test] + fn numbered_codex_answers_send_one_key_without_enter(cx: &mut TestAppContext) { + let (view, window, receiver) = open(cx); + view.update(window, |view, cx| { + waiting(view, "codex", "Question 1/1 (1 unanswered)\nChoose a scope.\n› 1. Current\n 2. All\ntab to add notes | enter to submit answer | esc to interrupt", "confirmation-test-numbered", cx); + feed(view, b"\x1b[>10u"); + assert!(view.term_mode().contains(nebula_terminal::term::TermMode::REPORT_ALL_KEYS_AS_ESC)); + let request = view.capture_confirmation().unwrap(); + assert_eq!(request.choices, ["Current", "All"]); + assert!(view.answer_choice(request.id, 1, cx)); + assert!(!view.answer_choice(request.id, 0, cx)); + }); + assert_eq!(input(&receiver), [b"\x1b[50u\x1b[50;1:3u".to_vec()]); + window.run_until_parked(); + window.background_executor.advance_clock(Duration::from_millis(800)); + window.run_until_parked(); + assert!(input(&receiver).is_empty()); + } +} diff --git a/nebula_app/src/gpui_shell/terminal/view/notifications.rs b/nebula_app/src/gpui_shell/terminal/view/notifications.rs index bd9a7d4..748e251 100644 --- a/nebula_app/src/gpui_shell/terminal/view/notifications.rs +++ b/nebula_app/src/gpui_shell/terminal/view/notifications.rs @@ -6,19 +6,16 @@ impl super::TerminalView { self.answer_reader.is_some() } - pub(super) fn notify_command_done(&self, cx: &mut Context) { - if self.agent_activity.hook_seen() { - return; - } + pub(super) fn notify_command_done(&self, exit_code: Option, cx: &mut Context) { if let Some(started) = self.command_started - && started.elapsed() >= crate::notify::COMMAND_NOTIFY_MIN + && let Some(notification) = crate::notify::Notification::command_finished( + self.running_program.clone(), + started.elapsed(), + exit_code, + self.agent_activity.hook_seen(), + ) { - cx.emit(super::TerminalViewEvent::Notification( - crate::notify::Notification::CommandDone { - duration: started.elapsed(), - program: self.running_program.clone(), - }, - )); + cx.emit(super::TerminalViewEvent::Notification(notification)); } } } diff --git a/nebula_app/src/gpui_shell/terminal/view/runtime.rs b/nebula_app/src/gpui_shell/terminal/view/runtime.rs index e81ee91..f01e857 100644 --- a/nebula_app/src/gpui_shell/terminal/view/runtime.rs +++ b/nebula_app/src/gpui_shell/terminal/view/runtime.rs @@ -159,7 +159,7 @@ impl TerminalView { exit_code: Option, cx: &mut Context, ) { - self.notify_command_done(cx); + 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) { cx.emit(TerminalViewEvent::TitleChanged); diff --git a/nebula_app/src/gpui_shell/toast.rs b/nebula_app/src/gpui_shell/toast.rs index fd2ccfc..7a8f5fa 100644 --- a/nebula_app/src/gpui_shell/toast.rs +++ b/nebula_app/src/gpui_shell/toast.rs @@ -19,7 +19,10 @@ use std::sync::Mutex; use std::time::{Duration, Instant}; -use gpui::{AnyElement, App, IntoElement as _, ParentElement as _, Styled as _, Window, div, px}; +use gpui::{ + AnyElement, App, InteractiveElement as _, IntoElement as _, ParentElement as _, Styled as _, + Window, div, px, +}; use gpui_component::notification::Notification; use gpui_component::{Root, WindowExt as _}; @@ -249,7 +252,7 @@ pub(crate) fn confirmation_for_pane( cx: &mut App, text: String, pane_id: u64, - confirmation: crate::gpui_shell::terminal::confirmation::BinaryConfirmation, + confirmation: crate::gpui_shell::terminal::confirmation::Confirmation, ) { let language = super::config::ui_language(cx); let allow_label = language.text(crate::i18n::Message::CommonYes).to_owned(); @@ -261,6 +264,30 @@ pub(crate) fn confirmation_for_pane( cx.defer(move |cx| super::workspace::windowing::focus_notification(Some(pane_id), cx)); }) .content(move |_, _, cx| { + if !confirmation.choices.is_empty() { + return v_flex() + .gap_2() + .child(div().text_sm().child(confirmation.question.clone())) + .children(confirmation.choices.iter().enumerate().map(|(choice, label)| { + Button::new(("confirmation-choice", choice)) + .debug_selector(|| format!("confirmation-choice-{choice}")) + .w_full() + .min_h(px(32.0)) + .tooltip(label.clone()) + .label(label.clone()) + .small() + .ghost() + .on_click(cx.listener(move |note, _, window, cx| { + cx.stop_propagation(); + let feedback = window.window_handle(); + note.dismiss(window, cx); + cx.defer(move |cx| { + reply_to_choice(pane_id, request_id, choice, Some(feedback), cx) + }); + })) + })) + .into_any_element(); + } v_flex() .gap_2() .child(div().text_sm().child(confirmation.question.clone())) @@ -314,12 +341,21 @@ fn reply_to_confirmation( allow: bool, feedback: gpui::AnyWindowHandle, cx: &mut App, +) { + reply_to_choice(pane_id, request_id, usize::from(!allow), Some(feedback), cx); +} + +pub(crate) fn reply_to_choice( + pane_id: u64, + request_id: u64, + choice: usize, + feedback: Option, + cx: &mut App, ) { super::workspace::windowing::focus_notification(Some(pane_id), cx); - let applied = super::workspace::windowing::notification_view(pane_id, cx).is_some_and(|view| { - view.update(cx, |view, cx| view.answer_confirmation(request_id, allow, cx)) - }); - if !applied { + let applied = super::workspace::windowing::notification_view(pane_id, cx) + .is_some_and(|view| view.update(cx, |view, cx| view.answer_choice(request_id, choice, cx))); + if !applied && let Some(feedback) = feedback { let _ = feedback.update(cx, |_, window, cx| { let language = crate::display::LanguagePreference::from( nebula_settings::RuntimeSettings::load().language, @@ -446,7 +482,7 @@ mod tests { }; use crate::gpui_shell::config::Settings; - use crate::gpui_shell::terminal::confirmation::BinaryConfirmation; + use crate::gpui_shell::terminal::confirmation::Confirmation; struct Empty; @@ -487,6 +523,81 @@ mod tests { cx.run_until_parked(); } + #[gpui::test] + fn numbered_choice_buttons_have_real_hit_targets_and_report_a_closed_pane( + cx: &mut TestAppContext, + ) { + struct NotificationSurface; + impl Render for NotificationSurface { + fn render( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> impl gpui::IntoElement { + div().relative().size_full().children(render_layer(window, cx)) + } + } + initialize(cx, true); + cx.update(|cx| { + cx.set_reduce_motion(true); + crate::gpui_shell::workspace::windowing::initialize( + cx, + crate::runtime_api::RuntimeHub::new(), + ); + }); + let mut surface = None; + let (_, cx) = cx.add_window_view(|window, cx| { + let view = cx.new(|_| NotificationSurface); + surface = Some(view.clone()); + Root::new(view, window, cx) + }); + let surface = surface.unwrap(); + cx.update(|window, cx| { + confirmation_for_pane( + window, + cx, + "Choose a scope".into(), + 99991, + Confirmation { + id: 99991, + question: "Choose a scope".into(), + choices: vec!["Current directory".into(), "Whole workspace".into()], + }, + ) + }); + cx.update(|_, cx| surface.update(cx, |_, cx| cx.notify())); + cx.run_until_parked(); + cx.background_executor.advance_clock(Duration::from_millis(200)); + cx.run_until_parked(); + cx.update(|window, cx| { + surface.update(cx, |_, cx| cx.notify()); + let _ = window.draw(cx); + }); + let original = ids(cx); + assert_eq!(original.len(), 1); + let bounds = + cx.debug_bounds("confirmation-choice-1").expect("numbered option is rendered"); + assert!(bounds.size.width >= px(100.0) && bounds.size.height >= px(32.0)); + cx.simulate_mouse_down( + bounds.center(), + gpui::MouseButton::Left, + gpui::Modifiers::default(), + ); + cx.simulate_mouse_up( + bounds.center(), + gpui::MouseButton::Left, + gpui::Modifiers::default(), + ); + settle_dismissal(cx); + let current = ids(cx); + assert!(!current.contains(&original[0]), "the clicked stale card is dismissed"); + assert_eq!( + current.len(), + 1, + "the closed source produces visible expired-request feedback" + ); + } + #[gpui::test] fn disabling_ai_toasts_removes_only_ai_cards_and_can_be_reenabled(cx: &mut TestAppContext) { initialize(cx, true); @@ -505,7 +616,7 @@ mod tests { cx, "AI confirmation".into(), 7101, - BinaryConfirmation { id: 7201, question: "Continue?".into() }, + Confirmation { choices: Vec::new(), id: 7201, question: "Continue?".into() }, ); banner(window, cx, ToastKind::Warning, "Configuration needs attention"); }); @@ -561,7 +672,7 @@ mod tests { cx, "Permission".into(), 7303, - BinaryConfirmation { id: 7304, question: "Continue?".into() }, + Confirmation { choices: Vec::new(), id: 7304, question: "Continue?".into() }, ); }); cx.run_until_parked(); @@ -608,7 +719,7 @@ mod tests { cx, "Hidden AI confirmation".into(), 7102, - BinaryConfirmation { id: 7202, question: "Continue?".into() }, + Confirmation { choices: Vec::new(), id: 7202, question: "Continue?".into() }, ); banner_for_pane( window, diff --git a/nebula_app/src/gpui_shell/toast/duration_tests.rs b/nebula_app/src/gpui_shell/toast/duration_tests.rs index 506db29..010c139 100644 --- a/nebula_app/src/gpui_shell/toast/duration_tests.rs +++ b/nebula_app/src/gpui_shell/toast/duration_tests.rs @@ -7,7 +7,7 @@ use nebula_settings::NotificationDuration; use std::{cell::Cell, rc::Rc}; use crate::gpui_shell::config::Settings; -use crate::gpui_shell::terminal::confirmation::BinaryConfirmation; +use crate::gpui_shell::terminal::confirmation::Confirmation; struct Empty; @@ -185,7 +185,7 @@ fn persistent_mode_has_no_expiry_and_retains_manual_dismissal(cx: &mut TestAppCo cx, "Persistent confirmation".into(), 8201, - BinaryConfirmation { id: 8301, question: "Continue?".into() }, + Confirmation { choices: Vec::new(), id: 8301, question: "Continue?".into() }, ); } else { toast(window, cx, ToastKind::Success, "Persistent ordinary toast"); @@ -215,7 +215,7 @@ fn refreshed_confirmation_gets_a_new_deadline_even_if_the_old_entity_is_retained cx, "Refreshed confirmation".into(), 8202, - BinaryConfirmation { id: 8302, question: "Continue?".into() }, + Confirmation { choices: Vec::new(), id: 8302, question: "Continue?".into() }, ); }; let old = window.update(|window, cx| { @@ -244,7 +244,7 @@ fn a_refreshed_persistent_card_cannot_be_closed_by_its_predecessors_timer(cx: &m cx, "Mode switch fixture".into(), 8203, - BinaryConfirmation { id: 8303, question: "Continue?".into() }, + Confirmation { choices: Vec::new(), id: 8303, question: "Continue?".into() }, ); }; window.update(push); @@ -280,7 +280,7 @@ fn default_mode_preserves_short_toasts_banners_and_persistent_update_notices( cx, "Default confirmation fixture".into(), 8204, - BinaryConfirmation { id: 8304, question: "Continue?".into() }, + Confirmation { choices: Vec::new(), id: 8304, question: "Continue?".into() }, ); } else { banner(window, cx, ToastKind::Warning, "Default non-AI configuration fixture"); @@ -323,7 +323,7 @@ fn duration_still_applies_to_non_ai_cards_when_ai_visibility_is_off(cx: &mut Tes cx, "Hidden AI fixture".into(), 8205, - BinaryConfirmation { id: 8305, question: "Continue?".into() }, + Confirmation { choices: Vec::new(), id: 8305, question: "Continue?".into() }, ); }); assert_eq!(ids(&mut window).len(), 3); @@ -408,7 +408,7 @@ fn overflow_removal_cannot_dismiss_a_refreshed_confirmation(cx: &mut TestAppCont cx, "Overflow confirmation".into(), 8206, - BinaryConfirmation { id: 8306, question: "Continue?".into() }, + Confirmation { choices: Vec::new(), id: 8306, question: "Continue?".into() }, ); }; push(window, cx); diff --git a/nebula_app/src/gpui_shell/workspace/notifications.rs b/nebula_app/src/gpui_shell/workspace/notifications.rs index 7103f81..1e2b0b0 100644 --- a/nebula_app/src/gpui_shell/workspace/notifications.rs +++ b/nebula_app/src/gpui_shell/workspace/notifications.rs @@ -35,6 +35,54 @@ impl NebulaWorkspace { notification: Notification, window: &mut Window, cx: &mut Context, + ) { + if notification.is_attention() + && let Some(source) = self.tabs.iter().find_map(|tab| match tab { + WorkspaceTab::Terminal { panes, .. } => { + panes.iter().find(|pane| pane.id == pane_id).map(|pane| pane.view.clone()) + }, + _ => None, + }) + && source.update(cx, |view, _| view.capture_confirmation()).is_none() + { + let generation = source.read(cx).confirmation_generation(); + let source = source.downgrade(); + let executor = cx.background_executor().clone(); + cx.spawn_in(window, async move |this, cx| { + for attempt in 0..8 { + executor.timer(std::time::Duration::from_millis(75)).await; + let ready = source + .update(cx, |view, _| { + if view.confirmation_generation() != generation + || !view.confirmation_waiting() + { + return None; + } + Some(view.capture_confirmation().is_some()) + }) + .ok() + .flatten(); + let Some(ready) = ready else { return }; + if ready || attempt == 7 { + let _ = this.update_in(cx, |workspace, window, cx| { + workspace.deliver_ready_notification(pane_id, notification, window, cx); + }); + return; + } + } + }) + .detach(); + return; + } + self.deliver_ready_notification(pane_id, notification, window, cx); + } + + fn deliver_ready_notification( + &mut self, + pane_id: u64, + notification: Notification, + window: &mut Window, + cx: &mut Context, ) { let Some(tab_index) = self.tab_of_pane(pane_id) else { return }; let Some(WorkspaceTab::Terminal { panes, focused, .. }) = self.tabs.get(tab_index) else { @@ -70,13 +118,13 @@ impl NebulaWorkspace { cx.notify(); return; } + let attention = notification.is_attention(); + let confirmation = if attention { + source_view.and_then(|view| view.update(cx, |view, _| view.capture_confirmation())) + } else { + None + }; if delivery.in_app { - let attention = notification.is_attention(); - let confirmation = if attention { - source_view.and_then(|view| view.update(cx, |view, _| view.capture_confirmation())) - } else { - None - }; // Log the original message before the banner creates a bounded preview. let (title, body) = notification.raw_toast_text(); let kind = if attention || notification.is_failure() { @@ -85,7 +133,7 @@ impl NebulaWorkspace { crate::display::ToastKind::Info }; let text = format!("{title} \u{b7} {body}"); - if let Some(confirmation) = confirmation { + if let Some(confirmation) = confirmation.clone() { crate::gpui_shell::toast::confirmation_for_pane( window, cx, @@ -105,7 +153,19 @@ impl NebulaWorkspace { } } if delivery.system { - crate::notify::deliver_gpui(¬ification, pane_id); + let choices = confirmation.map(|confirmation| { + let language = crate::gpui_shell::config::ui_language(cx); + let labels = if confirmation.choices.is_empty() { + vec![ + language.text(crate::i18n::Message::CommonYes).to_owned(), + language.text(crate::i18n::Message::CommonNo).to_owned(), + ] + } else { + confirmation.choices + }; + (confirmation.id, labels) + }); + crate::notify::deliver_gpui_with_choices(¬ification, pane_id, choices); } cx.notify(); } diff --git a/nebula_app/src/gpui_shell/workspace/residency.rs b/nebula_app/src/gpui_shell/workspace/residency.rs index b15a1f3..8b5f3fa 100644 --- a/nebula_app/src/gpui_shell/workspace/residency.rs +++ b/nebula_app/src/gpui_shell/workspace/residency.rs @@ -79,6 +79,19 @@ impl NebulaWorkspace { let pane = *pane; cx.defer(move |cx| super::windowing::focus_notification(pane, cx)); }, + GpuiShellEvent::NotificationChoice { pane_id, request_id, choice } => { + let (pane_id, request_id, choice) = (*pane_id, *request_id, *choice); + cx.defer(move |cx| { + super::windowing::dispatch_shell_events( + vec![GpuiShellEvent::NotificationChoice { + pane_id, + request_id, + choice, + }], + cx, + ) + }); + }, GpuiShellEvent::TrayQuit => { self.quit_from_tray(cx); return; diff --git a/nebula_app/src/gpui_shell/workspace/windowing.rs b/nebula_app/src/gpui_shell/workspace/windowing.rs index 2b9bef6..8bf78b2 100644 --- a/nebula_app/src/gpui_shell/workspace/windowing.rs +++ b/nebula_app/src/gpui_shell/workspace/windowing.rs @@ -861,6 +861,12 @@ pub(crate) fn dispatch_shell_events(events: Vec, cx: &mut App) { for event in events { match event { GpuiShellEvent::NotificationFocus(pane_id) => focus_notification(pane_id, cx), + GpuiShellEvent::NotificationChoice { pane_id, request_id, choice } => { + let feedback = entry_with_pane(pane_id, cx).map(|entry| entry.handle); + crate::gpui_shell::toast::reply_to_choice( + pane_id, request_id, choice, feedback, cx, + ); + }, GpuiShellEvent::TrayFocus(pane_id) => { let target = pane_id.and_then(|pane_id| entry_with_pane(pane_id, cx)).or_else(|| { @@ -1241,6 +1247,7 @@ pub(crate) fn focus_notification(pane_id: Option, cx: &mut App) { } super::quick_terminal::show_native_window(entry.native_hwnd); } + cx.activate(true); let workspace = entry.workspace.clone(); let _ = entry.handle.update(cx, move |_, window, cx| { let _ = workspace.update(cx, |workspace, cx| { @@ -1254,12 +1261,22 @@ pub(crate) fn focus_notification(pane_id: Option, cx: &mut App) { #[cfg(windows)] if let Some(hwnd) = native_hwnd(window) { use windows_sys::Win32::UI::WindowsAndMessaging::{ - IsIconic, SW_RESTORE, ShowWindow, + BringWindowToTop, IsIconic, IsWindowVisible, SW_RESTORE, SW_SHOW, + SetForegroundWindow, ShowWindow, }; + // Consult native visibility: tray/OS state can differ from the + // workspace flag. A notification click is an explicit request + // to reveal and foreground this window, including a hidden HWND. unsafe { let hwnd = hwnd as *mut std::ffi::c_void; if IsIconic(hwnd) != 0 { ShowWindow(hwnd, SW_RESTORE); + } else if IsWindowVisible(hwnd) == 0 { + ShowWindow(hwnd, SW_SHOW); + } + BringWindowToTop(hwnd); + if SetForegroundWindow(hwnd) == 0 { + log::debug!("notification foreground request deferred to GPUI activation"); } } } diff --git a/nebula_app/src/main.rs b/nebula_app/src/main.rs index cf44c5a..0199ab6 100644 --- a/nebula_app/src/main.rs +++ b/nebula_app/src/main.rs @@ -255,6 +255,13 @@ fn main() -> Result<(), Box> { Some(Subcommands::NotifyTest) => std::process::exit(crate::notify::notify_test()), #[cfg(windows)] Some(Subcommands::SetupAi(options)) => { + if let Some(distro) = &options.wsl { + std::process::exit(crate::platform::wsl_hooks::setup_cli( + distro, + options.wsl_user.as_deref(), + options.remove, + )); + } if let Some(destination) = &options.ssh { std::process::exit(crate::ssh_session::setup_ai_cli(destination, options.remove)); } diff --git a/nebula_app/src/notify.rs b/nebula_app/src/notify.rs index 528e831..efb4154 100644 --- a/nebula_app/src/notify.rs +++ b/nebula_app/src/notify.rs @@ -48,8 +48,8 @@ pub fn init_proxy(proxy: EventLoopProxy) { let _ = PROXY.set(proxy); } +use crate::platform::notifications::ToastActivation; pub use crate::platform::notifications::notify_test; -use crate::platform::notifications::{ToastActivation, toast_clickable}; #[cfg(feature = "gpui-shell")] static GPUI_ACTIVATION: OnceLock> = @@ -94,6 +94,8 @@ pub enum Notification { Bell { program: Option }, /// A tracked command finished (OSC 133;C started it, 133;D ended it). CommandDone { duration: Duration, program: Option }, + /// Shell-reported nonzero exit status, including short failed commands. + CommandFailed { duration: Duration, program: Option, exit_code: i32 }, /// Free-text notification from a program (OSC 9, iTerm style). Claude /// Code emits these (with the turn's actual message) when its notif /// channel is `iterm2`/`iterm2_with_bell`. Carries the tracked program @@ -137,6 +139,19 @@ pub(crate) fn clamp_toast_body(body: &str) -> String { } impl Notification { + pub(crate) fn command_finished( + program: Option, + duration: Duration, + exit_code: Option, + hook_seen: bool, + ) -> Option { + if let Some(exit_code) = exit_code.filter(|code| *code != 0) { + return Some(Self::CommandFailed { program, duration, exit_code }); + } + (!hook_seen && duration >= COMMAND_NOTIFY_MIN) + .then_some(Self::CommandDone { program, duration }) + } + pub(crate) fn from_ai_hook( event: &crate::ai_hook::AiHookEvent, message: Option, @@ -172,11 +187,12 @@ impl Notification { pub(crate) fn is_failure(&self) -> bool { matches!( self, - Self::AiTurnIssue { - outcome: crate::ai_hook::AiTurnOutcome::Failed - | crate::ai_hook::AiTurnOutcome::Incomplete, - .. - } + Self::CommandFailed { .. } + | Self::AiTurnIssue { + outcome: crate::ai_hook::AiTurnOutcome::Failed + | crate::ai_hook::AiTurnOutcome::Incomplete, + .. + } ) } @@ -187,6 +203,7 @@ impl Notification { Self::AiTurn { .. } | Self::AiTurnIssue { .. } => return true, Self::Bell { program } | Self::CommandDone { program, .. } + | Self::CommandFailed { program, .. } | Self::Text { program, .. } => program.as_deref(), }; program.is_some_and(|program| crate::ai_agents::AgentKind::parse(program).is_some()) @@ -208,18 +225,23 @@ impl Notification { ), None => (crate::brand::NAME.to_owned(), "终端响铃".to_owned()), }, - Self::CommandDone { duration, program } => { - let secs = duration.as_secs(); - let human = if secs >= 60 { - format!("{}m {}s", secs / 60, secs % 60) - } else { - format!("{secs}s") - }; - match program { - Some(p) => (p.clone(), format!("命令完成,用时 {human}")), - None => (crate::brand::NAME.to_owned(), format!("命令完成,用时 {human}")), - } - }, + Self::CommandDone { duration, program } => ( + program.clone().unwrap_or_else(|| crate::brand::NAME.to_owned()), + notification_language().format( + crate::i18n::Message::NotificationCommandFinished, + &[("seconds", &duration.as_secs().to_string())], + ), + ), + Self::CommandFailed { duration, program, exit_code } => ( + program.clone().unwrap_or_else(|| crate::brand::NAME.to_owned()), + notification_language().format( + crate::i18n::Message::NotificationCommandFailed, + &[ + ("seconds", &duration.as_secs().to_string()), + ("code", &exit_code.to_string()), + ], + ), + ), Self::Text { body, program } => match program { Some(p) => (p.clone(), body.clone()), None => (crate::brand::NAME.to_owned(), body.clone()), @@ -345,36 +367,86 @@ impl PaneFailureThrottle { #[cfg(any(feature = "gpui-shell", test))] #[derive(Default)] struct PaneNotificationThrottle { - recent: HashMap<(u64, bool), Instant>, + recent: HashMap<(u64, bool, Option), Instant>, } #[cfg(any(feature = "gpui-shell", test))] impl PaneNotificationThrottle { fn accepts(&mut self, pane_id: u64, attention: bool, now: Instant) -> bool { + self.accepts_request(pane_id, attention, None, now) + } + + fn accepts_request( + &mut self, + pane_id: u64, + attention: bool, + request: Option, + now: Instant, + ) -> bool { self.recent.retain(|_, last| now.saturating_duration_since(*last) < TOAST_THROTTLE); - if self.recent.contains_key(&(pane_id, attention)) { + if self.recent.contains_key(&(pane_id, attention, request)) { return false; } - self.recent.insert((pane_id, attention), now); + self.recent.insert((pane_id, attention, request), now); true } } #[cfg(feature = "gpui-shell")] pub(crate) fn deliver_gpui(notification: &Notification, pane_id: u64) { + deliver_gpui_with_choices(notification, pane_id, None); +} + +#[cfg(feature = "gpui-shell")] +pub(crate) fn deliver_gpui_with_choices( + notification: &Notification, + pane_id: u64, + confirmation: Option<(u64, Vec)>, +) { static THROTTLE: OnceLock> = OnceLock::new(); let accepted = THROTTLE .get_or_init(Mutex::default) .lock() .unwrap_or_else(|poison| poison.into_inner()) - .accepts(pane_id, notification.is_attention(), Instant::now()); + .accepts_request( + pane_id, + notification.is_attention(), + confirmation.as_ref().map(|c| c.0), + Instant::now(), + ); if !accepted { log::debug!("notify: toast suppressed for pane={pane_id}: {notification:?}"); return; } let (title, body) = notification.toast_text(); log::debug!("notify: system toast source for pane={pane_id}: {notification:?}"); - spawn_toast(title, body, application_activation(Some(pane_id))); + let actions = confirmation + .and_then(|(request_id, labels)| { + let sender = GPUI_ACTIVATION.get()?.clone(); + Some( + labels + .into_iter() + .enumerate() + .map(|(choice, label)| { + let sender = sender.clone(); + crate::platform::notifications::ToastAction { + label: label.chars().take(80).collect(), + activate: Arc::new(move || { + let _ = sender.send( + crate::gpui_shell::GpuiShellEvent::NotificationChoice { + pane_id, + request_id, + choice, + }, + ); + }), + } + }) + .collect(), + ) + }) + .unwrap_or_default(); + spawn_actionable_toast(title, body, application_activation(Some(pane_id)), actions); } /// Deliver `notification` for a window that is currently unfocused. @@ -436,12 +508,16 @@ pub(crate) fn toast(title: &str, body: &str) { } fn spawn_toast(title: String, body: String, activation: Option) { - if let Err(error) = std::thread::Builder::new() - .name("pebrel-toast".into()) - .spawn(move || toast_clickable(&title, &body, activation)) - { - log::warn!("notify: failed to spawn toast thread: {error}"); - } + spawn_actionable_toast(title, body, activation, Vec::new()); +} + +fn spawn_actionable_toast( + title: String, + body: String, + activation: Option, + actions: Vec, +) { + crate::platform::notifications::dispatch(title, body, activation, actions); } #[cfg(test)] @@ -456,6 +532,57 @@ mod delivery_tests { crate::ai_hook::parse_remote_envelope(envelope.as_bytes(), Some(12)).unwrap() } + #[test] + fn short_failures_notify_while_success_and_hook_completion_remain_quiet() { + for hook in [false, true] { + let failed = Notification::command_finished( + Some("cargo".into()), + Duration::from_millis(50), + Some(1), + hook, + ) + .unwrap(); + assert!(failed.is_failure()); + assert!(!failed.is_attention()); + assert!( + Notification::command_finished( + Some("cargo".into()), + Duration::from_millis(50), + Some(0), + hook + ) + .is_none() + ); + } + assert!( + Notification::command_finished( + Some("codex".into()), + Duration::from_secs(60), + Some(0), + true + ) + .is_none() + ); + assert!( + Notification::command_finished( + Some("cargo".into()), + Duration::from_secs(60), + Some(0), + false + ) + .is_some() + ); + } + + #[test] + fn successive_questions_have_independent_native_notification_identity() { + let mut throttle = PaneNotificationThrottle::default(); + let now = Instant::now(); + assert!(throttle.accepts_request(42, true, Some(100), now)); + assert!(!throttle.accepts_request(42, true, Some(100), now)); + assert!(throttle.accepts_request(42, true, Some(101), now)); + } + #[test] fn unfinished_background_tasks_suppress_every_terminal_turn_result() { for reason in ["stop", "error", "length", "aborted"] { diff --git a/nebula_app/src/platform/mod.rs b/nebula_app/src/platform/mod.rs index c92d34d..a82c44a 100644 --- a/nebula_app/src/platform/mod.rs +++ b/nebula_app/src/platform/mod.rs @@ -36,6 +36,7 @@ pub mod startup; pub(crate) mod update_installation; #[cfg(feature = "gpui-shell")] pub(crate) mod window_chrome; +pub(crate) mod wsl_hooks; pub use capabilities::CAPABILITIES; diff --git a/nebula_app/src/platform/notifications.rs b/nebula_app/src/platform/notifications.rs index 158b2a2..b95b751 100644 --- a/nebula_app/src/platform/notifications.rs +++ b/nebula_app/src/platform/notifications.rs @@ -3,6 +3,38 @@ pub(crate) type ToastActivation = std::sync::Arc; +#[derive(Clone)] +pub(crate) struct ToastAction { + pub label: String, + pub activate: ToastActivation, +} + +#[cfg(windows)] +mod windows; + +pub(crate) fn toast_clickable(title: &str, body: &str, activation: Option) { + toast_actionable(title, body, activation, Vec::new()); +} + +/// Dispatch owns the platform's worker and COM lifetime; notification policy +/// remains in the application capability. +pub(crate) fn dispatch( + title: String, + body: String, + activation: Option, + actions: Vec, +) { + #[cfg(windows)] + toast_actionable(&title, &body, activation, actions); + #[cfg(not(windows))] + if let Err(error) = std::thread::Builder::new() + .name("pebrel-toast".into()) + .spawn(move || toast_actionable(&title, &body, activation, actions)) + { + log::warn!("notify: failed to spawn toast thread: {error}"); + } +} + #[cfg(target_os = "macos")] static MACOS_READY: std::sync::OnceLock = std::sync::OnceLock::new(); @@ -40,44 +72,23 @@ pub fn show(title: &str, body: &str) { }); } -/// [`toast`], optionally wired for click-to-focus: activating the banner (or -/// its Action Center entry) surfaces `window` and, when a pane is named, its -/// tab. Uses the in-process WinRT Activated handler — no COM server, no -/// protocol registration. The one trade-off: clicks after Pebrel exited do -/// nothing, which is exactly right (there is nothing left to focus). #[cfg(windows)] -pub(crate) fn toast_clickable(title: &str, body: &str, activation: Option) { - use tauri_winrt_notification::{IconCrop, Toast}; - - // Attribute the toast to the Pebrel AUMID so it reads "Pebrel" instead of - // "Windows PowerShell". One registry write, cached per process. - win::ensure_aumid(); - - let mut toast = Toast::new(win::AUMID) - .title(title) - .text1(body) - .duration(tauri_winrt_notification::Duration::Short); - // Belt and braces: besides the AUMID IconUri (which some Windows builds - // cache stale), embed the logo per-toast as appLogoOverride so the banner - // always carries the Pebrel mark next to the message. - if let Some(icon) = win::icon_path() { - toast = toast.icon(&icon, IconCrop::Square, crate::brand::NAME); - } - if let Some(activation) = activation { - toast = toast.on_activated(move |_action| { - activation(); - Ok(()) - }); - } - - match toast.show() { - Ok(()) => log::debug!("notify: toast shown"), - Err(err) => log::warn!("notify: toast failed: {err}"), - } +pub(crate) fn toast_actionable( + title: &str, + body: &str, + activation: Option, + actions: Vec, +) { + windows::enqueue(title, body, activation, actions); } #[cfg(any(target_os = "linux", target_os = "macos"))] -pub(crate) fn toast_clickable(title: &str, body: &str, activation: Option) { +pub(crate) fn toast_actionable( + title: &str, + body: &str, + activation: Option, + actions: Vec, +) { #[cfg(target_os = "macos")] { if objc2_foundation::NSBundle::mainBundle().bundleIdentifier().is_none() { @@ -95,14 +106,21 @@ pub(crate) fn toast_clickable(title: &str, body: &str, activation: Option { - if let Some(activation) = activation { + if activation.is_some() || !actions.is_empty() { let result = handle.wait_for_response(move |response: ¬ify_rust::NotificationResponse| { if response.is_default_action() || matches!(response, notify_rust::NotificationResponse::Action(action) if action == "default") { - activation(); + if let Some(activation) = &activation { activation(); } + } else if let notify_rust::NotificationResponse::Action(id) = response { + if let Some(index) = id.strip_prefix("choice-").and_then(|id| id.parse::().ok()) + && let Some(action) = actions.get(index) + { (action.activate)(); } } }); if let Err(error) = result { @@ -115,7 +133,12 @@ pub(crate) fn toast_clickable(title: &str, body: &str, activation: Option) { +pub(crate) fn toast_actionable( + title: &str, + body: &str, + _activation: Option, + _actions: Vec, +) { crate::platform::notifications::show(title, body); } diff --git a/nebula_app/src/platform/notifications/windows.rs b/nebula_app/src/platform/notifications/windows.rs new file mode 100644 index 0000000..5a65a73 --- /dev/null +++ b/nebula_app/src/platform/notifications/windows.rs @@ -0,0 +1,143 @@ +//! Own WinRT notification objects until their bounded retention expires. +//! A successful Show RPC alone does not keep Activated callbacks alive. + +use std::collections::VecDeque; +use std::sync::{OnceLock, mpsc}; + +use ::windows::Data::Xml::Dom::XmlDocument; +use ::windows::Foundation::TypedEventHandler; +use ::windows::UI::Notifications::{ + ToastActivatedEventArgs, ToastNotification, ToastNotificationManager, +}; +use ::windows::Win32::System::Com::{COINIT_MULTITHREADED, CoInitializeEx, CoUninitialize}; +use ::windows::core::{IInspectable, Interface}; + +use super::{ToastAction, ToastActivation, win}; + +struct Request { + title: String, + body: String, + open: Option, + actions: Vec, +} + +pub(super) fn enqueue( + title: &str, + body: &str, + open: Option, + actions: Vec, +) { + static WORKER: OnceLock>> = OnceLock::new(); + let worker = WORKER.get_or_init(|| { + let (sender, receiver) = mpsc::sync_channel(32); + std::thread::Builder::new() + .name("pebrel-native-notifications".into()) + .spawn(move || run(receiver)) + .map(|_| sender) + .map_err(|error| log::warn!("notify: could not start native worker: {error}")) + .ok() + }); + let Some(worker) = worker else { return }; + if let Err(error) = + worker.try_send(Request { title: title.to_owned(), body: body.to_owned(), open, actions }) + { + log::warn!("notify: native queue unavailable: {error}"); + } +} + +fn run(receiver: mpsc::Receiver) { + // All WinRT objects live on this MTA thread; callbacks only enqueue app events. + if let Err(error) = unsafe { CoInitializeEx(None, COINIT_MULTITHREADED) }.ok() { + log::warn!("notify: COM initialization failed: {error}"); + return; + } + let mut live = VecDeque::new(); + for request in receiver { + match show(request) { + Ok(notification) => { + if live.len() == 64 { + live.pop_front(); + } + live.push_back(notification); + }, + Err(error) => log::warn!("notify: native delivery failed: {error}"), + } + } + drop(live); + unsafe { CoUninitialize() }; +} + +fn show(request: Request) -> ::windows::core::Result { + win::ensure_aumid(); + let document = XmlDocument::new()?; + document.LoadXml(&xml(&request).into())?; + let notification = ToastNotification::CreateToastNotification(&document)?; + notification.Activated(&TypedEventHandler::::new( + move |_, args| { + let argument = args + .as_ref() + .and_then(|args| args.cast::().ok()) + .and_then(|args| args.Arguments().ok()) + .map(|value| value.to_string()) + .unwrap_or_default(); + if argument.is_empty() { + if let Some(open) = &request.open { + open(); + } + } else if let Some(index) = + argument.strip_prefix("choice-").and_then(|id| id.parse::().ok()) + && let Some(action) = request.actions.get(index) + { + (action.activate)(); + } + Ok(()) + }, + ))?; + ToastNotificationManager::CreateToastNotifierWithId(&win::AUMID.into())?.Show(¬ification)?; + Ok(notification) +} + +fn xml(request: &Request) -> String { + let mut xml = format!( + "{}{}", + escape(&request.title), + escape(&request.body), + ); + if let Some(path) = win::icon_path() { + xml.push_str(&format!( + "", + escape(&path.to_string_lossy()) + )); + } + xml.push_str(""); + if !request.actions.is_empty() { + xml.push_str(""); + for (index, action) in request.actions.iter().take(5).enumerate() { + xml.push_str(&format!("", escape(&action.label))); + } + xml.push_str(""); + } + xml.push_str(""); + xml +} + +fn escape(text: &str) -> String { + text.chars() + .filter(|c| !c.is_control() || matches!(c, '\n' | '\r' | '\t')) + .collect::() + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn terminal_controls_and_markup_cannot_break_the_toast_document() { + assert_eq!(escape("\u{1b}\u{0}\"&'"), "<tool>"&'"); + } +} diff --git a/nebula_app/src/platform/wsl_hooks.rs b/nebula_app/src/platform/wsl_hooks.rs new file mode 100644 index 0000000..2bf9942 --- /dev/null +++ b/nebula_app/src/platform/wsl_hooks.rs @@ -0,0 +1,8 @@ +//! Guest hook setup is a Windows transport capability. Provider policy stays in ai_hook. +#[cfg(windows)] +mod windows; +#[cfg(windows)] +pub(crate) use windows::{prepare, setup_cli}; + +#[cfg(not(windows))] +pub(crate) fn prepare(_options: &mut nebula_terminal::tty::Options) {} diff --git a/nebula_app/src/platform/wsl_hooks/windows.rs b/nebula_app/src/platform/wsl_hooks/windows.rs new file mode 100644 index 0000000..8aac37c --- /dev/null +++ b/nebula_app/src/platform/wsl_hooks/windows.rs @@ -0,0 +1,236 @@ +//! WSL transport for the shared POSIX hook installer. No provider policy here. +//! +//! A guest has its own home and executable search path. Windows hook files and +//! Windows PIDs cannot stand in for either. Delivery uses the pane's existing +//! authenticated OSC protocol, with a fresh token for every PTY. + +use std::collections::HashMap; +use std::io::{Read as _, Seek as _, Write as _}; +use std::process::{Command, Stdio}; +use std::sync::{OnceLock, mpsc}; +use std::time::{Duration, Instant}; + +use nebula_terminal::tty; +use serde_json::json; + +use crate::ai_hook::remote::{self, Action, Snapshot}; + +const BUDGET: Duration = Duration::from_secs(8); +const MAX_OUTPUT: u64 = 20 * 1024 * 1024; +const RETRY_AFTER: Duration = Duration::from_secs(300); + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +struct Target { + distro: Option, + user: Option, +} + +impl Target { + fn from_shell(shell: &tty::Shell) -> Option { + (crate::display::extract_program(shell.program()).as_deref() == Some("wsl")).then(|| Self { + distro: crate::shell_detect::wsl_launch_distro(shell.program(), shell.args()) + .map(str::to_owned), + user: crate::shell_detect::wsl_launch_user(shell.program(), shell.args()) + .map(str::to_owned), + }) + } + + fn command(&self) -> Command { + let mut command = Command::new("wsl.exe"); + if let Some(distro) = &self.distro { + command.args(["--distribution", distro]); + } + if let Some(user) = &self.user { + command.args(["--user", user]); + } + // This is a separate setup process. The user's interactive shell and + // its launch arguments are left to WSL's normal /etc/passwd selection. + command.args(["--exec", "sh", "-lc", "exec python3 -"]); + crate::platform::process::hidden_command(&mut command); + command + } + + fn exchange(&self, request: &[u8], deadline: Instant) -> Result { + let run = || -> std::io::Result { + let mut input = tempfile::tempfile()?; + input.write_all(request)?; + input.rewind()?; + let mut output = tempfile::tempfile()?; + let mut child = self + .command() + .stdin(input) + .stdout(output.try_clone()?) + .stderr(Stdio::null()) + .spawn()?; + let status = loop { + let poll = child.try_wait().and_then(|status| { + if status.is_none() + && (Instant::now() >= deadline || output.metadata()?.len() > MAX_OUTPUT) + { + return Err(std::io::Error::other("WSL hook setup exceeded its budget")); + } + Ok(status) + }); + match poll { + Ok(Some(status)) => break status, + Ok(None) => { + std::thread::sleep(Duration::from_millis(10)); + }, + Err(error) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(error); + }, + } + }; + if !status.success() || output.metadata()?.len() > MAX_OUTPUT { + return Err(std::io::Error::other("WSL hook setup failed")); + } + output.rewind()?; + let mut text = String::new(); + output.take(MAX_OUTPUT).read_to_string(&mut text)?; + Ok(text) + }; + run().map_err(|error| error.to_string()) + } + + fn install(&self, action: Action) -> Result<(), String> { + let deadline = Instant::now() + BUDGET; + let raw = self.exchange(&remote::request(&json!({"action":"snapshot"})), deadline)?; + let snapshot: Snapshot = serde_json::from_value(remote::response(&raw)?) + .map_err(|_| "invalid WSL integration snapshot")?; + let Some(files) = snapshot.for_wsl().plan(action)? else { return Ok(()) }; + if files.is_empty() { + return Ok(()); + } + let raw = + self.exchange(&remote::request(&json!({"action":"apply", "files":files})), deadline)?; + if remote::response(&raw)?.get("applied").and_then(serde_json::Value::as_bool) != Some(true) + { + return Err("WSL integration did not confirm installation".into()); + } + log::info!("ai_hook: WSL hook files updated; Codex may require review in /hooks"); + Ok(()) + } +} + +/// Called during local PTY preparation. Guest I/O belongs to one setup worker; +/// the caller checks the local preference and prepares the pane environment. +pub(crate) fn prepare(options: &mut tty::Options) { + let Some(target) = options.shell.as_ref().and_then(Target::from_shell) else { return }; + if nebula_settings::RawSettings::load().bool_on("ai_hooks") == Some(false) { + return; + } + let token = match remote::new_token() { + Ok(token) => token, + Err(error) => { + log::warn!("ai_hook: WSL channel unavailable: {error}"); + return; + }, + }; + options.env.insert(remote::TOKEN_ENV.into(), token); + let wslenv = options.env.entry("WSLENV".into()).or_default(); + if !wslenv.split(':').any(|entry| entry.split('/').next() == Some(remote::TOKEN_ENV)) { + if !wslenv.is_empty() { + wslenv.push(':'); + } + wslenv.push_str(remote::TOKEN_ENV); + } + match worker().and_then(|tx| tx.try_send(target).ok()) { + Some(()) => {}, + None => log::warn!("ai_hook: WSL setup queue unavailable; retry with setup-ai --wsl"), + } +} + +fn worker() -> Option<&'static mpsc::SyncSender> { + static WORKER: OnceLock>> = OnceLock::new(); + WORKER + .get_or_init(|| { + let (tx, rx) = mpsc::sync_channel::(16); + std::thread::Builder::new() + .name("pebrel-wsl-hooks".into()) + .spawn(move || { + let mut attempted: HashMap = HashMap::new(); + while let Ok(target) = rx.recv() { + if nebula_settings::RawSettings::load().bool_on("ai_hooks") == Some(false) { + continue; + } + attempted.retain(|_, at| at.elapsed() < RETRY_AFTER); + if attempted.contains_key(&target) { + continue; + } + // Cache has a fixed bound even if custom launchers invent users. + if attempted.len() >= 32 { + attempted.clear(); + } + attempted.insert(target.clone(), Instant::now()); + if let Err(error) = target.install(Action::Automatic) { + log::warn!("ai_hook: WSL integration unavailable: {error}"); + } + } + }) + .ok() + .map(|_| tx) + }) + .as_ref() +} + +pub(crate) fn setup_cli(distro: &str, user: Option<&str>, remove: bool) -> i32 { + let target = Target { distro: Some(distro.into()), user: user.map(str::to_owned) }; + match target.install(if remove { Action::Remove } else { Action::Install }) { + Ok(()) => { + println!( + "{}", + if remove { + "WSL hook integration removed." + } else { + "WSL hook integration installed. Open a new Pebrel WSL terminal. Codex may require review in /hooks." + } + ); + 0 + }, + Err(error) => { + eprintln!("WSL hook integration: {error}"); + 1 + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn setup_targets_the_same_distribution_and_user_without_changing_the_shell() { + let shell = tty::Shell::new( + "wsl.exe".into(), + vec![ + "-d".into(), + "Debian custom".into(), + "-u".into(), + "alice".into(), + "--cd".into(), + "/work/my project".into(), + ], + ); + let target = Target::from_shell(&shell).unwrap(); + let command = target.command(); + let args: Vec<_> = + command.get_args().map(|arg| arg.to_string_lossy().into_owned()).collect(); + assert_eq!( + args, + [ + "--distribution", + "Debian custom", + "--user", + "alice", + "--exec", + "sh", + "-lc", + "exec python3 -" + ] + ); + assert_eq!(shell.args()[4..], ["--cd", "/work/my project"]); + assert!(Target::from_shell(&tty::Shell::new("pwsh.exe".into(), vec![])).is_none()); + } +} diff --git a/nebula_app/src/ssh_session.rs b/nebula_app/src/ssh_session.rs index 2f7571f..4b8761b 100644 --- a/nebula_app/src/ssh_session.rs +++ b/nebula_app/src/ssh_session.rs @@ -1745,15 +1745,7 @@ fn clear_secret(secret: &mut Option>) { } fn remote_hook_token() -> io::Result { - let mut bytes = [0u8; 16]; - getrandom::fill(&mut bytes) - .map_err(|err| io::Error::other(format!("生成 SSH Hook 令牌失败: {err}")))?; - let mut token = String::with_capacity(32); - for byte in bytes { - use std::fmt::Write as _; - let _ = write!(token, "{byte:02x}"); - } - Ok(token) + crate::ai_hook::remote::new_token() } fn render_error( diff --git a/nebula_terminal/src/event_loop.rs b/nebula_terminal/src/event_loop.rs index 0cff468..30e0397 100644 --- a/nebula_terminal/src/event_loop.rs +++ b/nebula_terminal/src/event_loop.rs @@ -298,6 +298,7 @@ pub struct EventLoop { event_proxy: U, drain_on_exit: bool, ref_test: bool, + remote_hook_token: Option, } impl EventLoop @@ -324,6 +325,7 @@ where event_proxy, drain_on_exit, ref_test, + remote_hook_token: None, }) } @@ -331,6 +333,11 @@ where EventLoopSender { sender: self.tx.clone(), poller: self.poll.clone() } } + /// Bind in-band hook delivery to this PTY before its reader starts. + pub fn set_remote_hook_token(&mut self, token: String) { + self.remote_hook_token = Some(token); + } + /// Drain the channel. /// /// Returns `Err(())` when a shutdown message was received; otherwise the @@ -511,6 +518,9 @@ where pub fn spawn(mut self) -> JoinHandle<(Self, State)> { thread::spawn_named("PTY reader", move || { let mut state = State::default(); + if let Some(token) = self.remote_hook_token.take() { + state.stream.set_remote_hook_token(token); + } let mut buf = [0u8; READ_BUFFER_SIZE]; let poll_opts = PollMode::Level; @@ -918,6 +928,44 @@ mod tests { use crate::term::Config; use crate::term::test::TermSize; + #[test] + fn authenticated_hook_frames_survive_every_chunk_boundary_and_reject_other_panes() { + #[derive(Clone, Default)] + struct Listener(std::sync::Arc>>>); + impl EventListener for Listener { + fn send_event(&self, event: Event) { + if let Event::AiHookEnvelope(envelope) = event { + self.0.lock().unwrap().push(envelope); + } + } + } + // Base64 for a small envelope; authentication is checked before delivery. + let frame = b"\x1b]777;nebula-hook;0123456789abcdef0123456789abcdef;bmVidWxhLWhvb2svMSBzb3VyY2U9Y29kZXgKe30=\x07"; + for token in [ + None, + Some("ffffffffffffffffffffffffffffffff"), + Some("0123456789abcdef0123456789abcdef"), + ] { + for split in 0..=frame.len() { + let listener = Listener::default(); + let mut terminal = + Term::new(Config::default(), &TermSize::new(80, 24), listener.clone()); + let mut stream = StreamProcessor::default(); + if let Some(token) = token { + stream.set_remote_hook_token(token.into()); + } + stream.feed(&mut terminal, &listener, &frame[..split]); + stream.feed(&mut terminal, &listener, &frame[split..]); + let events = listener.0.lock().unwrap(); + if token == Some("0123456789abcdef0123456789abcdef") { + assert_eq!(events.as_slice(), [b"nebula-hook/1 source=codex\n{}".to_vec()]); + } else { + assert!(events.is_empty()); + } + } + } + } + #[test] fn shell_identity_cwd_and_title_keep_wire_order_across_chunk_boundaries() { #[derive(Clone, Default)] diff --git a/scripts/tests/test_remote_hooks.py b/scripts/tests/test_remote_hooks.py index f192b6d..bb32b16 100644 --- a/scripts/tests/test_remote_hooks.py +++ b/scripts/tests/test_remote_hooks.py @@ -40,6 +40,27 @@ class RemoteHooksTests(unittest.TestCase): self.environment.start() self.addCleanup(self.environment.stop) + def test_discovery_finds_nvm_cli_and_its_interpreter_without_login_path(self): + files = module("remote_files") + bindir = self.root / ".nvm/versions/node/v24.18.0/bin" + bindir.mkdir(parents=True) + node = bindir / "node" + node.write_text("#!/bin/sh\nprintf 'hooks stable true\\n'\n") + node.chmod(0o700) + cli = bindir / "codex" + cli.write_text("#!/usr/bin/env node\n") + cli.chmod(0o700) + with patch.dict(os.environ, {"PATH": "/usr/bin:/bin"}): + self.assertEqual(files.find_program("codex"), str(cli)) + self.assertEqual(files.capture(str(cli), "features", "list"), "hooks stable true\n") + configured = self.root / "bin" + configured.mkdir() + preferred = configured / "codex" + preferred.write_text("#!/bin/sh\n") + preferred.chmod(0o700) + with patch.dict(os.environ, {"PATH": str(configured)}): + self.assertEqual(files.find_program("codex"), str(preferred)) + def test_compare_and_swap_does_not_overwrite_concurrent_user_edit(self): files = module("remote_files") snapshot = files.snapshot()