From 80cf48943d2cd31c900f211b8ea50a632bd85605 Mon Sep 17 00:00:00 2001 From: galact <1582093495@qq.com> Date: Sat, 19 Sep 2026 03:13:30 +0800 Subject: [PATCH 1/9] fix: restore Ctrl+C handling for Windows terminal children --- nebula_app/src/main.rs | 8 ++ nebula_app/src/platform/startup.rs | 15 +++ .../src/platform/startup/console_tests.rs | 113 ++++++++++++++++++ 3 files changed, 136 insertions(+) create mode 100644 nebula_app/src/platform/startup/console_tests.rs diff --git a/nebula_app/src/main.rs b/nebula_app/src/main.rs index 46a9546..22dcdf6 100644 --- a/nebula_app/src/main.rs +++ b/nebula_app/src/main.rs @@ -223,6 +223,14 @@ fn main() -> Result<(), Box> { } } + #[cfg(windows)] + if options.subcommands.is_none() + && let Err(error) = platform::startup::prepare_console_for_gui() + { + platform::startup::report_error(&error, true); + return Err(error.into()); + } + // 产品主窗:GPUI 作为 nebula.exe 的 UI 层,从主线程直接进 GPUI // 消息循环,winit 旧壳完全不启动。1.1.0 安装包 / 双击 / 资源管理器 // 右键都走这里;`--legacy-shell` 才回旧壳。 diff --git a/nebula_app/src/platform/startup.rs b/nebula_app/src/platform/startup.rs index f2ce192..692846a 100644 --- a/nebula_app/src/platform/startup.rs +++ b/nebula_app/src/platform/startup.rs @@ -6,6 +6,21 @@ pub(crate) fn report_error(error: &dyn std::fmt::Display, gui_launch: bool) { let _ = (error, gui_launch); } +#[cfg(windows)] +pub(crate) fn prepare_console_for_gui() -> std::io::Result<()> { + // The NULL-handler ignore flag is inherited by ConPTY shells. Normalize it + // once, before any workers or terminals exist; never toggle it around spawn. + // SAFETY: this changes only this process's console attribute, not its parent + // or any existing child. The call also works without an attached console. + if unsafe { windows_sys::Win32::System::Console::SetConsoleCtrlHandler(None, 0) } == 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) +} + +#[cfg(all(test, windows))] +mod console_tests; + pub fn prepare_gui() { #[cfg(target_os = "macos")] { diff --git a/nebula_app/src/platform/startup/console_tests.rs b/nebula_app/src/platform/startup/console_tests.rs new file mode 100644 index 0000000..b03d925 --- /dev/null +++ b/nebula_app/src/platform/startup/console_tests.rs @@ -0,0 +1,113 @@ +//! Console attributes are process-global: each scenario runs in a disposable process. +use std::env; +use std::process::{Child, Command}; +use std::thread; +use std::time::{Duration, Instant, SystemTime}; + +use windows_sys::Win32::System::Console::{ + AllocConsole, CTRL_C_EVENT, FreeConsole, GenerateConsoleCtrlEvent, SetConsoleCtrlHandler, +}; + +const TEST: &str = "platform::startup::console_tests::gui_startup_restores_child_ctrl_c"; + +struct TestChild(Child); + +impl Drop for TestChild { + fn drop(&mut self) { + // Failure cleanup only: a killed child never counts as an interruption. + if !matches!(self.0.try_wait(), Ok(Some(_))) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } + } +} + +struct ReadyFile(std::path::PathBuf); + +impl Drop for ReadyFile { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.0); + } +} + +fn wait(child: &mut TestChild) -> std::process::ExitStatus { + let deadline = Instant::now() + Duration::from_secs(12); + loop { + if let Some(status) = child.0.try_wait().unwrap() { + return status; + } + assert!(Instant::now() < deadline, "console test child exceeded its deadline"); + thread::sleep(Duration::from_millis(20)); + } +} + +#[test] +fn gui_startup_restores_child_ctrl_c() { + let Ok(role) = env::var("PEBREL_CONSOLE_TEST_ROLE") else { + for detached in [false, true] { + let mut child = TestChild( + Command::new(env::current_exe().unwrap()) + .args(["--exact", TEST, "--nocapture"]) + .env("PEBREL_CONSOLE_TEST_ROLE", "host") + .env("PEBREL_CONSOLE_TEST_DETACHED", detached.to_string()) + .spawn() + .unwrap(), + ); + assert!(wait(&mut child).success(), "GUI console startup failed (detached={detached})"); + } + return; + }; + if role == "target" { + // The product uses the GUI subsystem, so its test executable must + // attach explicitly just as main does before it can receive events. + unsafe { + windows_sys::Win32::System::Console::AttachConsole( + windows_sys::Win32::System::Console::ATTACH_PARENT_PROCESS, + ); + } + let ready = env::var_os("PEBREL_CONSOLE_TEST_READY").unwrap(); + std::fs::write(ready, b"ready").unwrap(); + thread::sleep(Duration::from_secs(5)); + return; + } + + // Never broadcast to cargo's or the developer's console. + unsafe { FreeConsole() }; + assert_ne!(unsafe { AllocConsole() }, 0); + assert_ne!(unsafe { SetConsoleCtrlHandler(None, 1) }, 0); + let detached = env::var("PEBREL_CONSOLE_TEST_DETACHED").unwrap() == "true"; + if detached { + assert_ne!(unsafe { FreeConsole() }, 0); + } + super::prepare_console_for_gui().unwrap(); + if detached { + // The API must also succeed for Explorer/detached launches. + return; + } + let stamp = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_nanos(); + let ready_file = ReadyFile( + env::temp_dir().join(format!("pebrel-ctrl-ready-{}-{stamp}", std::process::id())), + ); + let ready = &ready_file.0; + let mut target = TestChild( + Command::new(env::current_exe().unwrap()) + .args(["--exact", TEST, "--nocapture"]) + .env("PEBREL_CONSOLE_TEST_ROLE", "target") + .env("PEBREL_CONSOLE_TEST_READY", &ready) + .spawn() + .unwrap(), + ); + let deadline = Instant::now() + Duration::from_secs(3); + while !ready.exists() { + assert!(Instant::now() < deadline, "target did not become ready"); + thread::sleep(Duration::from_millis(20)); + } + // Shield only the disposable test driver AFTER the child inherited the + // production startup state. This cannot change the existing child's state. + assert_ne!(unsafe { SetConsoleCtrlHandler(None, 1) }, 0); + let started = Instant::now(); + assert_ne!(unsafe { GenerateConsoleCtrlEvent(CTRL_C_EVENT, 0) }, 0); + let status = wait(&mut target); + assert_eq!(status.code().map(|code| code as u32), Some(0xc000013a)); + assert!(started.elapsed() < Duration::from_secs(3), "child did not interrupt promptly"); +} From 32c0ee4e1c4bcb45bde34585d251c48ba248c960 Mon Sep 17 00:00:00 2001 From: galact <1582093495@qq.com> Date: Sat, 19 Sep 2026 03:13:31 +0800 Subject: [PATCH 2/9] fix: distinguish Pi turn outcomes in completion notifications --- nebula_app/i18n/en-US.json | 3 + nebula_app/i18n/zh-CN.json | 3 + nebula_app/src/ai_hook.rs | 19 ++++ nebula_app/src/ai_hook/win.rs | 41 +++++-- .../src/gpui_shell/terminal/view/runtime.rs | 18 +-- .../gpui_shell/terminal/view/startup_tests.rs | 68 ++++++++++++ nebula_app/src/notify.rs | 50 +++++++++ scripts/tests/test_pi_bridge.mjs | 105 +++++++++++++++++- 8 files changed, 289 insertions(+), 18 deletions(-) diff --git a/nebula_app/i18n/en-US.json b/nebula_app/i18n/en-US.json index cea9a51..68e8243 100644 --- a/nebula_app/i18n/en-US.json +++ b/nebula_app/i18n/en-US.json @@ -1,4 +1,7 @@ { + "notifications": { + "turn_failed": "This turn failed." + }, "language": { "self_name": "English", "system": "Follow system", diff --git a/nebula_app/i18n/zh-CN.json b/nebula_app/i18n/zh-CN.json index b2927dd..20d6932 100644 --- a/nebula_app/i18n/zh-CN.json +++ b/nebula_app/i18n/zh-CN.json @@ -1,4 +1,7 @@ { + "notifications": { + "turn_failed": "本轮失败。" + }, "language": { "self_name": "简体中文", "system": "跟随系统", diff --git a/nebula_app/src/ai_hook.rs b/nebula_app/src/ai_hook.rs index 9b5d8b0..d5770aa 100644 --- a/nebula_app/src/ai_hook.rs +++ b/nebula_app/src/ai_hook.rs @@ -332,6 +332,7 @@ pub struct AiHookEvent { /// AI CLI identity, used as the toast title. pub source: String, pub kind: AiHookKind, + pub outcome: Option, /// Human text when the event carries one (claude's notification message, /// codex's last assistant message). pub message: Option, @@ -369,6 +370,14 @@ pub struct AiHookEvent { pub attention: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AiTurnOutcome { + Success, + Failed, + Cancelled, + Unknown, +} + impl AiHookEvent { pub fn capabilities(&self) -> AiHookCapabilities { capabilities_for(&self.source) @@ -767,6 +776,16 @@ fn parse_envelope(bytes: &[u8]) -> Option { answer, answer_cwd, pane, + outcome: if source == "pi" && kind == AiHookKind::TurnDone { + payload.get("outcome").map(|value| match value.as_str() { + Some("success") => AiTurnOutcome::Success, + Some("failed") => AiTurnOutcome::Failed, + Some("cancelled") => AiTurnOutcome::Cancelled, + _ => AiTurnOutcome::Unknown, + }) + } else { + None + }, source, kind, message, diff --git a/nebula_app/src/ai_hook/win.rs b/nebula_app/src/ai_hook/win.rs index c3dc696..6aa88a9 100644 --- a/nebula_app/src/ai_hook/win.rs +++ b/nebula_app/src/ai_hook/win.rs @@ -607,14 +607,14 @@ export const PebrelNotify = async ({ $, directory, worktree }) => { } "#; -// Pi 官方扩展 API 在 agent_start/agent_end 提供稳定的回合边界。扩展只做 -// fire-and-forget 转发,且 NEBULA_HOOK_EXE 不存在时完全静默,因此全局安装 -// 不会影响从其他终端启动的 Pi。 +// agent_end 只结束一次尝试;Pi 0.80.4+ 的 agent_settled 才覆盖自动重试。 +// 旧版仍使用原有结束边界。无 hook 环境变量时,扩展不启动子进程。 const PI_EXTENSION_TS: &str = r#"// Pebrel ↔ Pi bridge — AUTO-GENERATED by Pebrel, do not edit. import { spawn } from "node:child_process"; import { openSync, readSync, closeSync } from "node:fs"; import { randomUUID } from "node:crypto"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { VERSION } from "@earendil-works/pi-coding-agent"; // Only provider IDs and the first JSONL metadata line are recovery identities. // A timestamped basename or a process ID cannot be handed to pi --session. @@ -644,6 +644,9 @@ function sessionFor(ctx: any): { session_id?: string; session_file?: string } { export default function (pi: ExtensionAPI) { let active = false; + let outcome = "unknown"; + const [major, minor, patch] = VERSION.split(".").map(Number); + const hasSettled = major > 0 || minor > 80 || (minor === 80 && patch >= 4); let sequence = 0; const sequenceEpoch = BigInt(Date.now()) * 1000000n; const bridge_instance = randomUUID(); @@ -656,6 +659,7 @@ export default function (pi: ExtensionAPI) { const event_id = `${bridge_instance}:${bridge_sequence}`; spawn(hook, ["pi", JSON.stringify({ kind, + ...(kind === "done" ? { outcome } : {}), ...identity, bridge_instance, bridge_sequence, @@ -669,18 +673,35 @@ export default function (pi: ExtensionAPI) { } catch (_) {} }; - pi.on("agent_start", async (_event, ctx) => { - active = true; - send("prompt", ctx); - }); - pi.on("agent_end", async (_event, ctx) => { + const finish = (ctx: any) => { if (!active) return; active = false; send("done", ctx); + }; + pi.on("agent_start", async (_event, ctx) => { + active = true; + outcome = "unknown"; + send("prompt", ctx); }); - try { pi.on("session_start", async (_event, ctx) => send("session-start", ctx)); } catch (_) {} + pi.on("agent_end", async (event, ctx) => { + if (!active) return; + const last = event.messages?.findLast((message: any) => message.role === "assistant"); + outcome = last?.stopReason === "stop" ? "success" + : last?.stopReason === "error" ? "failed" + : last?.stopReason === "aborted" ? "cancelled" : "unknown"; + if (!hasSettled) finish(ctx); + }); + if (hasSettled) pi.on("agent_settled", async (_event, ctx) => finish(ctx)); + try { pi.on("session_start", async (_event, ctx) => { + active = false; + outcome = "unknown"; + send("session-start", ctx); + }); } catch (_) {} try { pi.on("tool_result", async (_event, ctx) => send("tool-complete", ctx)); } catch (_) {} - try { pi.on("session_shutdown", async (_event, ctx) => send("session-end", ctx)); } catch (_) {} + try { pi.on("session_shutdown", async (_event, ctx) => { + active = false; + send("session-end", ctx); + }); } catch (_) {} } "#; diff --git a/nebula_app/src/gpui_shell/terminal/view/runtime.rs b/nebula_app/src/gpui_shell/terminal/view/runtime.rs index 26ae8b0..db34829 100644 --- a/nebula_app/src/gpui_shell/terminal/view/runtime.rs +++ b/nebula_app/src/gpui_shell/terminal/view/runtime.rs @@ -949,6 +949,14 @@ impl TerminalView { self.agent_status_rule = Some(format!("hook.background_tasks.active={active}")); }, + AiHookKind::TurnDone + if event.outcome.is_some_and(|outcome| { + outcome != crate::ai_hook::AiTurnOutcome::Success + }) => + { + self.agent_status = AgentStatus::Idle; + self.agent_turn_active = false; + }, AiHookKind::TurnDone | AiHookKind::NeedsAttention => { let screen_asks = event.kind == AiHookKind::TurnDone && self.screen_tail_asks(); @@ -979,14 +987,10 @@ impl TerminalView { })); } } else if from_primary_agent - && event.kind == AiHookKind::TurnDone - && matches!(self.agent_status, AgentStatus::Done | AgentStatus::Blocked) + && let Some(notification) = event + .turn_notification(super::ui_language(), self.agent_status == AgentStatus::Blocked) { - cx.emit(TerminalViewEvent::Notification(crate::notify::Notification::AiTurn { - program: event.source.clone(), - message: event.message.clone(), - attention: self.agent_status == AgentStatus::Blocked, - })); + cx.emit(TerminalViewEvent::Notification(notification)); } cx.emit(TerminalViewEvent::TitleChanged); cx.notify(); diff --git a/nebula_app/src/gpui_shell/terminal/view/startup_tests.rs b/nebula_app/src/gpui_shell/terminal/view/startup_tests.rs index e269634..70943e3 100644 --- a/nebula_app/src/gpui_shell/terminal/view/startup_tests.rs +++ b/nebula_app/src/gpui_shell/terminal/view/startup_tests.rs @@ -136,6 +136,74 @@ fn review_regression_cold_resume_survives_initial_prompt_and_clears_on_exit( })); } +#[gpui::test] +fn pi_cancelled_turn_becomes_idle_instead_of_completed(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + for payload in [ + r#"{"kind":"prompt","session_id":"pi-test"}"#, + r#"{"kind":"done","outcome":"cancelled","session_id":"pi-test"}"#, + ] { + let wire = format!("nebula-hook/1 source=pi\n{payload}"); + let event = crate::ai_hook::parse_remote_envelope(wire.as_bytes(), Some(view.pane_id)) + .expect("Pi hook"); + assert!(view.handle_ai_hook(&event, cx)); + } + assert_eq!(view.agent_status, crate::ai_agents::AgentStatus::Idle); + assert!(!view.agent_turn_active); + }); +} + +#[gpui::test] +fn pi_outcomes_emit_only_the_matching_notification(cx: &mut TestAppContext) { + use std::cell::RefCell; + use std::rc::Rc; + + let (view, window, _) = open(cx); + let notifications = Rc::new(RefCell::new(Vec::new())); + let observed = notifications.clone(); + let _subscription = view.update(window, |_, cx| { + cx.subscribe(&view, move |_, _, event, _| { + if let TerminalViewEvent::Notification(notification) = event { + observed.borrow_mut().push(notification.clone()); + } + }) + }); + for (outcome, status, expected_count) in [ + ("cancelled", crate::ai_agents::AgentStatus::Idle, 0), + ("unknown", crate::ai_agents::AgentStatus::Idle, 0), + ("failed", crate::ai_agents::AgentStatus::Idle, 1), + ("success", crate::ai_agents::AgentStatus::Done, 2), + ] { + view.update(window, |view, cx| { + for payload in [ + serde_json::json!({"kind": "prompt", "session_id": "pi-test"}), + serde_json::json!({"kind": "done", "outcome": outcome, "session_id": "pi-test"}), + ] { + let wire = format!("nebula-hook/1 source=pi\n{payload}"); + let event = + crate::ai_hook::parse_remote_envelope(wire.as_bytes(), Some(view.pane_id)) + .expect("Pi hook"); + assert!(view.handle_ai_hook(&event, cx)); + } + assert_eq!(view.agent_status, status); + if outcome != "success" { + assert!(!view.agent_turn_active); + } + }); + assert_eq!(notifications.borrow().len(), expected_count, "{outcome}"); + } + let notifications = notifications.borrow(); + let crate::notify::Notification::AiTurn { message, attention, .. } = ¬ifications[0] else { + panic!("expected a failure notification"); + }; + assert_eq!( + message.as_deref(), + Some(super::ui_language().text(crate::i18n::Message::NotificationsTurnFailed)) + ); + assert!(!attention); +} + #[gpui::test] fn failed_cold_resume_keeps_target_for_retry(cx: &mut TestAppContext) { let (view, window, receiver) = open(cx); diff --git a/nebula_app/src/notify.rs b/nebula_app/src/notify.rs index 40f32a2..1705b22 100644 --- a/nebula_app/src/notify.rs +++ b/nebula_app/src/notify.rs @@ -106,6 +106,31 @@ pub enum Notification { AiTurn { program: String, message: Option, attention: bool }, } +impl crate::ai_hook::AiHookEvent { + pub(crate) fn turn_notification( + &self, + language: crate::display::UiLanguage, + attention: bool, + ) -> Option { + use crate::ai_hook::{AiHookKind, AiTurnOutcome}; + if self.kind != AiHookKind::TurnDone || self.active_background_tasks() > 0 { + return None; + } + let message = match self.outcome { + Some(AiTurnOutcome::Cancelled | AiTurnOutcome::Unknown) => return None, + Some(AiTurnOutcome::Failed) => { + Some(language.text(crate::i18n::Message::NotificationsTurnFailed).to_owned()) + }, + Some(AiTurnOutcome::Success) | None => self.message.clone(), + }; + Some(Notification::AiTurn { + program: self.source.clone(), + message, + attention: attention && self.outcome != Some(AiTurnOutcome::Failed), + }) + } +} + /// Longest notification body any shell may render. /// /// A toast/banner is a glance layer, not a reader. Codex's turn-complete @@ -309,6 +334,31 @@ fn spawn_toast(title: String, body: String, activation: Option) mod delivery_tests { use super::*; + #[test] + fn pi_outcomes_do_not_turn_failure_or_cancellation_into_success() { + for (outcome, expected) in [ + (Some("failed"), Some("本轮失败。")), + (Some("cancelled"), None), + (Some("unknown"), None), + (Some("future"), None), + ] { + let payload = serde_json::json!({"kind": "done", "outcome": outcome}); + let wire = format!("nebula-hook/1 source=pi\n{payload}"); + let event = crate::ai_hook::parse_remote_envelope(wire.as_bytes(), Some(1)).unwrap(); + let notification = event.turn_notification(crate::display::UiLanguage::ZhCn, false); + assert_eq!(notification.map(|value| value.toast_text().1).as_deref(), expected); + } + for outcome in [None, Some("success")] { + let mut payload = serde_json::json!({"kind": "done"}); + if let Some(outcome) = outcome { + payload["outcome"] = outcome.into(); + } + let wire = format!("nebula-hook/1 source=pi\n{payload}"); + let event = crate::ai_hook::parse_remote_envelope(wire.as_bytes(), Some(1)).unwrap(); + assert!(event.turn_notification(crate::display::UiLanguage::EnUs, false).is_some()); + } + } + #[test] fn every_registered_ai_is_recognized_for_bell_text_and_command_events() { for agent in crate::ai_agents::AgentKind::ALL { diff --git a/scripts/tests/test_pi_bridge.mjs b/scripts/tests/test_pi_bridge.mjs index 572ee65..db581bd 100644 --- a/scripts/tests/test_pi_bridge.mjs +++ b/scripts/tests/test_pi_bridge.mjs @@ -15,9 +15,13 @@ globalThis.__pebrelBridgeSpawn = (_hook, args) => { events.push(JSON.parse(args[1])); return { unref() {} }; }; +globalThis.__pebrelPiVersion = '0.85.1'; const executable = stripTypeScriptTypes(embedded.replace( 'import { spawn } from "node:child_process";', 'const spawn = globalThis.__pebrelBridgeSpawn;', +).replace( + 'import { VERSION } from "@earendil-works/pi-coding-agent";', + 'const VERSION = globalThis.__pebrelPiVersion;', ) + '\nexport { sessionFor };'); const bridge = await import('data:text/javascript;base64,' + Buffer.from(executable).toString('base64')); mkdirSync(new URL('tmp/', root), { recursive: true }); @@ -50,6 +54,104 @@ test('stale or corrupt file cannot replace direct native identity', () => { } }); +test('a retryable failed attempt must not report successful completion', async () => { + const previous = process.env.PEBREL_HOOK_EXE; + process.env.PEBREL_HOOK_EXE = 'test-hook'; + const firstEvent = events.length; + try { + const callbacks = new Map(); + bridge.default({ on: (kind, callback) => callbacks.set(kind, callback) }); + const ctx = { ...context('retrying'), isIdle: () => false }; + await callbacks.get('agent_start')({}, ctx); + await callbacks.get('agent_end')({ messages: [ + { role: 'assistant', stopReason: 'error', errorMessage: 'synthetic retryable failure' }, + ] }, ctx); + assert.deepEqual(events.slice(firstEvent).map(event => event.kind), ['prompt']); + } finally { + events.splice(firstEvent); + if (previous === undefined) delete process.env.PEBREL_HOOK_EXE; + else process.env.PEBREL_HOOK_EXE = previous; + } +}); + +for (const [reason, outcome] of [['stop', 'success'], ['error', 'failed'], ['aborted', 'cancelled'], ['future', 'unknown']]) { + test(`settled ${reason} reports ${outcome} once, without leaking error text`, async () => { + const previous = process.env.PEBREL_HOOK_EXE; + process.env.PEBREL_HOOK_EXE = 'test-hook'; + const firstEvent = events.length; + try { + const callbacks = new Map(); + bridge.default({ on: (kind, callback) => callbacks.set(kind, callback) }); + const ctx = context('settled'); + await callbacks.get('agent_start')({}, ctx); + await callbacks.get('agent_end')({ messages: [ + { role: 'assistant', stopReason: reason, errorMessage: 'SECRET-provider-request' }, + ] }, ctx); + assert.deepEqual(events.slice(firstEvent).map(event => event.kind), ['prompt']); + assert.equal(typeof callbacks.get('agent_settled'), 'function'); + await callbacks.get('agent_settled')({}, ctx); + await callbacks.get('agent_settled')({}, ctx); + assert.deepEqual(events.slice(firstEvent).map(event => event.kind), ['prompt', 'done']); + assert.equal(events.at(-1).outcome, outcome); + assert.ok(!JSON.stringify(events.slice(firstEvent)).includes('SECRET')); + } finally { + events.splice(firstEvent); + if (previous === undefined) delete process.env.PEBREL_HOOK_EXE; + else process.env.PEBREL_HOOK_EXE = previous; + } + }); +} + +test('retry success replaces the failed attempt; switching sessions drops unfinished results', async () => { + const previous = process.env.PEBREL_HOOK_EXE; + process.env.PEBREL_HOOK_EXE = 'test-hook'; + const firstEvent = events.length; + try { + const callbacks = new Map(); + bridge.default({ on: (kind, callback) => callbacks.set(kind, callback) }); + const ctx = context('retry-success'); + await callbacks.get('agent_start')({}, ctx); + await callbacks.get('agent_end')({ messages: [{ role: 'assistant', stopReason: 'error' }] }, ctx); + await callbacks.get('agent_start')({}, ctx); + await callbacks.get('agent_end')({ messages: [{ role: 'assistant', stopReason: 'stop' }] }, ctx); + await callbacks.get('agent_settled')({}, ctx); + assert.equal(events.slice(firstEvent).filter(event => event.kind === 'done').length, 1); + assert.equal(events.at(-1).outcome, 'success'); + await callbacks.get('agent_start')({}, ctx); + await callbacks.get('agent_end')({ messages: [{ role: 'assistant', stopReason: 'error' }] }, ctx); + await callbacks.get('session_start')({}, context('new-session')); + await callbacks.get('agent_settled')({}, context('new-session')); + assert.equal(events.at(-1).kind, 'session-start'); + } finally { + events.splice(firstEvent); + if (previous === undefined) delete process.env.PEBREL_HOOK_EXE; + else process.env.PEBREL_HOOK_EXE = previous; + } +}); + +test('pre-settled Pi versions retain agent_end fallback without registering unsupported events', async () => { + const legacy = await import('data:text/javascript;base64,' + Buffer.from(executable.replace( + 'const VERSION = globalThis.__pebrelPiVersion;', 'const VERSION = "0.80.3";', + )).toString('base64')); + const previous = process.env.PEBREL_HOOK_EXE; + process.env.PEBREL_HOOK_EXE = 'test-hook'; + const firstEvent = events.length; + try { + const callbacks = new Map(); + legacy.default({ on: (kind, callback) => callbacks.set(kind, callback) }); + assert.equal(callbacks.has('agent_settled'), false); + const ctx = context('legacy'); + await callbacks.get('agent_start')({}, ctx); + await callbacks.get('agent_end')({ messages: [{ role: 'assistant', stopReason: 'stop' }] }, ctx); + assert.deepEqual(events.slice(firstEvent).map(event => event.kind), ['prompt', 'done']); + assert.equal(events.at(-1).outcome, 'success'); + } finally { + events.splice(firstEvent); + if (previous === undefined) delete process.env.PEBREL_HOOK_EXE; + else process.env.PEBREL_HOOK_EXE = previous; + } +}); + test('session switch retains one process ordering stream and reports identity before turns', async () => { const previous = process.env.PEBREL_HOOK_EXE; process.env.PEBREL_HOOK_EXE = 'test-hook'; @@ -59,7 +161,8 @@ test('session switch retains one process ordering stream and reports identity be await callbacks.get('session_start')({}, context('first')); await callbacks.get('session_start')({}, context('second')); await callbacks.get('agent_start')({}, context('second')); - await callbacks.get('agent_end')({}, context('second')); + await callbacks.get('agent_end')({ messages: [{ role: 'assistant', stopReason: 'stop' }] }, context('second')); + await callbacks.get('agent_settled')({}, context('second')); assert.deepEqual(events.map(event => event.session_id), ['first', 'second', 'second', 'second']); assert.deepEqual(events.map(event => event.kind), ['session-start', 'session-start', 'prompt', 'done']); assert.equal(new Set(events.map(event => event.bridge_instance)).size, 1); From 96f481339c288c87e98e6762b950f312b7131afd Mon Sep 17 00:00:00 2001 From: galact <1582093495@qq.com> Date: Sat, 19 Sep 2026 18:08:03 +0800 Subject: [PATCH 3/9] fix: preserve application extended colors across terminal themes --- nebula_app/src/display/terminal_color.rs | 11 ++--- nebula_app/src/display/ui/theme.rs | 17 +------ nebula_app/src/gpui_shell/config.rs | 45 ++++++++++++++---- nebula_settings/src/custom_theme.rs | 37 +++++++++++---- nebula_settings/src/lib.rs | 5 +- nebula_terminal/src/tty/windows/mod.rs | 60 ++++++++++++++++++------ 6 files changed, 117 insertions(+), 58 deletions(-) diff --git a/nebula_app/src/display/terminal_color.rs b/nebula_app/src/display/terminal_color.rs index b715e8e..2b0fd43 100644 --- a/nebula_app/src/display/terminal_color.rs +++ b/nebula_app/src/display/terminal_color.rs @@ -142,11 +142,7 @@ pub(crate) fn is_fixed_color(color: Color, overrides: &Colors) -> bool { match color { Color::Spec(_) => true, Color::Named(name) => overrides[name].is_some(), - Color::Indexed(index) => { - overrides[index as usize].is_some() - // 16..=23 是 Nebula 随主题重建的 powerline 动态槽位。 - || index >= 24 - }, + Color::Indexed(index) => overrides[index as usize].is_some() || index >= 16, } } @@ -328,7 +324,10 @@ mod tests { let mut overrides = Colors::default(); assert!(is_fixed_color(Color::Spec(VteRgb { r: 1, g: 2, b: 3 }), &overrides)); assert!(is_fixed_color(Color::Indexed(24), &overrides)); - assert!(!is_fixed_color(Color::Indexed(16), &overrides)); + for index in 16..=255 { + assert!(is_fixed_color(Color::Indexed(index), &overrides)); + } + assert!(!is_fixed_color(Color::Indexed(15), &overrides)); assert!(!is_fixed_color(Color::Named(NamedColor::Red), &overrides)); overrides[NamedColor::Red] = Some(VteRgb { r: 1, g: 2, b: 3 }); diff --git a/nebula_app/src/display/ui/theme.rs b/nebula_app/src/display/ui/theme.rs index b3bbab6..c654690 100644 --- a/nebula_app/src/display/ui/theme.rs +++ b/nebula_app/src/display/ui/theme.rs @@ -22,12 +22,6 @@ use crate::display::color::{List, Rgb}; use crate::renderer::ui::Rgba; use nebula_terminal::vte::ansi::NamedColor; -/// First 256-color palette slot claimed for the powerline prompt chips -/// (16..=23: icon bg/fg, path bg/fg, branch bg/fg, time bg/fg). Chosen at the -/// very start of the 6×6×6 cube — the darkest corner, rarely load-bearing for -/// TUIs — so hijacking eight slots stays invisible in practice. -pub(crate) const POWERLINE_SLOT0: usize = 16; - /// Built-in chrome themes exposed from the settings panel. The original seven /// Nebula looks remain unchanged; Nord/Paper are an additive dark/light pair /// carrying their own complete palettes. @@ -292,14 +286,6 @@ impl NebulaTheme { *colors = *defaults; let p = self.palette(); colors[NamedColor::Background] = p.term_bg; - // Powerline prompt slots: the injected prompt paints its segment chips - // with indexed colors 16..=23 instead of baked-in truecolor, so a - // theme switch remaps the palette and every chip ALREADY PRINTED in - // scrollback recolors instantly — indexed cells resolve the palette at - // draw time; truecolor is frozen the moment it is printed. - for (i, rgb) in self.powerline_colors().into_iter().enumerate() { - colors[POWERLINE_SLOT0 + i] = rgb; - } if let Some(exact) = self.exact_term_colors() { let foreground = rgb8(exact.foreground); colors[NamedColor::Foreground] = foreground; @@ -376,8 +362,7 @@ impl NebulaTheme { } } - /// Segment colors for the injected powerline prompt, published into the - /// 256-color palette at [`POWERLINE_SLOT0`]`..+8` by [`Self::apply_term_colors`]. + /// 旧提示符段色参考;不发布到终端扩展色表,避免覆盖应用的索引色。 /// Order: icon bg/fg, path bg/fg, branch bg/fg, time bg/fg — one flat color /// per chip (the old per-character truecolor gradient could never follow a /// theme switch retroactively, which users read as "the prompt is stuck"). diff --git a/nebula_app/src/gpui_shell/config.rs b/nebula_app/src/gpui_shell/config.rs index 462949b..7abe24a 100644 --- a/nebula_app/src/gpui_shell/config.rs +++ b/nebula_app/src/gpui_shell/config.rs @@ -361,7 +361,7 @@ fn runtime_background( } /// 主题叠加在 toml 配色之上(镜像旧壳 `apply_term_colors` 的范围与次序): -/// 背景永远替换;Powerline 槽位 16..=23 替换。原有主题保持旧合同:仅浅色 +/// 背景永远替换;保留应用使用的扩展色槽。原有主题保持旧合同:仅浅色 /// 主题替换前景与 ANSI-16;自带完整 palette 的主题(Nord/Paper)应用其明确色表。 fn apply_theme(palette: &mut Palette, theme: nebula_settings::ThemeName) { let resolved = crate::gpui_shell::theme::ResolvedTheme::builtin(theme, None); @@ -398,9 +398,6 @@ fn apply_resolved_theme(palette: &mut Palette, resolved: &crate::gpui_shell::the return; } palette.background = rgba8(term.background); - for (i, color) in term.powerline.into_iter().enumerate() { - set_indexed(palette, nebula_settings::POWERLINE_SLOT0 + i as u8, rgba8(color)); - } if let Some(exact) = term.exact { let foreground = rgba8(resolved.foreground_override().unwrap_or(exact.foreground)); palette.foreground = foreground; @@ -440,11 +437,6 @@ fn apply_resolved_theme(palette: &mut Palette, resolved: &crate::gpui_shell::the } } -fn set_indexed(palette: &mut Palette, index: u8, color: gpui::Rgba) { - palette.indexed.retain(|(existing, _)| *existing != index); - palette.indexed.push((index, color)); -} - fn rgba8(color: nebula_settings::Rgb8) -> gpui::Rgba { gpui::Rgba { r: color[0] as f32 / 255.0, @@ -810,6 +802,41 @@ mod tests { assert!(!settings.term_config().default_cursor_style.blinking); } + #[test] + fn builtin_themes_preserve_application_extended_colors() { + use nebula_terminal::term::color::Colors; + use nebula_terminal::vte::ansi::{Color, Rgb}; + + for theme in ThemeName::BUILTIN { + let mut palette = Palette::default(); + apply_theme(&mut palette, theme); + let overrides = Colors::default(); + for (index, expected) in [ + (16, [0, 0, 0]), + (17, [0, 0, 95]), + (18, [0, 0, 135]), + (19, [0, 0, 175]), + (20, [0, 0, 215]), + (21, [0, 0, 255]), + (22, [0, 95, 0]), + (23, [0, 95, 95]), + (231, [255, 255, 255]), + (255, [238, 238, 238]), + ] { + assert_eq!( + palette.resolve(Color::Indexed(index), &overrides, false), + rgba8(expected), + "{} index {index}", + theme.prompt_name() + ); + assert_eq!( + palette.query_reply(index as usize, &overrides), + Rgb { r: expected[0], g: expected[1], b: expected[2] } + ); + } + } + } + #[test] fn existing_dark_theme_keeps_user_terminal_colors() { let mut palette = Palette::default(); diff --git a/nebula_settings/src/custom_theme.rs b/nebula_settings/src/custom_theme.rs index 3dcd9b1..68850a7 100644 --- a/nebula_settings/src/custom_theme.rs +++ b/nebula_settings/src/custom_theme.rs @@ -60,13 +60,7 @@ impl ThemeDefinition { } }); - let mut indexed = IndexedPalette::from_ansi(exact.ansi); - // These slots are part of Nebula's existing prompt contract. Keeping - // them in the snapshot means a custom theme recolors prompt chips and - // the rest of the terminal from one value object. - for (offset, color) in term.powerline.into_iter().enumerate() { - indexed.colors[16 + offset] = color; - } + let indexed = IndexedPalette::from_ansi(exact.ansi); Self { name: base.prompt_name().to_owned(), @@ -724,6 +718,33 @@ fn relative_luminance(color: Rgb8) -> f64 { mod tests { use super::*; + #[test] + fn builtin_snapshots_preserve_standard_extended_colors() { + // 提示符配色不能覆盖应用使用的 xterm 色立方。 + for name in ThemeName::BUILTIN { + let snapshot = ThemeDefinition::from_builtin(name); + for (index, expected) in [ + (16, [0, 0, 0]), + (17, [0, 0, 95]), + (18, [0, 0, 135]), + (19, [0, 0, 175]), + (20, [0, 0, 215]), + (21, [0, 0, 255]), + (22, [0, 95, 0]), + (23, [0, 95, 95]), + (231, [255, 255, 255]), + (255, [238, 238, 238]), + ] { + assert_eq!( + snapshot.terminal.palette.colors[index], + expected, + "{} index {index}", + name.prompt_name() + ); + } + } + } + #[test] fn builtin_snapshot_keeps_declared_exact_colors() { let nord = ThemeDefinition::from_builtin(ThemeName::Nord); @@ -733,7 +754,7 @@ mod tests { assert_eq!(nord.terminal.cursor, exact.cursor); assert_eq!(nord.terminal.selection_background, exact.selection_background); assert_eq!(nord.terminal.palette.ansi_colors(), exact.ansi); - assert_eq!(nord.terminal.palette.colors[16], ThemeName::Nord.term_theme().powerline[0]); + assert_eq!(nord.terminal.palette.colors[16], [0, 0, 0]); assert_eq!(nord.ui.selection[3], 255); assert_eq!(nord.ui.line[3], 255); diff --git a/nebula_settings/src/lib.rs b/nebula_settings/src/lib.rs index 8250775..bb36c90 100644 --- a/nebula_settings/src/lib.rs +++ b/nebula_settings/src/lib.rs @@ -513,13 +513,10 @@ pub struct TermTheme { /// 只有自带完整终端 palette 的主题使用;现有七个主题保持 `None`,继续 /// 走原来的背景/浅色替换合同。 pub exact: Option, - /// Powerline 提示符段色,发布到 256 色表 [`POWERLINE_SLOT0`]`..+8`。 + /// 旧提示符段色参考;不再发布到终端扩展色表。 pub powerline: [Rgb8; 8], } -/// Powerline 槽位起点(索引色 16..=23)。 -pub const POWERLINE_SLOT0: u8 = 16; - /// 浅色主题的替换前景(#24292f)。 pub const LIGHT_FOREGROUND: Rgb8 = [36, 41, 47]; diff --git a/nebula_terminal/src/tty/windows/mod.rs b/nebula_terminal/src/tty/windows/mod.rs index d39a81b..bc4fb68 100644 --- a/nebula_terminal/src/tty/windows/mod.rs +++ b/nebula_terminal/src/tty/windows/mod.rs @@ -496,11 +496,8 @@ function global:prompt { if ($global:NebulaPromptCount -gt 1) { $leadingNewline = "`n" } } - # Segment colors come from the terminal's 256-color palette, slots - # 16..=23 (icon bg/fg, path bg/fg, branch bg/fg, time bg/fg), published - # per-theme by Nebula (theme.rs::apply_term_colors). Indexed colors mean a - # theme switch recolors every prompt already in scrollback — truecolor - # (the old scheme) is frozen the moment it prints. No theme file, no polling. + # 使用主题的 ANSI-16 索引,历史提示符仍随主题换色;不占用应用所需的 + # xterm 扩展色槽。路径和时间用默认前景/背景,避免浅色主题的 ANSI 灰阶低对比。 if ($userPrompt) { # 视觉全部来自用户提示符;Nebula 只补协议:133;A 标出提示符起点,标题 @@ -508,25 +505,25 @@ function global:prompt { $output = "$e]133;A$([char]7)$e]2;NEBULA|$cwd|$branch$([char]7)$userPrompt" } elseif (-not (Get-NebulaBoolSetting 'powerline' $true)) { $branchText = if ($branch) { " ($branch)" } else { "" } - $output = "$leadingNewline$e]133;A$([char]7)$e]2;NEBULA|$cwd|$branch$([char]7)$e[38;5;19m$loc$branchText $e[35m$NebPromptArrow $reset" + $output = "$leadingNewline$e]133;A$([char]7)$e]2;NEBULA|$cwd|$branch$([char]7)$e[38;5;6m$loc$branchText $e[35m$NebPromptArrow $reset" } else { $segs = New-Object System.Collections.ArrayList - [void]$segs.Add(@{ bg=16; fg=17; t=" $NebFolderIcon " }) - [void]$segs.Add(@{ bg=18; fg=19; t=" $loc " }) - if ($branch) { [void]$segs.Add(@{ bg=20; fg=21; t=" $NebGitBranchIcon $branch " }) } - [void]$segs.Add(@{ bg=22; fg=23; t=" $NebClockIcon $time " }) + [void]$segs.Add(@{ bg=4; fg='38;5;0'; t=" $NebFolderIcon " }) + [void]$segs.Add(@{ bg=$null; fg='39'; t=" $loc " }) + if ($branch) { [void]$segs.Add(@{ bg=$null; fg='38;5;6'; t=" $NebGitBranchIcon $branch " }) } + [void]$segs.Add(@{ bg=$null; fg='39'; t=" $NebClockIcon $time " }) # 49 = default background on both caps: the cap cell's square corners # always match the real terminal bg (any theme / wallpaper). $out = "$reset$e[38;5;$($segs[0].bg)m$e[49m$NebLeftRound$reset" for ($i = 0; $i -lt $segs.Count; $i++) { $s = $segs[$i] - $out += "$e[48;5;$($s.bg)m$e[38;5;$($s.fg)m$($s.t)" - if ($i -lt $segs.Count - 1) { - $nb = $segs[$i + 1].bg - $out += "$reset$e[38;5;$($s.bg)m$e[48;5;${nb}m$NebArrow$reset" + $bg = if ($null -eq $s.bg) { '49' } else { "48;5;$($s.bg)" } + $out += "$e[${bg}m$e[$($s.fg)m$($s.t)" + if ($null -ne $s.bg) { + $out += "$reset$e[38;5;$($s.bg)m$e[49m$NebArrow$reset" } else { - $out += "$reset$e[38;5;$($s.bg)m$e[49m$NebRightRound$reset" + $out += $reset } } $output = "$leadingNewline$e]133;A$([char]7)$e]2;NEBULA|$cwd|$branch$([char]7)$out`n`n$e[35m$NebPromptArrow $reset" @@ -1357,6 +1354,39 @@ $env:NEBULA_CONFIG_DIR = $env:PEBREL_CONFIG_DIR function global:Set-PSReadLineOption { } "#; + #[test] + fn powershell_prompt_uses_only_theme_ansi_slots() { + run_powershell_integration_case( + PS_PRELUDE, + r#" +function global:git { $global:LASTEXITCODE = 0; 'test-branch' } +foreach ($powerline in @($true, $false)) { + $global:TestPowerline = $powerline + function global:Get-NebulaBoolSetting { param($key, $default); $global:TestPowerline } + $rendered = prompt + $colors = [regex]::Matches($rendered, '\x1b\[(?:38|48);5;(\d+)m') + if ($colors.Count -eq 0) { throw 'No indexed prompt colors' } + foreach ($color in $colors) { + if ([int]$color.Groups[1].Value -gt 15) { + throw "Prompt occupied extended color $($color.Groups[1].Value)" + } + } + if ($powerline) { + $e = [char]27 + if (-not $rendered.Contains("$e[49m$e[39m $((Get-Location).Path) ")) { + throw 'Path must use theme default foreground and background' + } + if (-not $rendered.Contains("$e[49m$e[39m $([char]0xf017)")) { + throw 'Clock must use theme default foreground and background' + } + } + if ($rendered -notlike '*test-branch*') { throw 'Missing branch' } + if ($rendered -notlike "*$([char]27)]133;A*") { throw 'Missing prompt boundary' } +} +"#, + ); + } + #[test] fn powershell_integration_preserves_the_users_prediction_source() { run_powershell_integration_case( From 01306331ac7531d27747c9bef742f1fefb4b02a6 Mon Sep 17 00:00:00 2001 From: galact <1582093495@qq.com> Date: Sun, 20 Sep 2026 03:04:10 +0800 Subject: [PATCH 4/9] fix: track native CMD command activity without Clink --- nebula_app/src/display/input_state.rs | 26 + .../src/gpui_shell/terminal/event_mailbox.rs | 91 ++- nebula_app/src/gpui_shell/terminal/session.rs | 35 +- nebula_app/src/gpui_shell/terminal/view.rs | 19 + .../terminal/view/agent_activity.rs | 9 +- .../gpui_shell/terminal/view/completion.rs | 91 ++- .../src/gpui_shell/terminal/view/runtime.rs | 151 ++++ .../src/gpui_shell/terminal/view/startup.rs | 5 + .../gpui_shell/terminal/view/startup_tests.rs | 761 ++++++++++++++++++ nebula_terminal/src/tty/windows/cmd_prompt.rs | 77 ++ nebula_terminal/src/tty/windows/conpty.rs | 3 +- nebula_terminal/src/tty/windows/mod.rs | 1 + 12 files changed, 1249 insertions(+), 20 deletions(-) create mode 100644 nebula_terminal/src/tty/windows/cmd_prompt.rs diff --git a/nebula_app/src/display/input_state.rs b/nebula_app/src/display/input_state.rs index 6d54e5c..1b44016 100644 --- a/nebula_app/src/display/input_state.rs +++ b/nebula_app/src/display/input_state.rs @@ -158,6 +158,9 @@ pub(crate) fn nebula_shell_prompt_restored_from_raw_grid( expected_prompt: &str, env: &SuggestEnv, ) -> bool { + if terminal.mode().intersects(nebula_terminal::term::TermMode::ALT_SCREEN) { + return false; + } let cursor = terminal.grid().cursor.point; raw_grid_logical_line(terminal, cursor) .is_some_and(|line| shell_prompt_restored(expected_prompt, &line, env)) @@ -226,6 +229,13 @@ fn prompt_line_snapshot( input: input.strip_prefix(' ').unwrap_or(input).to_owned(), }); } + // CMD history recall and completion need not update the keystroke mirror. + if matches!(env, SuggestEnv::Local) + && let Some((head, input)) = text.split_once('>') + && cmd_path_prompt(head) + { + return Some(PromptLineSnapshot { prompt: format!("{head}>"), input: input.to_owned() }); + } if typed_tail.is_empty() || !text.ends_with(typed_tail) { return None; } @@ -249,12 +259,28 @@ fn shell_prompt_restored(expected_prompt: &str, current_line: &str, env: &Sugges return true; } + if matches!(env, SuggestEnv::Local) + && expected.strip_suffix('>').is_some_and(cmd_path_prompt) + && current.strip_suffix('>').is_some_and(cmd_path_prompt) + { + return true; + } + !env.is_this_machine() && remote_prompt_anchor(expected, marker) .zip(remote_prompt_anchor(current, marker)) .is_some_and(|(expected, current)| expected == current) } +fn cmd_path_prompt(head: &str) -> bool { + let bytes = head.as_bytes(); + bytes.len() >= 3 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && bytes[2] == b'\\' + && !head.contains(['<', '>', '|', '\r', '\n']) +} + fn safe_shell_prompt_marker(prompt: &str, marker: char, env: &SuggestEnv) -> bool { if matches!(marker, '$' | '#' | '%') { return true; diff --git a/nebula_app/src/gpui_shell/terminal/event_mailbox.rs b/nebula_app/src/gpui_shell/terminal/event_mailbox.rs index 7448665..839a417 100644 --- a/nebula_app/src/gpui_shell/terminal/event_mailbox.rs +++ b/nebula_app/src/gpui_shell/terminal/event_mailbox.rs @@ -1,8 +1,8 @@ //! Coalesce repaint wakeups before enqueueing; retain every semantic event. use std::pin::Pin; -use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; use std::task::{Context, Poll}; use futures::{Stream, channel::mpsc}; @@ -12,6 +12,18 @@ use nebula_terminal::event::Event; pub(super) struct EventSender { sender: mpsc::UnboundedSender, wake_pending: Arc, + pub(super) native_prompt: NativePromptState, +} + +#[derive(Clone, Default)] +pub(super) struct NativePromptState(Arc>); + +/// PTY 接收与用户输入共用的边界;队列消费只唤醒视图,不重新给旧标记盖章。 +#[derive(Clone, Copy, Default)] +pub(super) struct NativePrompt { + pub seen: bool, + pub input_epoch: u64, + pub pending: bool, } pub(super) struct EventReceiver { @@ -23,13 +35,88 @@ pub(super) fn channel() -> (EventSender, EventReceiver) { let (sender, receiver) = mpsc::unbounded(); let wake_pending = Arc::new(AtomicBool::new(false)); ( - EventSender { sender, wake_pending: wake_pending.clone() }, + EventSender { + sender, + wake_pending: wake_pending.clone(), + native_prompt: NativePromptState::default(), + }, EventReceiver { receiver, wake_pending }, ) } +/// Editing a CMD input line does not invalidate a prompt already received from +/// the PTY. Submission and unknown control sequences remain conservative. +pub(super) fn preserves_native_prompt(mut bytes: &[u8]) -> bool { + while let Some((&byte, rest)) = bytes.split_first() { + if byte != 0x1b { + if byte.is_ascii_control() && !matches!(byte, 8 | 9 | 127) { + return false; + } + bytes = rest; + continue; + } + let Some(sequence) = bytes.strip_prefix(b"\x1b[") else { return false }; + let Some(end) = sequence.iter().position(|byte| (0x40..=0x7e).contains(byte)) else { + return false; + }; + let parameters = &sequence[..end]; + match sequence[end] { + b'A'..=b'D' | b'H' | b'F' if parameters.is_empty() => {}, + b'~' if matches!(parameters, b"1" | b"2" | b"3" | b"4" | b"5" | b"6") => {}, + b'_' => { + let Ok(parameters) = std::str::from_utf8(parameters) else { return false }; + let mut fields = parameters.split(';'); + let mut values = [0u16; 6]; + for value in &mut values { + let Some(parsed) = fields.next().and_then(|field| field.parse().ok()) else { + return false; + }; + *value = parsed; + } + if fields.next().is_some() || values[3] > 1 { + return false; + } + // Vk;Sc;Uc;Kd;Cs;Rc: Enter is a boundary even if Uc is zero. + if values[0] == 13 || (values[2] < 32 && !matches!(values[2], 0 | 8 | 9 | 27)) { + return false; + } + }, + _ => return false, + } + bytes = &sequence[end + 1..]; + } + true +} + +impl NativePromptState { + pub(super) fn snapshot(&self) -> NativePrompt { + *self.0.lock().unwrap() + } + + pub(super) fn observe_input(&self, input_epoch: u64, preserves_prompt: bool) { + let mut prompt = self.0.lock().unwrap(); + prompt.input_epoch = input_epoch; + prompt.pending &= preserves_prompt; + } + + pub(super) fn consume_native_prompt(&self) { + self.0.lock().unwrap().pending = false; + } + + pub(super) fn observe_prompt(&self) { + let mut prompt = self.0.lock().unwrap(); + prompt.seen = true; + prompt.pending = true; + } +} + impl EventSender { pub(super) fn send(&self, event: Event) { + if matches!(&event, Event::UserVar { name, value } + if name == "pebrel_cmd_prompt" && value == "1") + { + self.native_prompt.observe_prompt(); + } let wake = matches!(event, Event::Wakeup); if wake && self.wake_pending.swap(true, Ordering::AcqRel) { return; diff --git a/nebula_app/src/gpui_shell/terminal/session.rs b/nebula_app/src/gpui_shell/terminal/session.rs index 6b8eb4d..45041b3 100644 --- a/nebula_app/src/gpui_shell/terminal/session.rs +++ b/nebula_app/src/gpui_shell/terminal/session.rs @@ -63,6 +63,7 @@ impl Dimensions for GridSize { pub struct TerminalSession { pub term: Arc>>, pub notifier: Notifier, + pub(super) native_prompt: super::event_mailbox::NativePromptState, /// PTY 直系 shell PID;关闭确认沿用旧壳 `busy_child(shell_pid)` 判据。 /// SSH 没有本地 shell 进程,固定为 0。 pub shell_pid: u32, @@ -71,21 +72,33 @@ pub struct TerminalSession { #[cfg(all(test, feature = "gpui-test-support"))] pub(super) fn test_session() -> (TerminalSession, std::sync::mpsc::Receiver) { - let (events, _) = super::event_mailbox::channel(); + let (session, receiver, _, _) = test_session_with_events(); + (session, receiver) +} + +#[cfg(all(test, feature = "gpui-test-support"))] +pub(super) fn test_session_with_events() -> ( + TerminalSession, + std::sync::mpsc::Receiver, + super::event_mailbox::EventReceiver, + EventProxy, +) { + let (events, event_rx) = super::event_mailbox::channel(); let (stages, _) = unbounded(); - let term = Term::new( - Config::default(), - &GridSize { columns: 80, screen_lines: 24 }, - EventProxy { events, stages }, - ); + let proxy = EventProxy { events, stages }; + let term = + Term::new(Config::default(), &GridSize { columns: 80, screen_lines: 24 }, proxy.clone()); let (sender, receiver) = nebula_terminal::event_loop::EventLoopSender::standalone().unwrap(); ( TerminalSession { term: Arc::new(FairMutex::new(term)), notifier: Notifier(sender), + native_prompt: proxy.events.native_prompt.clone(), shell_pid: 0, }, receiver, + event_rx, + proxy, ) } @@ -170,11 +183,12 @@ pub fn spawn( // NEBULA_PTY_RECORD=1 复用 ref_test 的 conout 录制(./nebula.recording), // 用于 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 notifier = Notifier(event_loop.channel()); let _io_thread = event_loop.spawn(); - Ok((TerminalSession { term, notifier, shell_pid }, rx, stage_rx)) + Ok((TerminalSession { term, notifier, native_prompt, shell_pid }, rx, stage_rx)) } /// 启动一个 SSH 直连会话(russh,与旧壳 `create_ssh_pane` 同一业务层): @@ -196,6 +210,7 @@ pub fn spawn_ssh( screen_lines: window_size.num_lines as usize, }; let term = Arc::new(FairMutex::new(Term::new(term_config, &grid, proxy.clone()))); + let native_prompt = proxy.events.native_prompt.clone(); let sender = crate::ssh_session::spawn_session_at( destination, initial_remote_cwd, @@ -204,5 +219,9 @@ pub fn spawn_ssh( proxy, )?; - Ok((TerminalSession { term, notifier: Notifier(sender), shell_pid: 0 }, rx, stage_rx)) + Ok(( + TerminalSession { term, notifier: Notifier(sender), native_prompt, shell_pid: 0 }, + rx, + stage_rx, + )) } diff --git a/nebula_app/src/gpui_shell/terminal/view.rs b/nebula_app/src/gpui_shell/terminal/view.rs index fd047b9..11e4c91 100644 --- a/nebula_app/src/gpui_shell/terminal/view.rs +++ b/nebula_app/src/gpui_shell/terminal/view.rs @@ -308,6 +308,11 @@ pub struct TerminalView { command_started: Option, /// 上次跑进程树探测的时刻,用于节流。 last_process_probe: Option, + prompt_process_probe: Option>, + prompt_input_epoch: u64, + native_prompt_seen: bool, + native_prompt_epoch: Option, + last_prompt_process_probe: Option, active_run: Option, last_run: Option, /// Shared lifecycle owns hook authority, session scope and fallback evidence. @@ -633,6 +638,12 @@ impl TerminalView { }, TermEvent::Bell => self.on_bell(cx), TermEvent::UserVar { name, value } => { + if name == "pebrel_cmd_prompt" + && value == "1" + && self.suggest.suggest_env.is_this_machine() + { + self.on_native_cmd_prompt(cx); + } self.suggest.completion_shell_report(&name, &value); cx.notify(); }, @@ -684,6 +695,7 @@ impl TerminalView { /// 输入后回到底部并请求重绘。 fn write_input(&mut self, bytes: Vec, cx: &mut Context) { + self.prompt_input_epoch = self.prompt_input_epoch.wrapping_add(1); self.path_drop.invalidate(); self.image_paste.observe_input(&bytes); self.confirmation.observe_input(&bytes); @@ -691,11 +703,17 @@ impl TerminalView { if let Some(session) = &self.session { { let mut term = session.term.lock(); + // Match the parser's Term-lock order before attributing queued prompts. + session.native_prompt.observe_input( + self.prompt_input_epoch, + super::event_mailbox::preserves_native_prompt(&bytes), + ); term.scroll_display(Scroll::Bottom); term.selection = None; } session.notifier.notify(bytes); } + self.sync_native_prompt(); self.restart_cursor_blink(cx); cx.notify(); } @@ -951,6 +969,7 @@ impl TerminalView { fn paste_now_impl(&mut self, text: &str, emit: bool, cx: &mut Context) { let normalized = text.replace("\r\n", "\r").replace('\n', "\r"); + self.capture_native_paste_submission(&normalized, cx); // 行镜像吃粘贴的字面文本;多行/控制字符由引擎侧作废(与旧壳 // `nebula_input_text` 的防注入契约一致)。 if !self.term_mode().contains(TermMode::ALT_SCREEN) { 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 ac4674a..1690287 100644 --- a/nebula_app/src/gpui_shell/terminal/view/agent_activity.rs +++ b/nebula_app/src/gpui_shell/terminal/view/agent_activity.rs @@ -117,6 +117,7 @@ impl TerminalView { self.flush_pending_runtime_submit(cx); self.flush_pending_shell_command(cx); self.reconcile_shell_activity(cx); + self.on_native_cmd_prompt(cx); self.probe_missing_codex_session(cx); let Some(session) = &self.session else { return }; let (prompt_restored, screen) = { @@ -143,12 +144,16 @@ impl TerminalView { (prompt_restored, screen) }; if prompt_restored + && !self.native_prompt_seen && self.pending_runtime_submit.is_none() && self.pending_shell_command.is_none() && !self.recovery.preparing() { - log::debug!("command lifecycle: shell prompt restored pane={}", self.pane_id); - self.finish_foreground_command(None, cx); + if self.suggest.suggest_env.is_this_machine() { + self.probe_restored_prompt(cx); + } else { + self.finish_foreground_command(None, cx); + } return; } let Some(screen) = screen else { return }; diff --git a/nebula_app/src/gpui_shell/terminal/view/completion.rs b/nebula_app/src/gpui_shell/terminal/view/completion.rs index bc958bb..20e5fed 100644 --- a/nebula_app/src/gpui_shell/terminal/view/completion.rs +++ b/nebula_app/src/gpui_shell/terminal/view/completion.rs @@ -11,13 +11,24 @@ impl TerminalView { /// line_buf 在光标移动/Tab 补全后就是拼接垃圾,不能进历史。Agent 已在 /// 前台时保留最初 shell 提示符,内部交互的 Enter 不得覆盖退出证据。 pub(super) fn commit_line(&mut self, cx: &mut Context) { - let agent_already_active = + self.sync_native_prompt(); + let agent_active = self.running_program.as_deref().and_then(crate::ai_agents::AgentKind::parse).is_some(); - if !agent_already_active { - self.suggest.pending_command_prompt = None; + let command_already_active = + self.command_running && !self.command_running_disproved || agent_active; + let native_submission = + self.native_prompt_epoch == Some(self.prompt_input_epoch) && !agent_active; + if command_already_active && !native_submission { + // Input belongs to the foreground command, not a new shell submission. + crate::display::nebula_clear_line(&mut self.suggest); + if agent_active { + self.agent_activity.input_sent(); + } + return; } + self.suggest.pending_command_prompt = None; #[cfg(windows)] - if !agent_already_active && let Some(session) = &self.session { + if let Some(session) = &self.session { let term = session.term.lock(); if !term.mode().intersects(TermMode::ALT_SCREEN | TermMode::VI) { let cursor = term.grid().cursor.point; @@ -41,10 +52,12 @@ impl TerminalView { self.suggest.pending_command_prompt = None; } } + let confirmed_submission = self.suggest.pending_command_prompt.is_some() + && !self.suggest.screen_line.trim().is_empty(); suggest::commit_line(&mut self.suggest); - if agent_already_active { - self.agent_activity.input_sent(); - return; + if confirmed_submission { + self.mark_submitted_command(native_submission); + cx.notify(); } if let Some(agent) = crate::ai_agents::AgentKind::parse_command(&self.suggest.last_committed) @@ -57,6 +70,70 @@ impl TerminalView { } } + fn mark_submitted_command(&mut self, native_submission: bool) { + if native_submission && self.command_running { + // 前一条的进程检查可能尚未返回;保留外层 Runtime run, + // 但下一次提交必须使旧命令/输入的异步结果失效。 + self.command_started = Some(std::time::Instant::now()); + self.prompt_process_probe = None; + self.last_prompt_process_probe = None; + } + self.mark_command_running(); + } + + pub(super) fn capture_native_paste_submission(&mut self, text: &str, cx: &mut Context) { + self.sync_native_prompt(); + let native_submission = self.native_prompt_epoch == Some(self.prompt_input_epoch); + if !self.native_prompt_seen + || (self.command_running && !self.command_running_disproved && !native_submission) + || self.runtime_agent().is_some() + || self + .term_mode() + .intersects(TermMode::ALT_SCREEN | TermMode::VI | TermMode::BRACKETED_PASTE) + { + return; + } + let Some((submitted, _)) = text.rsplit_once('\r') else { return }; + let prompt = self.session.as_ref().and_then(|session| { + let term = session.term.lock(); + crate::display::nebula_prompt_line_from_raw_grid( + &term, + term.grid().cursor.point, + &self.suggest.line_buf, + &self.suggest.suggest_env, + ) + }); + if let Some(line) = prompt { + if !line.input.trim().is_empty() || !submitted.trim().is_empty() { + // 尚无 PTY 回显,只捕获活动边界,不把猜测的粘贴内容写入历史。 + self.suggest.pending_command_prompt = Some(line.prompt); + self.mark_submitted_command(native_submission); + cx.notify(); + } + } + } + + /// Runtime writes text before its echo barrier; retain the idle prompt now, + /// rather than trying to infer it after marking the submission as running. + pub(super) fn capture_runtime_prompt(&mut self) { + if self.command_running || self.runtime_agent().is_some() { + return; + } + let Some(session) = &self.session else { return }; + let term = session.term.lock(); + if term.mode().intersects(TermMode::ALT_SCREEN | TermMode::VI) { + return; + } + if let Some(line) = crate::display::nebula_prompt_line_from_raw_grid( + &term, + term.grid().cursor.point, + "", + &self.suggest.suggest_env, + ) { + self.suggest.pending_command_prompt = Some(line.prompt); + } + } + /// 用元素在网格快照同一次 `Term` 锁内取得的提示行重算 ghost/弹窗。 /// 这与旧壳 `draw_pane` 的锁序一致,避免退格回显夹在 render/paint 两次 /// 取锁之间时拼成“旧提示 + 新光标”的跳动帧。 diff --git a/nebula_app/src/gpui_shell/terminal/view/runtime.rs b/nebula_app/src/gpui_shell/terminal/view/runtime.rs index bc14cf0..e81ee91 100644 --- a/nebula_app/src/gpui_shell/terminal/view/runtime.rs +++ b/nebula_app/src/gpui_shell/terminal/view/runtime.rs @@ -145,6 +145,9 @@ impl TerminalView { self.command_running_disproved = false; self.command_started = None; self.last_process_probe = None; + self.prompt_process_probe = None; + self.last_prompt_process_probe = None; + self.consume_native_prompt(); self.pending_runtime_submit = None; self.suggest.pending_command_prompt = None; self.awaiting_input = false; @@ -423,6 +426,9 @@ impl TerminalView { self.ensure_runtime_readable()?; let bytes = self.runtime_key_sequence(key, modifiers, repeat)?; let bytes_sent = bytes.len(); + if key == crate::runtime_api::RuntimeKey::Enter && modifiers == Default::default() { + self.commit_line(cx); + } self.write_input(bytes, cx); Ok(bytes_sent) } @@ -469,6 +475,7 @@ impl TerminalView { baseline_screen: self.runtime_screen_snapshot().unwrap_or_default(), submit_bytes, }); + self.capture_runtime_prompt(); let run = crate::runtime_api::begin_runtime_run(); let run_id = run.run_id; self.active_run = Some(run); @@ -515,6 +522,9 @@ impl TerminalView { )); } let recognized_agent = submit && self.runtime_agent().is_some(); + if submit { + self.capture_runtime_prompt(); + } let mut bytes = crate::input::terminal_input::build_runtime_text_sequence(&text, self.term_mode()); if submit { @@ -779,13 +789,149 @@ impl TerminalView { .detach(); } + pub(super) fn sync_native_prompt(&mut self) { + if !self.suggest.suggest_env.is_this_machine() { + return; + } + if let Some(session) = &self.session { + let prompt = session.native_prompt.snapshot(); + self.native_prompt_seen = prompt.seen; + self.native_prompt_epoch = prompt.pending.then_some(prompt.input_epoch); + } + } + + pub(super) fn consume_native_prompt(&mut self) { + if let Some(session) = &self.session { + session.native_prompt.consume_native_prompt(); + } + self.native_prompt_epoch = None; + } + + pub(super) fn on_native_cmd_prompt(&mut self, cx: &mut Context) { + self.sync_native_prompt(); + if self.pending_runtime_submit.is_some() + || self.pending_shell_command.is_some() + || self.recovery.preparing() + || self.exited.is_some() + { + return; + } + if self.command_running && self.native_prompt_epoch == Some(self.prompt_input_epoch) { + self.probe_restored_prompt(cx); + } + } + + pub(super) fn probe_restored_prompt(&mut self, cx: &mut Context) { + if self.prompt_process_probe.is_some() + || self + .last_prompt_process_probe + .is_some_and(|at| at.elapsed() < std::time::Duration::from_secs(1)) + { + return; + } + let Some(session) = &self.session else { return }; + let pid = session.shell_pid; + let started = self.command_started; + let input_epoch = self.prompt_input_epoch; + self.last_prompt_process_probe = Some(std::time::Instant::now()); + let work = + cx.background_executor().spawn(async move { crate::process_tree::descendants(pid) }); + self.prompt_process_probe = Some(cx.spawn(async move |this, cx| { + let result = work.await; + let _ = this.update(cx, |view, cx| { + view.prompt_process_probe = None; + view.apply_prompt_process_probe(started, input_epoch, result, cx); + }); + })); + } + + pub(super) fn apply_prompt_process_probe( + &mut self, + started: Option, + input_epoch: u64, + result: Result, String>, + cx: &mut Context, + ) { + self.sync_native_prompt(); + if self.command_started != started + || self.prompt_input_epoch != input_epoch + || self.pending_runtime_submit.is_some() + || self.exited.is_some() + { + return; + } + let Ok(processes) = result else { return }; + if self.native_prompt_seen { + if self.native_prompt_epoch != Some(input_epoch) { + return; + } + // Nested shells inherit PROMPT; their marker cannot end the outer run. + let root_is_cmd = processes.iter().any(|p| { + p.depth == 0 + && crate::process_tree::display_name(&p.executable).eq_ignore_ascii_case("cmd") + }); + let nested_shell = processes.iter().any(|p| { + p.depth > 0 + && crate::process_tree::is_interactive_shell_command( + &crate::process_tree::display_name(&p.executable), + ) + }); + self.consume_native_prompt(); + if root_is_cmd { + if nested_shell { + self.command_running_disproved = true; + if !self.agent_activity.hook_seen() + && !self.agent_activity.status().is_decided() + { + self.process_event( + nebula_terminal::event::Event::Progress { state: 0, value: None }, + cx, + ); + } + cx.notify(); + } else { + self.finish_foreground_command(None, cx); + } + } + return; + } + if processes.iter().any(|process| { + process.depth > 0 + && !crate::process_tree::is_interactive_shell_command( + &crate::process_tree::display_name(&process.executable), + ) + && !matches!( + process.executable.to_ascii_lowercase().as_str(), + "conhost.exe" | "openconsole.exe" + ) + }) { + return; + } + let restored = self.session.as_ref().is_some_and(|session| { + let term = session.term.lock(); + self.suggest.pending_command_prompt.as_deref().is_some_and(|expected| { + crate::display::nebula_shell_prompt_restored_from_raw_grid( + &term, + expected, + &self.suggest.suggest_env, + ) + }) + }); + if restored { + self.finish_foreground_command(None, cx); + } + } + /// `command_running` 的统一置位口:进程树探测的节流窗口从这里起算, /// 上一条命令的反证同时作废(新命令开始,「树里没活儿」不再成立)。 /// /// 上一条命令的失败标记与「刚完成」的对勾也在这里作废:新命令一起跑,旧结果 /// 就不再是这个 pane 的现状。 pub(super) fn mark_command_running(&mut self) { + self.consume_native_prompt(); if !self.command_running { + self.prompt_process_probe = None; + self.last_prompt_process_probe = None; self.command_started = Some(std::time::Instant::now()); self.last_process_probe = None; } @@ -864,6 +1010,11 @@ impl TerminalView { // Only a deliberately entered interactive shell may use this hint. // Start-Sleep and other builtins run inside the shell itself; absence // of a child cannot finish them. SSH/WSL were excluded at entry. + // A native CMD prompt is stronger than the process fallback. In particular, + // an idle nested shell remains a child of the outer command. + if self.native_prompt_seen { + return; + } let interactive_shell = crate::process_tree::is_interactive_shell_command(&self.suggest.last_committed); let disproved = interactive_shell && !evidence.busy; diff --git a/nebula_app/src/gpui_shell/terminal/view/startup.rs b/nebula_app/src/gpui_shell/terminal/view/startup.rs index 697cea2..eaab74b 100644 --- a/nebula_app/src/gpui_shell/terminal/view/startup.rs +++ b/nebula_app/src/gpui_shell/terminal/view/startup.rs @@ -235,6 +235,11 @@ impl TerminalView { command_running_disproved: false, command_started: None, last_process_probe: None, + prompt_process_probe: None, + prompt_input_epoch: 0, + native_prompt_seen: false, + native_prompt_epoch: None, + last_prompt_process_probe: None, active_run: None, last_run: None, agent_activity: Default::default(), diff --git a/nebula_app/src/gpui_shell/terminal/view/startup_tests.rs b/nebula_app/src/gpui_shell/terminal/view/startup_tests.rs index 5dd3e9b..ee013f5 100644 --- a/nebula_app/src/gpui_shell/terminal/view/startup_tests.rs +++ b/nebula_app/src/gpui_shell/terminal/view/startup_tests.rs @@ -54,6 +54,11 @@ pub(super) fn open( (view, window, receiver) } +fn native_prompt(view: &mut TerminalView, cx: &mut Context<'_, TerminalView>) { + view.session.as_ref().unwrap().native_prompt.observe_prompt(); + view.process_event(Event::UserVar { name: "pebrel_cmd_prompt".into(), value: "1".into() }, cx); +} + pub(super) fn feed(view: &mut TerminalView, bytes: &[u8]) { let mut term = view.session.as_ref().unwrap().term.lock(); let mut parser = nebula_terminal::vte::ansi::Processor::< @@ -62,6 +67,762 @@ pub(super) fn feed(view: &mut TerminalView, bytes: &[u8]) { parser.advance(&mut *term, bytes); } +#[cfg(windows)] +#[gpui::test] +fn native_queued_prompt_cannot_finish_a_later_submission(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + let (session, _input, mut events, proxy) = session::test_session_with_events(); + view.session = Some(session); + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + // Parse a real prompt, but hold its mailbox delivery until after Enter. + nebula_terminal::event_loop::StreamProcessor::default().feed( + &mut *view.session.as_ref().unwrap().term.lock(), + &proxy, + b"C:\\work>\x1b]1337;SetUserVar=pebrel_cmd_prompt=MQ==\x07pause", + ); + view.runtime_send_key(crate::runtime_api::RuntimeKey::Enter, Default::default(), 1, cx) + .unwrap(); + let started = view.command_started; + while let Ok(event) = events.try_recv() { + view.process_event(event, cx); + } + view.apply_prompt_process_probe( + started, + view.prompt_input_epoch, + Ok(vec![crate::process_tree::ProcessEntry { + pid: 1, + parent_pid: 0, + executable: "cmd.exe".into(), + depth: 0, + }]), + cx, + ); + assert_eq!( + view.sidebar_activity(), + SidebarActivity::Running, + "queued startup prompt must not end pause" + ); + // A later real prompt still completes this same command without another Enter. + nebula_terminal::event_loop::StreamProcessor::default().feed( + &mut *view.session.as_ref().unwrap().term.lock(), + &proxy, + b"\r\nC:\\work>\x1b]1337;SetUserVar=pebrel_cmd_prompt=MQ==\x07", + ); + while let Ok(event) = events.try_recv() { + view.process_event(event, cx); + } + view.apply_prompt_process_probe( + started, + view.prompt_input_epoch, + Ok(vec![crate::process_tree::ProcessEntry { + pid: 1, + parent_pid: 0, + executable: "cmd.exe".into(), + depth: 0, + }]), + cx, + ); + assert_eq!(view.sidebar_activity(), SidebarActivity::Idle); + }); +} + +#[cfg(windows)] +#[gpui::test] +fn native_queued_prompt_allows_fast_next_submission(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + let (session, _input, _events, proxy) = session::test_session_with_events(); + view.session = Some(session); + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + feed(view, b"C:\\work>echo first"); + view.runtime_send_key(crate::runtime_api::RuntimeKey::Enter, Default::default(), 1, cx) + .unwrap(); + let first = view.command_started; + nebula_terminal::event_loop::StreamProcessor::default().feed( + &mut *view.session.as_ref().unwrap().term.lock(), + &proxy, + b"\r\nfirst\r\nC:\\work>\x1b]1337;SetUserVar=pebrel_cmd_prompt=MQ==\x07pause", + ); + view.runtime_send_key(crate::runtime_api::RuntimeKey::Enter, Default::default(), 1, cx) + .unwrap(); + assert_eq!( + view.suggest.last_committed, "pause", + "a parsed prompt owns the next input even before UI delivery" + ); + assert_ne!(view.command_started, first, "new command invalidates first command probes"); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + }); +} + +#[cfg(windows)] +#[gpui::test] +fn native_queued_prompt_allows_fast_newline_paste(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + let (session, _input, mut events, proxy) = session::test_session_with_events(); + view.session = Some(session); + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + feed(view, b"C:\\work>echo first"); + view.runtime_send_key(crate::runtime_api::RuntimeKey::Enter, Default::default(), 1, cx) + .unwrap(); + let first = view.command_started; + nebula_terminal::event_loop::StreamProcessor::default().feed( + &mut *view.session.as_ref().unwrap().term.lock(), + &proxy, + b"\r\nfirst\r\nC:\\work>\x1b]1337;SetUserVar=pebrel_cmd_prompt=MQ==\x07", + ); + view.paste_now_impl("pause\r\n", false, cx); + assert_ne!(view.command_started, first, "newline paste starts its own command boundary"); + while let Ok(event) = events.try_recv() { + view.process_event(event, cx); + } + view.apply_prompt_process_probe( + first, + 1, + Ok(vec![crate::process_tree::ProcessEntry { + pid: 1, + parent_pid: 0, + executable: "cmd.exe".into(), + depth: 0, + }]), + cx, + ); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + assert_eq!( + view.suggest.last_committed, "echo first", + "unconfirmed paste must not enter history" + ); + }); +} + +#[cfg(windows)] +#[gpui::test] +fn native_cmd_submission_starts_activity_without_osc_or_clink(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + feed(view, b"C:\\work>ping -n 8 127.0.0.1"); + view.suggest.line_buf = "ping -n 8 127.0.0.1".into(); + view.commit_line(cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + assert_eq!(view.suggest.pending_command_prompt.as_deref(), Some("C:\\work>")); + }); +} + +#[cfg(windows)] +#[gpui::test] +fn native_cmd_prompt_return_ends_non_agent_command(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + view.suggest.pending_command_prompt = Some("C:\\work>".into()); + view.mark_command_running(); + feed(view, b"C:\\work>"); + view.refresh_agent_screen_state(cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + view.apply_prompt_process_probe( + view.command_started, + view.prompt_input_epoch, + Ok(vec![]), + cx, + ); + assert_eq!(view.sidebar_activity(), SidebarActivity::Idle); + assert!(view.suggest.pending_command_prompt.is_none()); + }); +} + +#[cfg(windows)] +#[gpui::test] +fn native_cmd_internal_input_keeps_the_original_command_boundary(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + view.suggest.pending_command_prompt = Some("C:\\work>".into()); + view.mark_command_running(); + let started = view.command_started; + feed(view, b"Password: reply"); + view.suggest.line_buf = "reply".into(); + view.commit_line(cx); + assert_eq!(view.suggest.pending_command_prompt.as_deref(), Some("C:\\work>")); + assert_eq!(view.command_started, started); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + }); +} + +#[cfg(windows)] +#[gpui::test] +fn native_cmd_empty_enter_does_not_start_activity(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + feed(view, b"C:\\work>"); + view.commit_line(cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Idle); + }); +} + +#[cfg(windows)] +#[gpui::test] +fn native_cmd_history_submission_uses_echo_without_typed_mirror(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + feed(view, b"C:\\work>ping -n 8 127.0.0.1"); + view.commit_line(cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + assert_eq!(view.suggest.last_committed, "ping -n 8 127.0.0.1"); + }); +} + +#[cfg(windows)] +#[gpui::test] +fn native_cmd_changed_directory_prompt_ends_command(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + view.suggest.pending_command_prompt = Some("C:\\work>".into()); + view.mark_command_running(); + feed(view, b"D:\\other>"); + view.refresh_agent_screen_state(cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + view.apply_prompt_process_probe( + view.command_started, + view.prompt_input_epoch, + Ok(vec![]), + cx, + ); + assert_eq!(view.sidebar_activity(), SidebarActivity::Idle); + }); +} + +#[cfg(windows)] +#[gpui::test] +fn native_cmd_alternate_screen_prompt_is_not_shell_completion(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + view.suggest.pending_command_prompt = Some("C:\\work>".into()); + view.mark_command_running(); + feed(view, b"\x1b[?1049hC:\\work>"); + view.refresh_agent_screen_state(cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + }); +} + +#[cfg(windows)] +#[gpui::test] +fn native_cmd_unknown_process_snapshot_does_not_end_builtin_wait(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + view.suggest.pending_command_prompt = Some("C:\\work>".into()); + view.mark_command_running(); + view.command_started = Some(std::time::Instant::now() - std::time::Duration::from_secs(5)); + view.session.as_mut().unwrap().shell_pid = u32::MAX; + feed(view, b"Value: "); + view.refresh_agent_screen_state(cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + }); +} + +#[cfg(windows)] +#[gpui::test] +fn native_cmd_runtime_enter_uses_the_same_submission_boundary(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + feed(view, b"C:\\work>pause"); + view.runtime_send_key(crate::runtime_api::RuntimeKey::Enter, Default::default(), 1, cx) + .unwrap(); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + assert_eq!(view.suggest.pending_command_prompt.as_deref(), Some("C:\\work>")); + }); +} + +#[cfg(windows)] +#[gpui::test] +fn native_cmd_runtime_prompt_captures_echo_before_submitting(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + feed(view, b"C:\\work>"); + view.runtime_prompt("pause".into(), true, cx).unwrap(); + feed(view, b"pause"); + view.flush_pending_runtime_submit(cx); + assert_eq!(view.suggest.pending_command_prompt.as_deref(), Some("C:\\work>")); + feed(view, b"\r\nPress any key to continue . . ."); + view.refresh_agent_screen_state(cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + feed(view, b"\r\nC:\\work>"); + view.refresh_agent_screen_state(cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + view.apply_prompt_process_probe( + view.command_started, + view.prompt_input_epoch, + Ok(vec![]), + cx, + ); + assert_eq!(view.sidebar_activity(), SidebarActivity::Idle); + }); +} + +#[cfg(windows)] +#[gpui::test] +fn native_cmd_prompt_probe_requires_current_successful_process_evidence(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + view.suggest.pending_command_prompt = Some("C:\\work>".into()); + view.mark_command_running(); + let started = view.command_started; + feed(view, b"C:\\work>"); + let root = crate::process_tree::ProcessEntry { + pid: 1, + parent_pid: 0, + executable: "cmd.exe".into(), + depth: 0, + }; + let child = crate::process_tree::ProcessEntry { + pid: 2, + parent_pid: 1, + executable: "python.exe".into(), + depth: 1, + }; + view.apply_prompt_process_probe(started, 0, Err("snapshot unavailable".into()), cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + view.apply_prompt_process_probe(started, 0, Ok(vec![root.clone(), child]), cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + view.apply_prompt_process_probe(None, 0, Ok(vec![root.clone()]), cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running, "stale command result"); + feed(view, b"still executing"); + view.apply_prompt_process_probe(started, 0, Ok(vec![root.clone()]), cx); + assert_eq!( + view.sidebar_activity(), + SidebarActivity::Running, + "prompt changed during probe" + ); + feed(view, b"\r\nC:\\work>"); + view.apply_prompt_process_probe(started, 0, Ok(vec![root]), cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Idle); + }); +} + +#[cfg(windows)] +#[gpui::test] +fn native_cmd_input_invalidates_pending_prompt_probe(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + view.suggest.pending_command_prompt = Some("C:\\work>".into()); + view.mark_command_running(); + let started = view.command_started; + feed(view, b"C:\\work>"); + // The new input has reached the PTY, but its echo has not arrived yet. + view.write_input(b"pause\r".to_vec(), cx); + view.apply_prompt_process_probe( + started, + 0, + Ok(vec![crate::process_tree::ProcessEntry { + pid: 1, + parent_pid: 0, + executable: "cmd.exe".into(), + depth: 0, + }]), + cx, + ); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + }); +} + +#[cfg(windows)] +#[gpui::test] +fn native_marker_restores_prompt_despite_background_process(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + view.mark_command_running(); + let started = view.command_started; + native_prompt(view, cx); + view.apply_prompt_process_probe( + started, + 0, + Ok(vec![ + crate::process_tree::ProcessEntry { + pid: 1, + parent_pid: 0, + executable: "cmd.exe".into(), + depth: 0, + }, + crate::process_tree::ProcessEntry { + pid: 2, + parent_pid: 1, + executable: "python.exe".into(), + depth: 1, + }, + ]), + cx, + ); + assert_eq!(view.sidebar_activity(), SidebarActivity::Idle); + }); +} + +#[cfg(windows)] +#[gpui::test] +fn native_marker_disables_visible_prompt_guessing(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + native_prompt(view, cx); + view.mark_command_running(); + view.suggest.pending_command_prompt = Some("C:\\work>".into()); + feed(view, b"C:\\work>"); + view.refresh_agent_screen_state(cx); + assert_eq!( + view.sidebar_activity(), + SidebarActivity::Running, + "set /p or removed PROMPT marker is not completion" + ); + }); +} + +#[cfg(windows)] +#[gpui::test] +fn native_marker_rejects_nested_shell_and_unknown_snapshot(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + view.mark_command_running(); + let started = view.command_started; + native_prompt(view, cx); + view.apply_prompt_process_probe(started, 0, Err("unavailable".into()), cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + let root = crate::process_tree::ProcessEntry { + pid: 1, + parent_pid: 0, + executable: "cmd.exe".into(), + depth: 0, + }; + view.apply_prompt_process_probe( + started, + 0, + Ok(vec![ + root.clone(), + crate::process_tree::ProcessEntry { + pid: 2, + parent_pid: 1, + executable: "cmd.exe".into(), + depth: 1, + }, + ]), + cx, + ); + assert_eq!(view.sidebar_activity(), SidebarActivity::Idle); + assert!(view.command_running, "outer command remains live"); + // The periodic process fallback must not undo a confirmed inner prompt. + view.session.as_mut().unwrap().shell_pid = std::process::id(); + view.suggest.last_committed = "ping -n 5 127.0.0.1".into(); + view.command_started = Some(std::time::Instant::now() - std::time::Duration::from_secs(4)); + view.reconcile_shell_activity(cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Idle); + view.command_started = started; + // A fresh outer prompt after the nested shell exits can complete the run. + native_prompt(view, cx); + view.apply_prompt_process_probe(started, 0, Ok(vec![root]), cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Idle); + }); +} + +#[cfg(windows)] +#[gpui::test] +fn native_nested_prompt_keeps_outer_run_and_agent_identity(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + view.mark_command_running(); + view.active_run = Some(crate::runtime_api::begin_runtime_run()); + let run_id = view.active_run.unwrap().run_id; + let started = view.command_started; + let processes = vec![ + crate::process_tree::ProcessEntry { + pid: 1, + parent_pid: 0, + executable: "cmd.exe".into(), + depth: 0, + }, + crate::process_tree::ProcessEntry { + pid: 2, + parent_pid: 1, + executable: "cmd.exe".into(), + depth: 1, + }, + ]; + native_prompt(view, cx); + view.apply_prompt_process_probe(started, 0, Ok(processes.clone()), cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Idle); + assert_eq!(view.active_run.unwrap().run_id, run_id); + assert!(view.last_run.is_none()); + feed(view, b"C:\\work>pause"); + view.commit_line(cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + view.running_program = Some("pi".into()); + let hook = crate::ai_hook::parse_remote_envelope( + b"nebula-hook/1 source=pi pane=42\n{\"kind\":\"prompt\",\"session_id\":\"nested\",\"bridge_sequence\":1}", + Some(42), + ).unwrap(); + view.handle_ai_hook(&hook, cx); + let started = view.command_started; + native_prompt(view, cx); + view.apply_prompt_process_probe(started, 0, Ok(processes), cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + assert_eq!(view.running_program.as_deref(), Some("pi")); + assert!(view.agent_activity.hook_seen()); + assert_eq!(view.active_run.unwrap().run_id, run_id); + }); +} + +#[cfg(windows)] +#[gpui::test] +fn native_nested_prompt_clears_cli_progress_without_ending_outer_run(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + view.mark_command_running(); + view.active_run = Some(crate::runtime_api::begin_runtime_run()); + let run_id = view.active_run.unwrap().run_id; + let started = view.command_started; + view.process_event(Event::Progress { state: 3, value: None }, cx); + native_prompt(view, cx); + view.apply_prompt_process_probe( + started, + 0, + Ok(vec![ + crate::process_tree::ProcessEntry { + pid: 1, + parent_pid: 0, + executable: "cmd.exe".into(), + depth: 0, + }, + crate::process_tree::ProcessEntry { + pid: 2, + parent_pid: 1, + executable: "cmd.exe".into(), + depth: 1, + }, + ]), + cx, + ); + assert_eq!(view.sidebar_activity(), SidebarActivity::Idle); + assert_eq!(view.progress, crate::taskbar::TaskProgress::None); + assert_eq!(view.active_run.unwrap().run_id, run_id); + assert!(view.last_run.is_none()); + }); +} + +#[cfg(windows)] +#[gpui::test] +fn native_marker_pending_probe_cannot_finish_new_input(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + view.mark_command_running(); + let started = view.command_started; + native_prompt(view, cx); + view.write_input(b"pause\r".to_vec(), cx); + view.apply_prompt_process_probe(started, 0, Ok(vec![]), cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + }); +} + +#[cfg(windows)] +#[gpui::test] +fn native_prompt_survives_typing_before_process_probe_returns(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + view.mark_command_running(); + let started = view.command_started; + native_prompt(view, cx); + view.write_input(b"echo next".to_vec(), cx); + let root = crate::process_tree::ProcessEntry { + pid: 1, + parent_pid: 0, + executable: "cmd.exe".into(), + depth: 0, + }; + view.apply_prompt_process_probe(started, 0, Ok(vec![root.clone()]), cx); + assert_eq!( + view.sidebar_activity(), + SidebarActivity::Running, + "old input snapshot is discarded" + ); + view.apply_prompt_process_probe(started, 1, Ok(vec![root]), cx); + assert_eq!( + view.sidebar_activity(), + SidebarActivity::Idle, + "typing without submitting must not lose the only prompt marker" + ); + }); +} + +#[cfg(windows)] +#[gpui::test] +fn native_queued_prompt_cannot_finish_encoded_submission(cx: &mut TestAppContext) { + for bytes in [&b"pause\r"[..], &b"\x1b[13;28;13;1;0;1_\x1b[13;28;13;0;0;1_"[..]] { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.mark_command_running(); + let started = view.command_started; + native_prompt(view, cx); + view.write_input(bytes.to_vec(), cx); + view.apply_prompt_process_probe( + started, + view.prompt_input_epoch, + Ok(vec![crate::process_tree::ProcessEntry { + pid: 1, + parent_pid: 0, + executable: "cmd.exe".into(), + depth: 0, + }]), + cx, + ); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running, "{bytes:?}"); + }); + } +} + +#[cfg(windows)] +#[gpui::test] +fn native_queued_prompt_survives_editing_keys(cx: &mut TestAppContext) { + for bytes in [ + &b"\x08"[..], + &b"\x1b[D"[..], + &b"\x1b[8;14;8;1;0;1_\x1b[8;14;8;0;0;1_"[..], + &b"\x1b[65;30;97;1;0;1_\x1b[65;30;97;0;0;1_"[..], + ] { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + let (session, _input, mut events, proxy) = session::test_session_with_events(); + view.session = Some(session); + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + view.mark_command_running(); + let started = view.command_started; + nebula_terminal::event_loop::StreamProcessor::default().feed( + &mut *view.session.as_ref().unwrap().term.lock(), + &proxy, + b"C:\\work>\x1b]1337;SetUserVar=pebrel_cmd_prompt=MQ==\x07", + ); + view.write_input(bytes.to_vec(), cx); + while let Ok(event) = events.try_recv() { + view.process_event(event, cx); + } + view.apply_prompt_process_probe( + started, + view.prompt_input_epoch, + Ok(vec![crate::process_tree::ProcessEntry { + pid: 1, + parent_pid: 0, + executable: "cmd.exe".into(), + depth: 0, + }]), + cx, + ); + assert_eq!(view.sidebar_activity(), SidebarActivity::Idle, "{bytes:?}"); + }); + } +} + +#[cfg(windows)] +#[gpui::test] +fn native_newline_paste_starts_command_without_recording_unconfirmed_history( + cx: &mut TestAppContext, +) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + feed(view, b"C:\\work>"); + native_prompt(view, cx); + view.paste_now_impl("pause\r\n", false, cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + assert_eq!(view.suggest.pending_command_prompt.as_deref(), Some("C:\\work>")); + assert!(view.suggest.last_committed.is_empty(), "paste is not yet echoed shell history"); + }); +} + +#[cfg(windows)] +#[gpui::test] +fn native_paste_without_shell_submission_does_not_start_command(cx: &mut TestAppContext) { + for (screen, pasted, bracketed) in [ + ("C:\\work>", "pause", false), + ("C:\\work>", "\r\n", false), + ("Password: ", "secret\r\n", false), + ("C:\\work>", "pause\r\n", true), + ] { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + feed(view, screen.as_bytes()); + native_prompt(view, cx); + if bracketed { + feed(view, b"\x1b[?2004h"); + } + view.paste_now_impl(pasted, false, cx); + assert_eq!( + view.sidebar_activity(), + SidebarActivity::Idle, + "{screen:?} {pasted:?} bracketed={bracketed}" + ); + }); + } +} + +#[gpui::test] +fn ended_command_does_not_leave_osc_progress_running(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + for exit_code in [Some(0), Some(1), None] { + view.process_event(Event::CommandStart, cx); + view.process_event(Event::Progress { state: 3, value: None }, cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + view.process_event(Event::CommandDone { exit_code }, cx); + let expected = if exit_code.is_some_and(|code| code != 0) { + SidebarActivity::CommandFailed + } else { + SidebarActivity::Idle + }; + assert_eq!(view.sidebar_activity(), expected); + assert_eq!(view.progress, crate::taskbar::TaskProgress::None); + } + }); +} + +#[cfg(windows)] +#[gpui::test] +fn native_prompt_completion_clears_progress_without_another_enter(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + feed(view, b"C:\\work>python synthetic-progress.py"); + view.commit_line(cx); + let started = view.command_started; + view.process_event(Event::Progress { state: 3, value: None }, cx); + native_prompt(view, cx); + view.apply_prompt_process_probe( + started, + 0, + Ok(vec![crate::process_tree::ProcessEntry { + pid: 1, + parent_pid: 0, + executable: "cmd.exe".into(), + depth: 0, + }]), + cx, + ); + assert_eq!(view.sidebar_activity(), SidebarActivity::Idle); + assert_eq!(view.progress, crate::taskbar::TaskProgress::None); + }); +} + #[gpui::test] fn ssh_tab_name_and_hover_preserve_host_identity_across_remote_titles(cx: &mut TestAppContext) { let (view, window, _) = open(cx); diff --git a/nebula_terminal/src/tty/windows/cmd_prompt.rs b/nebula_terminal/src/tty/windows/cmd_prompt.rs new file mode 100644 index 0000000..e6d71e1 --- /dev/null +++ b/nebula_terminal/src/tty/windows/cmd_prompt.rs @@ -0,0 +1,77 @@ +use crate::tty::Options; + +const MARK: &str = "\x1b]1337;SetUserVar=pebrel_cmd_prompt=MQ==\x07"; + +pub(super) fn prepare(config: &Options) -> Options { + let mut prepared = config.clone(); + let Some(shell) = &config.shell else { return prepared }; + let name = shell.program().rsplit(['/', '\\']).next().unwrap_or_default(); + if !name.eq_ignore_ascii_case("cmd") && !name.eq_ignore_ascii_case("cmd.exe") { + return prepared; + } + + let prompt = config + .env + .iter() + .find(|(key, _)| key.eq_ignore_ascii_case("PROMPT")) + .map(|(_, value)| value.clone()) + .or_else(|| (!config.env_is_complete).then(|| std::env::var("PROMPT").ok()).flatten()) + .unwrap_or_else(|| "$P$G".into()); + prepared.env.retain(|key, _| !key.eq_ignore_ascii_case("PROMPT")); + // 只改子进程的环境副本;嵌套启动或重复准备不能不断叠加标记。 + let prompt = if prompt.starts_with(MARK) { prompt } else { format!("{MARK}{prompt}") }; + prepared.env.insert("PROMPT".into(), prompt); + prepared +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tty::Shell; + + const MARK: &str = "\x1b]1337;SetUserVar=pebrel_cmd_prompt=MQ==\x07"; + + fn cmd() -> Options { + Options { + shell: Some(Shell::new("C:\\Windows\\System32\\CMD.EXE".into(), vec!["/d".into()])), + env_is_complete: true, + ..Options::default() + } + } + + #[test] + fn native_prompt_preserves_custom_text_without_mutating_caller() { + let mut config = cmd(); + config.env.insert("Prompt".into(), "[$P]$_$G".into()); + let prepared = prepare(&config); + assert_eq!(prepared.env.get("PROMPT").unwrap(), &format!("{MARK}[$P]$_$G")); + assert!(!prepared.env.contains_key("Prompt")); + assert_eq!(config.env.get("Prompt").unwrap(), "[$P]$_$G"); + } + + #[test] + fn native_prompt_defaults_and_is_idempotent() { + let prepared = prepare(&cmd()); + assert_eq!(prepared.env.get("PROMPT").unwrap(), &format!("{MARK}$P$G")); + assert_eq!(prepare(&prepared), prepared); + } + + #[test] + fn native_prompt_does_not_instrument_other_shells() { + for name in ["powershell.exe", "pwsh", "wsl.exe", "mycmd.exe"] { + let mut config = cmd(); + config.shell = Some(Shell::new(name.into(), vec![])); + assert_eq!(prepare(&config), config); + } + let mut config = cmd(); + config.shell = None; + assert_eq!(prepare(&config), config); + } + + #[test] + fn native_prompt_preserves_explicit_empty_prompt() { + let mut config = cmd(); + config.env.insert("PROMPT".into(), String::new()); + assert_eq!(prepare(&config).env.get("PROMPT").unwrap(), MARK); + } +} diff --git a/nebula_terminal/src/tty/windows/conpty.rs b/nebula_terminal/src/tty/windows/conpty.rs index 90b350e..12357b9 100644 --- a/nebula_terminal/src/tty/windows/conpty.rs +++ b/nebula_terminal/src/tty/windows/conpty.rs @@ -288,7 +288,8 @@ fn spawn_shell(config: &Options, conpty: &Conpty) -> Result { let mut cmdline = win32_string(&cmdline(config)); let cwd = config.working_directory.as_ref().map(win32_string); let mut creation_flags = EXTENDED_STARTUPINFO_PRESENT; - let custom_env_block = convert_custom_env(&config.env, config.env_is_complete); + let child_config = super::cmd_prompt::prepare(config); + let custom_env_block = convert_custom_env(&child_config.env, child_config.env_is_complete); let custom_env_block_pointer = match &custom_env_block { Some(custom_env_block) => { creation_flags |= CREATE_UNICODE_ENVIRONMENT; diff --git a/nebula_terminal/src/tty/windows/mod.rs b/nebula_terminal/src/tty/windows/mod.rs index d39a81b..9389616 100644 --- a/nebula_terminal/src/tty/windows/mod.rs +++ b/nebula_terminal/src/tty/windows/mod.rs @@ -14,6 +14,7 @@ use crate::tty::{ChildEvent, EventedPty, EventedReadWrite, Options, Shell}; mod blocking; mod child; +mod cmd_prompt; mod conpty; mod environment; From b5778a892fb5501376fb2c6ff7293021638c37bc Mon Sep 17 00:00:00 2001 From: galact <1582093495@qq.com> Date: Sun, 20 Sep 2026 04:39:59 +0800 Subject: [PATCH 5/9] fix: shorten ordinary Pi completion banners --- nebula_app/src/gpui_shell/toast.rs | 143 ++++++++++++++++-- .../src/gpui_shell/workspace/notifications.rs | 2 +- 2 files changed, 130 insertions(+), 15 deletions(-) diff --git a/nebula_app/src/gpui_shell/toast.rs b/nebula_app/src/gpui_shell/toast.rs index 3a05e2f..d22df04 100644 --- a/nebula_app/src/gpui_shell/toast.rs +++ b/nebula_app/src/gpui_shell/toast.rs @@ -192,7 +192,7 @@ pub fn banner(window: &mut Window, cx: &mut App, kind: ToastKind, text: impl Int return; } log::warn!("banner [{kind:?}]: {text}"); - push_banner(window, cx, note(kind, text).on_click(|_, _, _| {}), false); + push_banner(window, cx, note(kind, text).on_click(|_, _, _| {}), false, BANNER_TTL); } pub(crate) fn banner_for_pane( @@ -201,8 +201,9 @@ pub(crate) fn banner_for_pane( kind: ToastKind, text: impl Into, pane_id: u64, - ai_toast: bool, + source: &crate::notify::Notification, ) { + let ai_toast = source.is_ai(); let text = text.into(); if text.trim().is_empty() || !PANE_BANNERS.lock().unwrap_or_else(|poison| poison.into_inner()).accepts( @@ -222,7 +223,16 @@ pub(crate) fn banner_for_pane( notification = notification .id1::(("ai-banner", NEXT_AI_TOAST.fetch_add(1, Ordering::Relaxed))); } - push_banner(window, cx, notification, ai_toast); + // Only ordinary Pi completion is transient; errors and requests retain their lifetime. + let ttl = match source { + crate::notify::Notification::AiTurn { program, message: None, attention: false } + if program == "pi" => + { + Duration::from_secs(5) + }, + _ => BANNER_TTL, + }; + push_banner(window, cx, notification, ai_toast, ttl); } pub(crate) fn confirmation_for_pane( @@ -286,7 +296,7 @@ pub(crate) fn confirmation_for_pane( ) .into_any_element() }); - push_banner(window, cx, notification, true); + push_banner(window, cx, notification, true, BANNER_TTL); } fn reply_to_confirmation( @@ -316,14 +326,20 @@ fn reply_to_confirmation( } } -fn push_banner(window: &mut Window, cx: &mut App, notification: Notification, ai_toast: bool) { +fn push_banner( + window: &mut Window, + cx: &mut App, + notification: Notification, + ai_toast: bool, + ttl: Duration, +) { if ai_toast && !super::config::ai_toasts_enabled(cx) { return; } let note = notification.autohide(false); if window.root::().flatten().is_some() { window.push_notification(note, cx); - schedule_banner_dismiss(window, cx); + schedule_banner_dismiss(window, cx, ttl); } else { window.defer(cx, move |window, cx| { // The preference may change before the startup Root is installed. @@ -332,7 +348,7 @@ fn push_banner(window: &mut Window, cx: &mut App, notification: Notification, ai } if window.root::().flatten().is_some() { window.push_notification(note, cx); - schedule_banner_dismiss(window, cx); + schedule_banner_dismiss(window, cx, ttl); } else { log::warn!("banner dropped because the window Root is unavailable"); } @@ -346,14 +362,14 @@ fn push_banner(window: &mut Window, cx: &mut App, notification: Notification, ai /// 自定义时长的入口;为这个改 fork 要动 rev pin,不值得。换个办法:push 之后 /// 队尾那条就是我们刚推的,抓住它的**弱**引用,到点自己调 `dismiss`。用户 /// 提前点掉时弱引用已经失效,定时器什么也不做,不会去误关后来的消息。 -fn schedule_banner_dismiss(window: &mut Window, cx: &mut App) { +fn schedule_banner_dismiss(window: &mut Window, cx: &mut App, ttl: Duration) { let Some(Some(root)) = window.root::() else { return }; let list = root.read(cx).notification.clone(); let Some(note) = list.read(cx).notifications().last().cloned() else { return }; let note = note.downgrade(); window .spawn(cx, async move |cx| { - cx.background_executor().timer(BANNER_TTL).await; + cx.background_executor().timer(ttl).await; let _ = cx.update(|window, cx| { if let Some(note) = note.upgrade() { note.update(cx, |note, cx| note.dismiss(window, cx)); @@ -424,6 +440,14 @@ mod tests { }) } + fn pi_completion() -> crate::notify::Notification { + crate::notify::Notification::AiTurn { + program: "pi".into(), + message: None, + attention: false, + } + } + fn settle_dismissal(cx: &mut VisualTestContext) { cx.run_until_parked(); cx.background_executor.advance_clock(Duration::from_millis(200)); @@ -435,7 +459,14 @@ mod tests { initialize(cx, true); let (_, cx) = cx.add_window_view(|window, cx| Root::new(cx.new(|_| Empty), window, cx)); cx.update(|window, cx| { - banner_for_pane(window, cx, ToastKind::Info, "AI completed", 7101, true); + banner_for_pane( + window, + cx, + ToastKind::Info, + "AI completed", + 7101, + &pi_completion(), + ); confirmation_for_pane( window, cx, @@ -459,17 +490,87 @@ mod tests { cx.run_until_parked(); assert_eq!(ids(cx), vec![original[2]], "reenabling must not replay old notices"); cx.update(|window, cx| { - banner_for_pane(window, cx, ToastKind::Info, "Next AI turn", 7101, true); + banner_for_pane( + window, + cx, + ToastKind::Info, + "Next AI turn", + 7101, + &pi_completion(), + ); }); assert_eq!(ids(cx).len(), 2); } + #[gpui::test] + fn pi_completion_expires_without_dismissing_attention_or_later_turns( + cx: &mut TestAppContext, + ) { + initialize(cx, true); + let (_, cx) = cx.add_window_view(|window, cx| Root::new(cx.new(|_| Empty), window, cx)); + cx.update(|window, cx| { + banner_for_pane( + window, + cx, + ToastKind::Info, + "First Pi completion", + 7301, + &pi_completion(), + ); + let failed = crate::notify::Notification::AiTurn { + program: "pi".into(), + message: Some("Turn failed".into()), + attention: false, + }; + banner_for_pane(window, cx, ToastKind::Info, "Pi failure", 7302, &failed); + confirmation_for_pane( + window, + cx, + "Permission".into(), + 7303, + BinaryConfirmation { id: 7304, question: "Continue?".into() }, + ); + banner(window, cx, ToastKind::Warning, "Configuration warning"); + }); + cx.run_until_parked(); + let original = ids(cx); + assert_eq!(original.len(), 4); + cx.background_executor.advance_clock(Duration::from_secs(4)); + cx.run_until_parked(); + assert_eq!(ids(cx), original, "completion remains readable before expiry"); + cx.update(|window, cx| { + banner_for_pane( + window, + cx, + ToastKind::Info, + "Second Pi completion", + 7301, + &pi_completion(), + ); + }); + cx.run_until_parked(); + let later = *ids(cx).last().unwrap(); + cx.background_executor.advance_clock(Duration::from_secs(1)); + settle_dismissal(cx); + assert_eq!(ids(cx), vec![original[1], original[2], original[3], later]); + cx.background_executor.advance_clock(Duration::from_secs(4)); + settle_dismissal(cx); + assert_eq!(ids(cx), original[1..]); + } + #[gpui::test] fn disabled_ai_cards_cannot_reenter_the_component_queue(cx: &mut TestAppContext) { initialize(cx, false); let (_, cx) = cx.add_window_view(|window, cx| Root::new(cx.new(|_| Empty), window, cx)); cx.update(|window, cx| { - banner_for_pane(window, cx, ToastKind::Info, "Hidden AI turn", 7102, true); + banner_for_pane( + window, + cx, + ToastKind::Info, + "Hidden AI turn", + 7102, + &pi_completion(), + ); confirmation_for_pane( window, cx, @@ -477,7 +578,14 @@ mod tests { 7102, BinaryConfirmation { id: 7202, question: "Continue?".into() }, ); - banner_for_pane(window, cx, ToastKind::Info, "Build finished", 7102, false); + banner_for_pane( + window, + cx, + ToastKind::Info, + "Build finished", + 7102, + &crate::notify::Notification::Bell { program: None }, + ); }); cx.run_until_parked(); assert_eq!(ids(cx).len(), 1, "ordinary terminal notices remain available"); @@ -487,7 +595,14 @@ mod tests { fn deferred_startup_banner_rechecks_the_current_preference(cx: &mut TestAppContext) { initialize(cx, true); let (_, cx) = cx.add_window_view(|window, cx| { - banner_for_pane(window, cx, ToastKind::Info, "Queued AI turn", 7103, true); + banner_for_pane( + window, + cx, + ToastKind::Info, + "Queued AI turn", + 7103, + &pi_completion(), + ); cx.update_global::(|settings, _| settings.ai_toasts = false); Root::new(cx.new(|_| Empty), window, cx) }); diff --git a/nebula_app/src/gpui_shell/workspace/notifications.rs b/nebula_app/src/gpui_shell/workspace/notifications.rs index 7e1a6b6..87cb4d4 100644 --- a/nebula_app/src/gpui_shell/workspace/notifications.rs +++ b/nebula_app/src/gpui_shell/workspace/notifications.rs @@ -89,7 +89,7 @@ impl NebulaWorkspace { kind, text, pane_id, - notification.is_ai(), + ¬ification, ); } } From 452c34e04aadc882d1065424be9f55a2da0791e3 Mon Sep 17 00:00:00 2001 From: kuddev Date: Sun, 20 Sep 2026 15:23:53 +0800 Subject: [PATCH 6/9] style: format integrated regression coverage with the pinned toolchain --- nebula_app/src/ai_hook/native_tests.rs | 14 ++--------- nebula_app/src/display/input_state.rs | 9 +++----- nebula_app/src/gpui_shell/config.rs | 5 +++- .../terminal/view/agent_activity.rs | 7 ++---- .../terminal/view/native_cmd_tests.rs | 10 +++++--- .../src/gpui_shell/toast/duration_tests.rs | 23 +++++++++++-------- nebula_terminal/src/term/prompt.rs | 6 ++--- 7 files changed, 34 insertions(+), 40 deletions(-) diff --git a/nebula_app/src/ai_hook/native_tests.rs b/nebula_app/src/ai_hook/native_tests.rs index ee8a262..dd3d177 100644 --- a/nebula_app/src/ai_hook/native_tests.rs +++ b/nebula_app/src/ai_hook/native_tests.rs @@ -24,14 +24,7 @@ fn non_successful_turns_do_not_emit_a_completion_notification() { Some(1), ) .unwrap(); - assert!( - crate::notify::Notification::from_ai_hook( - &event, - None, - false, - ) - .is_some() - ); + assert!(crate::notify::Notification::from_ai_hook(&event, None, false,).is_some()); } #[test] fn pi_result_metadata_distinguishes_legacy_unknown_and_background_work() { @@ -54,10 +47,7 @@ fn pi_result_metadata_distinguishes_legacy_unknown_and_background_work() { let mut activity = lifecycle::AgentActivity::default(); assert!(activity.apply_hook(&event)); assert_eq!(activity.status(), status); - assert_eq!( - Notification::from_ai_hook(&event, None, false).is_some(), - notifies - ); + assert_eq!(Notification::from_ai_hook(&event, None, false).is_some(), notifies); event.background_tasks = Some(AiBackgroundTasks { active: 1, total: 1 }); assert!(activity.apply_hook(&event)); assert_eq!(activity.status(), AgentStatus::Working); diff --git a/nebula_app/src/display/input_state.rs b/nebula_app/src/display/input_state.rs index c373a9d..b6f9aa7 100644 --- a/nebula_app/src/display/input_state.rs +++ b/nebula_app/src/display/input_state.rs @@ -149,12 +149,9 @@ pub(crate) fn nebula_shell_ready_from_raw_grid( terminal: &Term, env: &SuggestEnv, ) -> bool { - if let Some(line) = nebula_prompt_line_from_raw_grid( - terminal, - terminal.grid().cursor.point, - "", - env, - ) { + if let Some(line) = + nebula_prompt_line_from_raw_grid(terminal, terminal.grid().cursor.point, "", env) + { return line.input.trim().is_empty(); } let Some(text) = raw_grid_logical_line(terminal, terminal.grid().cursor.point) else { diff --git a/nebula_app/src/gpui_shell/config.rs b/nebula_app/src/gpui_shell/config.rs index 27e2c48..7f953c5 100644 --- a/nebula_app/src/gpui_shell/config.rs +++ b/nebula_app/src/gpui_shell/config.rs @@ -858,7 +858,10 @@ mod tests { let mut overrides = Colors::default(); assert_eq!(palette.resolve(Color::Indexed(16), &overrides, false), rgba8(configured)); overrides[16] = Some(osc); - assert_eq!(palette.resolve(Color::Indexed(16), &overrides, false), rgba8([78, 90, 123])); + assert_eq!( + palette.resolve(Color::Indexed(16), &overrides, false), + rgba8([78, 90, 123]) + ); assert_eq!(palette.query_reply(16, &overrides), osc); overrides[16] = None; assert_eq!(palette.query_reply(16, &overrides), Rgb { r: 12, g: 34, b: 56 }); 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 34b2a38..eb7d085 100644 --- a/nebula_app/src/gpui_shell/terminal/view/agent_activity.rs +++ b/nebula_app/src/gpui_shell/terminal/view/agent_activity.rs @@ -95,11 +95,8 @@ impl TerminalView { })); } } else if event.kind == AiHookKind::TurnDone - && let Some(notification) = crate::notify::Notification::from_ai_hook( - event, - event.message.clone(), - false, - ) + && let Some(notification) = + crate::notify::Notification::from_ai_hook(event, event.message.clone(), false) { cx.emit(TerminalViewEvent::Notification(notification)); } diff --git a/nebula_app/src/gpui_shell/terminal/view/native_cmd_tests.rs b/nebula_app/src/gpui_shell/terminal/view/native_cmd_tests.rs index 5014be7..0628b11 100644 --- a/nebula_app/src/gpui_shell/terminal/view/native_cmd_tests.rs +++ b/nebula_app/src/gpui_shell/terminal/view/native_cmd_tests.rs @@ -4,8 +4,10 @@ use nebula_terminal::event_loop::StreamProcessor; use startup_tests::open; fn prompt_bytes(prompt: &str, input: &str) -> Vec { - format!("\x1b]133;A\x07{prompt}\x1b]133;B\x07\x1b]1337;SetUserVar=pebrel_cmd_prompt=MQ==\x07{input}") - .into_bytes() + format!( + "\x1b]133;A\x07{prompt}\x1b]133;B\x07\x1b]1337;SetUserVar=pebrel_cmd_prompt=MQ==\x07{input}" + ) + .into_bytes() } fn root_process() -> Vec { @@ -89,7 +91,9 @@ fn custom_native_prompts_handle_empty_submit_paste_and_runtime_without_fake_hist match action { "empty" => view.commit_line(cx), "paste" => view.paste_now_impl("pause\r\n", false, cx), - _ => { view.runtime_prompt("pause".into(), true, cx).unwrap(); }, + _ => { + view.runtime_prompt("pause".into(), true, cx).unwrap(); + }, } assert_eq!(view.command_running, action != "empty", "{prompt:?} {action}"); assert!(view.suggest.last_committed.is_empty(), "unconfirmed input is not history"); diff --git a/nebula_app/src/gpui_shell/toast/duration_tests.rs b/nebula_app/src/gpui_shell/toast/duration_tests.rs index 775e838..506db29 100644 --- a/nebula_app/src/gpui_shell/toast/duration_tests.rs +++ b/nebula_app/src/gpui_shell/toast/duration_tests.rs @@ -45,11 +45,7 @@ fn settle_dismissal(cx: &mut VisualTestContext) { } fn pi_completion() -> crate::notify::Notification { - crate::notify::Notification::AiTurn { - program: "pi".into(), - message: None, - attention: false, - } + crate::notify::Notification::AiTurn { program: "pi".into(), message: None, attention: false } } #[gpui::test] @@ -61,12 +57,19 @@ fn pi_completion_uses_five_seconds_only_when_the_user_keeps_the_default(cx: &mut NotificationDuration::ThirtySeconds, NotificationDuration::NinetySeconds, NotificationDuration::Persistent, - ].into_iter().enumerate() { + ] + .into_iter() + .enumerate() + { let mut window = open(duration, cx); window.update(|window, cx| { banner_for_pane( - window, cx, ToastKind::Info, format!("Pi complete {duration:?}"), - 84000 + index as u64, &pi_completion(), + window, + cx, + ToastKind::Info, + format!("Pi complete {duration:?}"), + 84000 + index as u64, + &pi_completion(), ); }); let original = ids(&mut window); @@ -91,7 +94,9 @@ fn pane_result_replacement_preserves_other_panes_and_uses_the_selected_duration( let mut window = open(NotificationDuration::FiveSeconds, cx); let old = window.update(|window, cx| { let failure = crate::notify::Notification::AiTurnIssue { - program: "pi".into(), message: None, outcome: crate::ai_hook::AiTurnOutcome::Failed, + program: "pi".into(), + message: None, + outcome: crate::ai_hook::AiTurnOutcome::Failed, }; banner_for_pane(window, cx, ToastKind::Warning, "Overloaded", 83001, &failure); let old = window.notifications(cx)[0].clone(); diff --git a/nebula_terminal/src/term/prompt.rs b/nebula_terminal/src/term/prompt.rs index 0ff1dcc..5f167ad 100644 --- a/nebula_terminal/src/term/prompt.rs +++ b/nebula_terminal/src/term/prompt.rs @@ -72,9 +72,8 @@ impl Term { if line < self.grid.scrolled_out() || column.0 >= self.columns() { return None; } - let relative = line as i64 - - self.grid.scrolled_out() as i64 - - self.grid.history_size() as i64; + let relative = + line as i64 - self.grid.scrolled_out() as i64 - self.grid.history_size() as i64; let point = Point::new(Line(i32::try_from(relative).ok()?), column); (point.line >= self.grid.topmost_line() && point.line <= self.grid.bottommost_line()) .then_some(point) @@ -114,5 +113,4 @@ impl Term { self.scroll_display(Scroll::Delta(delta)); true } - } From 1cb27fb165d781787ef462248b1677949a6a7c54 Mon Sep 17 00:00:00 2001 From: kuddev Date: Sun, 20 Sep 2026 15:32:20 +0800 Subject: [PATCH 7/9] fix: keep platform startup and CMD regressions within their module boundaries --- .../terminal/view/native_cmd_tests.rs | 718 ++++++++++++++++- .../gpui_shell/terminal/view/startup_tests.rs | 741 ------------------ nebula_app/src/main.rs | 30 +- nebula_app/src/platform/startup.rs | 21 +- nebula_terminal/src/event_loop.rs | 2 +- 5 files changed, 738 insertions(+), 774 deletions(-) diff --git a/nebula_app/src/gpui_shell/terminal/view/native_cmd_tests.rs b/nebula_app/src/gpui_shell/terminal/view/native_cmd_tests.rs index 0628b11..b9215c6 100644 --- a/nebula_app/src/gpui_shell/terminal/view/native_cmd_tests.rs +++ b/nebula_app/src/gpui_shell/terminal/view/native_cmd_tests.rs @@ -1,7 +1,9 @@ //! Native CMD input comes from the marked grid, including history recall. use super::*; +use gpui::TestAppContext; +use nebula_terminal::event::Event; use nebula_terminal::event_loop::StreamProcessor; -use startup_tests::open; +use startup_tests::{feed, open}; fn prompt_bytes(prompt: &str, input: &str) -> Vec { format!( @@ -104,3 +106,717 @@ fn custom_native_prompts_handle_empty_submit_paste_and_runtime_without_fake_hist } } } + +fn native_prompt(view: &mut TerminalView, cx: &mut Context<'_, TerminalView>) { + view.session.as_ref().unwrap().native_prompt.observe_prompt(); + view.process_event(Event::UserVar { name: "pebrel_cmd_prompt".into(), value: "1".into() }, cx); +} + +#[gpui::test] +fn native_queued_prompt_cannot_finish_a_later_submission(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + let (session, _input, mut events, proxy) = session::test_session_with_events(); + view.session = Some(session); + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + // Parse a real prompt, but hold its mailbox delivery until after Enter. + nebula_terminal::event_loop::StreamProcessor::default().feed( + &mut *view.session.as_ref().unwrap().term.lock(), + &proxy, + b"C:\\work>\x1b]1337;SetUserVar=pebrel_cmd_prompt=MQ==\x07pause", + ); + view.runtime_send_key(crate::runtime_api::RuntimeKey::Enter, Default::default(), 1, cx) + .unwrap(); + let started = view.command_started; + while let Ok(event) = events.try_recv() { + view.process_event(event, cx); + } + view.apply_prompt_process_probe( + started, + view.prompt_input_epoch, + Ok(vec![crate::process_tree::ProcessEntry { + pid: 1, + parent_pid: 0, + executable: "cmd.exe".into(), + depth: 0, + }]), + cx, + ); + assert_eq!( + view.sidebar_activity(), + SidebarActivity::Running, + "queued startup prompt must not end pause" + ); + // A later real prompt still completes this same command without another Enter. + nebula_terminal::event_loop::StreamProcessor::default().feed( + &mut *view.session.as_ref().unwrap().term.lock(), + &proxy, + b"\r\nC:\\work>\x1b]1337;SetUserVar=pebrel_cmd_prompt=MQ==\x07", + ); + while let Ok(event) = events.try_recv() { + view.process_event(event, cx); + } + view.apply_prompt_process_probe( + started, + view.prompt_input_epoch, + Ok(vec![crate::process_tree::ProcessEntry { + pid: 1, + parent_pid: 0, + executable: "cmd.exe".into(), + depth: 0, + }]), + cx, + ); + assert_eq!(view.sidebar_activity(), SidebarActivity::Idle); + }); +} + +#[gpui::test] +fn native_queued_prompt_allows_fast_next_submission(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + let (session, _input, _events, proxy) = session::test_session_with_events(); + view.session = Some(session); + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + feed(view, b"C:\\work>echo first"); + view.runtime_send_key(crate::runtime_api::RuntimeKey::Enter, Default::default(), 1, cx) + .unwrap(); + let first = view.command_started; + nebula_terminal::event_loop::StreamProcessor::default().feed( + &mut *view.session.as_ref().unwrap().term.lock(), + &proxy, + b"\r\nfirst\r\nC:\\work>\x1b]1337;SetUserVar=pebrel_cmd_prompt=MQ==\x07pause", + ); + view.runtime_send_key(crate::runtime_api::RuntimeKey::Enter, Default::default(), 1, cx) + .unwrap(); + assert_eq!( + view.suggest.last_committed, "pause", + "a parsed prompt owns the next input even before UI delivery" + ); + assert_ne!(view.command_started, first, "new command invalidates first command probes"); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + }); +} + +#[gpui::test] +fn native_queued_prompt_allows_fast_newline_paste(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + let (session, _input, mut events, proxy) = session::test_session_with_events(); + view.session = Some(session); + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + feed(view, b"C:\\work>echo first"); + view.runtime_send_key(crate::runtime_api::RuntimeKey::Enter, Default::default(), 1, cx) + .unwrap(); + let first = view.command_started; + nebula_terminal::event_loop::StreamProcessor::default().feed( + &mut *view.session.as_ref().unwrap().term.lock(), + &proxy, + b"\r\nfirst\r\nC:\\work>\x1b]1337;SetUserVar=pebrel_cmd_prompt=MQ==\x07", + ); + view.paste_now_impl("pause\r\n", false, cx); + assert_ne!(view.command_started, first, "newline paste starts its own command boundary"); + while let Ok(event) = events.try_recv() { + view.process_event(event, cx); + } + view.apply_prompt_process_probe( + first, + 1, + Ok(vec![crate::process_tree::ProcessEntry { + pid: 1, + parent_pid: 0, + executable: "cmd.exe".into(), + depth: 0, + }]), + cx, + ); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + assert_eq!( + view.suggest.last_committed, "echo first", + "unconfirmed paste must not enter history" + ); + }); +} + +#[gpui::test] +fn native_cmd_submission_starts_activity_without_osc_or_clink(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + feed(view, b"C:\\work>ping -n 8 127.0.0.1"); + view.suggest.line_buf = "ping -n 8 127.0.0.1".into(); + view.commit_line(cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + assert_eq!(view.suggest.pending_command_prompt.as_deref(), Some("C:\\work>")); + }); +} + +#[gpui::test] +fn native_cmd_prompt_return_ends_non_agent_command(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + view.suggest.pending_command_prompt = Some("C:\\work>".into()); + view.mark_command_running(); + feed(view, b"C:\\work>"); + view.refresh_agent_screen_state(cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + view.apply_prompt_process_probe( + view.command_started, + view.prompt_input_epoch, + Ok(vec![]), + cx, + ); + assert_eq!(view.sidebar_activity(), SidebarActivity::Idle); + assert!(view.suggest.pending_command_prompt.is_none()); + }); +} + +#[gpui::test] +fn native_cmd_internal_input_keeps_the_original_command_boundary(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + view.suggest.pending_command_prompt = Some("C:\\work>".into()); + view.mark_command_running(); + let started = view.command_started; + feed(view, b"Password: reply"); + view.suggest.line_buf = "reply".into(); + view.commit_line(cx); + assert_eq!(view.suggest.pending_command_prompt.as_deref(), Some("C:\\work>")); + assert_eq!(view.command_started, started); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + }); +} + +#[gpui::test] +fn native_cmd_empty_enter_does_not_start_activity(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + feed(view, b"C:\\work>"); + view.commit_line(cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Idle); + }); +} + +#[gpui::test] +fn native_cmd_history_submission_uses_echo_without_typed_mirror(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + feed(view, b"C:\\work>ping -n 8 127.0.0.1"); + view.commit_line(cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + assert_eq!(view.suggest.last_committed, "ping -n 8 127.0.0.1"); + }); +} + +#[gpui::test] +fn native_cmd_changed_directory_prompt_ends_command(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + view.suggest.pending_command_prompt = Some("C:\\work>".into()); + view.mark_command_running(); + feed(view, b"D:\\other>"); + view.refresh_agent_screen_state(cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + view.apply_prompt_process_probe( + view.command_started, + view.prompt_input_epoch, + Ok(vec![]), + cx, + ); + assert_eq!(view.sidebar_activity(), SidebarActivity::Idle); + }); +} + +#[gpui::test] +fn native_cmd_alternate_screen_prompt_is_not_shell_completion(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + view.suggest.pending_command_prompt = Some("C:\\work>".into()); + view.mark_command_running(); + feed(view, b"\x1b[?1049hC:\\work>"); + view.refresh_agent_screen_state(cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + }); +} + +#[gpui::test] +fn native_cmd_unknown_process_snapshot_does_not_end_builtin_wait(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + view.suggest.pending_command_prompt = Some("C:\\work>".into()); + view.mark_command_running(); + view.command_started = Some(std::time::Instant::now() - std::time::Duration::from_secs(5)); + view.session.as_mut().unwrap().shell_pid = u32::MAX; + feed(view, b"Value: "); + view.refresh_agent_screen_state(cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + }); +} + +#[gpui::test] +fn native_cmd_runtime_enter_uses_the_same_submission_boundary(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + feed(view, b"C:\\work>pause"); + view.runtime_send_key(crate::runtime_api::RuntimeKey::Enter, Default::default(), 1, cx) + .unwrap(); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + assert_eq!(view.suggest.pending_command_prompt.as_deref(), Some("C:\\work>")); + }); +} + +#[gpui::test] +fn native_cmd_runtime_prompt_captures_echo_before_submitting(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + feed(view, b"C:\\work>"); + view.runtime_prompt("pause".into(), true, cx).unwrap(); + feed(view, b"pause"); + view.flush_pending_runtime_submit(cx); + assert_eq!(view.suggest.pending_command_prompt.as_deref(), Some("C:\\work>")); + feed(view, b"\r\nPress any key to continue . . ."); + view.refresh_agent_screen_state(cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + feed(view, b"\r\nC:\\work>"); + view.refresh_agent_screen_state(cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + view.apply_prompt_process_probe( + view.command_started, + view.prompt_input_epoch, + Ok(vec![]), + cx, + ); + assert_eq!(view.sidebar_activity(), SidebarActivity::Idle); + }); +} + +#[gpui::test] +fn native_cmd_prompt_probe_requires_current_successful_process_evidence(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + view.suggest.pending_command_prompt = Some("C:\\work>".into()); + view.mark_command_running(); + let started = view.command_started; + feed(view, b"C:\\work>"); + let root = crate::process_tree::ProcessEntry { + pid: 1, + parent_pid: 0, + executable: "cmd.exe".into(), + depth: 0, + }; + let child = crate::process_tree::ProcessEntry { + pid: 2, + parent_pid: 1, + executable: "python.exe".into(), + depth: 1, + }; + view.apply_prompt_process_probe(started, 0, Err("snapshot unavailable".into()), cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + view.apply_prompt_process_probe(started, 0, Ok(vec![root.clone(), child]), cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + view.apply_prompt_process_probe(None, 0, Ok(vec![root.clone()]), cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running, "stale command result"); + feed(view, b"still executing"); + view.apply_prompt_process_probe(started, 0, Ok(vec![root.clone()]), cx); + assert_eq!( + view.sidebar_activity(), + SidebarActivity::Running, + "prompt changed during probe" + ); + feed(view, b"\r\nC:\\work>"); + view.apply_prompt_process_probe(started, 0, Ok(vec![root]), cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Idle); + }); +} + +#[gpui::test] +fn native_cmd_input_invalidates_pending_prompt_probe(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + view.suggest.pending_command_prompt = Some("C:\\work>".into()); + view.mark_command_running(); + let started = view.command_started; + feed(view, b"C:\\work>"); + // The new input has reached the PTY, but its echo has not arrived yet. + view.write_input(b"pause\r".to_vec(), cx); + view.apply_prompt_process_probe( + started, + 0, + Ok(vec![crate::process_tree::ProcessEntry { + pid: 1, + parent_pid: 0, + executable: "cmd.exe".into(), + depth: 0, + }]), + cx, + ); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + }); +} + +#[gpui::test] +fn native_marker_restores_prompt_despite_background_process(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + view.mark_command_running(); + let started = view.command_started; + native_prompt(view, cx); + view.apply_prompt_process_probe( + started, + 0, + Ok(vec![ + crate::process_tree::ProcessEntry { + pid: 1, + parent_pid: 0, + executable: "cmd.exe".into(), + depth: 0, + }, + crate::process_tree::ProcessEntry { + pid: 2, + parent_pid: 1, + executable: "python.exe".into(), + depth: 1, + }, + ]), + cx, + ); + assert_eq!(view.sidebar_activity(), SidebarActivity::Idle); + }); +} + +#[gpui::test] +fn native_marker_disables_visible_prompt_guessing(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + native_prompt(view, cx); + view.mark_command_running(); + view.suggest.pending_command_prompt = Some("C:\\work>".into()); + feed(view, b"C:\\work>"); + view.refresh_agent_screen_state(cx); + assert_eq!( + view.sidebar_activity(), + SidebarActivity::Running, + "set /p or removed PROMPT marker is not completion" + ); + }); +} + +#[gpui::test] +fn native_marker_rejects_nested_shell_and_unknown_snapshot(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + view.mark_command_running(); + let started = view.command_started; + native_prompt(view, cx); + view.apply_prompt_process_probe(started, 0, Err("unavailable".into()), cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + let root = crate::process_tree::ProcessEntry { + pid: 1, + parent_pid: 0, + executable: "cmd.exe".into(), + depth: 0, + }; + view.apply_prompt_process_probe( + started, + 0, + Ok(vec![ + root.clone(), + crate::process_tree::ProcessEntry { + pid: 2, + parent_pid: 1, + executable: "cmd.exe".into(), + depth: 1, + }, + ]), + cx, + ); + assert_eq!(view.sidebar_activity(), SidebarActivity::Idle); + assert!(view.command_running, "outer command remains live"); + // The periodic process fallback must not undo a confirmed inner prompt. + view.session.as_mut().unwrap().shell_pid = std::process::id(); + view.suggest.last_committed = "ping -n 5 127.0.0.1".into(); + view.command_started = Some(std::time::Instant::now() - std::time::Duration::from_secs(4)); + view.reconcile_shell_activity(cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Idle); + view.command_started = started; + // A fresh outer prompt after the nested shell exits can complete the run. + native_prompt(view, cx); + view.apply_prompt_process_probe(started, 0, Ok(vec![root]), cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Idle); + }); +} + +#[gpui::test] +fn native_nested_prompt_keeps_outer_run_and_agent_identity(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + view.mark_command_running(); + view.active_run = Some(crate::runtime_api::begin_runtime_run()); + let run_id = view.active_run.unwrap().run_id; + let started = view.command_started; + let processes = vec![ + crate::process_tree::ProcessEntry { + pid: 1, + parent_pid: 0, + executable: "cmd.exe".into(), + depth: 0, + }, + crate::process_tree::ProcessEntry { + pid: 2, + parent_pid: 1, + executable: "cmd.exe".into(), + depth: 1, + }, + ]; + native_prompt(view, cx); + view.apply_prompt_process_probe(started, 0, Ok(processes.clone()), cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Idle); + assert_eq!(view.active_run.unwrap().run_id, run_id); + assert!(view.last_run.is_none()); + feed(view, b"C:\\work>pause"); + view.commit_line(cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + view.running_program = Some("pi".into()); + let hook = crate::ai_hook::parse_remote_envelope( + b"nebula-hook/1 source=pi pane=42\n{\"kind\":\"prompt\",\"session_id\":\"nested\",\"bridge_sequence\":1}", + Some(42), + ).unwrap(); + view.handle_ai_hook(&hook, cx); + let started = view.command_started; + native_prompt(view, cx); + view.apply_prompt_process_probe(started, 0, Ok(processes), cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + assert_eq!(view.running_program.as_deref(), Some("pi")); + assert!(view.agent_activity.hook_seen()); + assert_eq!(view.active_run.unwrap().run_id, run_id); + }); +} + +#[gpui::test] +fn native_nested_prompt_clears_cli_progress_without_ending_outer_run(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + view.mark_command_running(); + view.active_run = Some(crate::runtime_api::begin_runtime_run()); + let run_id = view.active_run.unwrap().run_id; + let started = view.command_started; + view.process_event(Event::Progress { state: 3, value: None }, cx); + native_prompt(view, cx); + view.apply_prompt_process_probe( + started, + 0, + Ok(vec![ + crate::process_tree::ProcessEntry { + pid: 1, + parent_pid: 0, + executable: "cmd.exe".into(), + depth: 0, + }, + crate::process_tree::ProcessEntry { + pid: 2, + parent_pid: 1, + executable: "cmd.exe".into(), + depth: 1, + }, + ]), + cx, + ); + assert_eq!(view.sidebar_activity(), SidebarActivity::Idle); + assert_eq!(view.progress, crate::taskbar::TaskProgress::None); + assert_eq!(view.active_run.unwrap().run_id, run_id); + assert!(view.last_run.is_none()); + }); +} + +#[gpui::test] +fn native_marker_pending_probe_cannot_finish_new_input(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + view.mark_command_running(); + let started = view.command_started; + native_prompt(view, cx); + view.write_input(b"pause\r".to_vec(), cx); + view.apply_prompt_process_probe(started, 0, Ok(vec![]), cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + }); +} + +#[gpui::test] +fn native_prompt_survives_typing_before_process_probe_returns(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + view.mark_command_running(); + let started = view.command_started; + native_prompt(view, cx); + view.write_input(b"echo next".to_vec(), cx); + let root = crate::process_tree::ProcessEntry { + pid: 1, + parent_pid: 0, + executable: "cmd.exe".into(), + depth: 0, + }; + view.apply_prompt_process_probe(started, 0, Ok(vec![root.clone()]), cx); + assert_eq!( + view.sidebar_activity(), + SidebarActivity::Running, + "old input snapshot is discarded" + ); + view.apply_prompt_process_probe(started, 1, Ok(vec![root]), cx); + assert_eq!( + view.sidebar_activity(), + SidebarActivity::Idle, + "typing without submitting must not lose the only prompt marker" + ); + }); +} + +#[gpui::test] +fn native_queued_prompt_cannot_finish_encoded_submission(cx: &mut TestAppContext) { + for bytes in [&b"pause\r"[..], &b"\x1b[13;28;13;1;0;1_\x1b[13;28;13;0;0;1_"[..]] { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.mark_command_running(); + let started = view.command_started; + native_prompt(view, cx); + view.write_input(bytes.to_vec(), cx); + view.apply_prompt_process_probe( + started, + view.prompt_input_epoch, + Ok(vec![crate::process_tree::ProcessEntry { + pid: 1, + parent_pid: 0, + executable: "cmd.exe".into(), + depth: 0, + }]), + cx, + ); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running, "{bytes:?}"); + }); + } +} + +#[gpui::test] +fn native_queued_prompt_survives_editing_keys(cx: &mut TestAppContext) { + for bytes in [ + &b"\x08"[..], + &b"\x1b[D"[..], + &b"\x1b[8;14;8;1;0;1_\x1b[8;14;8;0;0;1_"[..], + &b"\x1b[65;30;97;1;0;1_\x1b[65;30;97;0;0;1_"[..], + ] { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + let (session, _input, mut events, proxy) = session::test_session_with_events(); + view.session = Some(session); + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + view.mark_command_running(); + let started = view.command_started; + nebula_terminal::event_loop::StreamProcessor::default().feed( + &mut *view.session.as_ref().unwrap().term.lock(), + &proxy, + b"C:\\work>\x1b]1337;SetUserVar=pebrel_cmd_prompt=MQ==\x07", + ); + view.write_input(bytes.to_vec(), cx); + while let Ok(event) = events.try_recv() { + view.process_event(event, cx); + } + view.apply_prompt_process_probe( + started, + view.prompt_input_epoch, + Ok(vec![crate::process_tree::ProcessEntry { + pid: 1, + parent_pid: 0, + executable: "cmd.exe".into(), + depth: 0, + }]), + cx, + ); + assert_eq!(view.sidebar_activity(), SidebarActivity::Idle, "{bytes:?}"); + }); + } +} + +#[gpui::test] +fn native_newline_paste_starts_command_without_recording_unconfirmed_history( + cx: &mut TestAppContext, +) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + feed(view, b"C:\\work>"); + native_prompt(view, cx); + view.paste_now_impl("pause\r\n", false, cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + assert_eq!(view.suggest.pending_command_prompt.as_deref(), Some("C:\\work>")); + assert!(view.suggest.last_committed.is_empty(), "paste is not yet echoed shell history"); + }); +} + +#[gpui::test] +fn native_paste_without_shell_submission_does_not_start_command(cx: &mut TestAppContext) { + for (screen, pasted, bracketed) in [ + ("C:\\work>", "pause", false), + ("C:\\work>", "\r\n", false), + ("Password: ", "secret\r\n", false), + ("C:\\work>", "pause\r\n", true), + ] { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + feed(view, screen.as_bytes()); + native_prompt(view, cx); + if bracketed { + feed(view, b"\x1b[?2004h"); + } + view.paste_now_impl(pasted, false, cx); + assert_eq!( + view.sidebar_activity(), + SidebarActivity::Idle, + "{screen:?} {pasted:?} bracketed={bracketed}" + ); + }); + } +} + +#[gpui::test] +fn native_prompt_completion_clears_progress_without_another_enter(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + feed(view, b"C:\\work>python synthetic-progress.py"); + view.commit_line(cx); + let started = view.command_started; + view.process_event(Event::Progress { state: 3, value: None }, cx); + native_prompt(view, cx); + view.apply_prompt_process_probe( + started, + 0, + Ok(vec![crate::process_tree::ProcessEntry { + pid: 1, + parent_pid: 0, + executable: "cmd.exe".into(), + depth: 0, + }]), + cx, + ); + assert_eq!(view.sidebar_activity(), SidebarActivity::Idle); + assert_eq!(view.progress, crate::taskbar::TaskProgress::None); + }); +} diff --git a/nebula_app/src/gpui_shell/terminal/view/startup_tests.rs b/nebula_app/src/gpui_shell/terminal/view/startup_tests.rs index db22625..320fd9d 100644 --- a/nebula_app/src/gpui_shell/terminal/view/startup_tests.rs +++ b/nebula_app/src/gpui_shell/terminal/view/startup_tests.rs @@ -54,11 +54,6 @@ pub(super) fn open( (view, window, receiver) } -fn native_prompt(view: &mut TerminalView, cx: &mut Context<'_, TerminalView>) { - view.session.as_ref().unwrap().native_prompt.observe_prompt(); - view.process_event(Event::UserVar { name: "pebrel_cmd_prompt".into(), value: "1".into() }, cx); -} - pub(super) fn feed(view: &mut TerminalView, bytes: &[u8]) { let mut term = view.session.as_ref().unwrap().term.lock(); let mut parser = nebula_terminal::vte::ansi::Processor::< @@ -67,715 +62,6 @@ pub(super) fn feed(view: &mut TerminalView, bytes: &[u8]) { parser.advance(&mut *term, bytes); } -#[cfg(windows)] -#[gpui::test] -fn native_queued_prompt_cannot_finish_a_later_submission(cx: &mut TestAppContext) { - let (view, window, _) = open(cx); - view.update(window, |view, cx| { - let (session, _input, mut events, proxy) = session::test_session_with_events(); - view.session = Some(session); - view.suggest.suggest_env = crate::display::SuggestEnv::Local; - // Parse a real prompt, but hold its mailbox delivery until after Enter. - nebula_terminal::event_loop::StreamProcessor::default().feed( - &mut *view.session.as_ref().unwrap().term.lock(), - &proxy, - b"C:\\work>\x1b]1337;SetUserVar=pebrel_cmd_prompt=MQ==\x07pause", - ); - view.runtime_send_key(crate::runtime_api::RuntimeKey::Enter, Default::default(), 1, cx) - .unwrap(); - let started = view.command_started; - while let Ok(event) = events.try_recv() { - view.process_event(event, cx); - } - view.apply_prompt_process_probe( - started, - view.prompt_input_epoch, - Ok(vec![crate::process_tree::ProcessEntry { - pid: 1, - parent_pid: 0, - executable: "cmd.exe".into(), - depth: 0, - }]), - cx, - ); - assert_eq!( - view.sidebar_activity(), - SidebarActivity::Running, - "queued startup prompt must not end pause" - ); - // A later real prompt still completes this same command without another Enter. - nebula_terminal::event_loop::StreamProcessor::default().feed( - &mut *view.session.as_ref().unwrap().term.lock(), - &proxy, - b"\r\nC:\\work>\x1b]1337;SetUserVar=pebrel_cmd_prompt=MQ==\x07", - ); - while let Ok(event) = events.try_recv() { - view.process_event(event, cx); - } - view.apply_prompt_process_probe( - started, - view.prompt_input_epoch, - Ok(vec![crate::process_tree::ProcessEntry { - pid: 1, - parent_pid: 0, - executable: "cmd.exe".into(), - depth: 0, - }]), - cx, - ); - assert_eq!(view.sidebar_activity(), SidebarActivity::Idle); - }); -} - -#[cfg(windows)] -#[gpui::test] -fn native_queued_prompt_allows_fast_next_submission(cx: &mut TestAppContext) { - let (view, window, _) = open(cx); - view.update(window, |view, cx| { - let (session, _input, _events, proxy) = session::test_session_with_events(); - view.session = Some(session); - view.suggest.suggest_env = crate::display::SuggestEnv::Local; - feed(view, b"C:\\work>echo first"); - view.runtime_send_key(crate::runtime_api::RuntimeKey::Enter, Default::default(), 1, cx) - .unwrap(); - let first = view.command_started; - nebula_terminal::event_loop::StreamProcessor::default().feed( - &mut *view.session.as_ref().unwrap().term.lock(), - &proxy, - b"\r\nfirst\r\nC:\\work>\x1b]1337;SetUserVar=pebrel_cmd_prompt=MQ==\x07pause", - ); - view.runtime_send_key(crate::runtime_api::RuntimeKey::Enter, Default::default(), 1, cx) - .unwrap(); - assert_eq!( - view.suggest.last_committed, "pause", - "a parsed prompt owns the next input even before UI delivery" - ); - assert_ne!(view.command_started, first, "new command invalidates first command probes"); - assert_eq!(view.sidebar_activity(), SidebarActivity::Running); - }); -} - -#[cfg(windows)] -#[gpui::test] -fn native_queued_prompt_allows_fast_newline_paste(cx: &mut TestAppContext) { - let (view, window, _) = open(cx); - view.update(window, |view, cx| { - let (session, _input, mut events, proxy) = session::test_session_with_events(); - view.session = Some(session); - view.suggest.suggest_env = crate::display::SuggestEnv::Local; - feed(view, b"C:\\work>echo first"); - view.runtime_send_key(crate::runtime_api::RuntimeKey::Enter, Default::default(), 1, cx) - .unwrap(); - let first = view.command_started; - nebula_terminal::event_loop::StreamProcessor::default().feed( - &mut *view.session.as_ref().unwrap().term.lock(), - &proxy, - b"\r\nfirst\r\nC:\\work>\x1b]1337;SetUserVar=pebrel_cmd_prompt=MQ==\x07", - ); - view.paste_now_impl("pause\r\n", false, cx); - assert_ne!(view.command_started, first, "newline paste starts its own command boundary"); - while let Ok(event) = events.try_recv() { - view.process_event(event, cx); - } - view.apply_prompt_process_probe( - first, - 1, - Ok(vec![crate::process_tree::ProcessEntry { - pid: 1, - parent_pid: 0, - executable: "cmd.exe".into(), - depth: 0, - }]), - cx, - ); - assert_eq!(view.sidebar_activity(), SidebarActivity::Running); - assert_eq!( - view.suggest.last_committed, "echo first", - "unconfirmed paste must not enter history" - ); - }); -} - -#[cfg(windows)] -#[gpui::test] -fn native_cmd_submission_starts_activity_without_osc_or_clink(cx: &mut TestAppContext) { - let (view, window, _) = open(cx); - view.update(window, |view, cx| { - view.suggest.suggest_env = crate::display::SuggestEnv::Local; - feed(view, b"C:\\work>ping -n 8 127.0.0.1"); - view.suggest.line_buf = "ping -n 8 127.0.0.1".into(); - view.commit_line(cx); - assert_eq!(view.sidebar_activity(), SidebarActivity::Running); - assert_eq!(view.suggest.pending_command_prompt.as_deref(), Some("C:\\work>")); - }); -} - -#[cfg(windows)] -#[gpui::test] -fn native_cmd_prompt_return_ends_non_agent_command(cx: &mut TestAppContext) { - let (view, window, _) = open(cx); - view.update(window, |view, cx| { - view.suggest.suggest_env = crate::display::SuggestEnv::Local; - view.suggest.pending_command_prompt = Some("C:\\work>".into()); - view.mark_command_running(); - feed(view, b"C:\\work>"); - view.refresh_agent_screen_state(cx); - assert_eq!(view.sidebar_activity(), SidebarActivity::Running); - view.apply_prompt_process_probe( - view.command_started, - view.prompt_input_epoch, - Ok(vec![]), - cx, - ); - assert_eq!(view.sidebar_activity(), SidebarActivity::Idle); - assert!(view.suggest.pending_command_prompt.is_none()); - }); -} - -#[cfg(windows)] -#[gpui::test] -fn native_cmd_internal_input_keeps_the_original_command_boundary(cx: &mut TestAppContext) { - let (view, window, _) = open(cx); - view.update(window, |view, cx| { - view.suggest.suggest_env = crate::display::SuggestEnv::Local; - view.suggest.pending_command_prompt = Some("C:\\work>".into()); - view.mark_command_running(); - let started = view.command_started; - feed(view, b"Password: reply"); - view.suggest.line_buf = "reply".into(); - view.commit_line(cx); - assert_eq!(view.suggest.pending_command_prompt.as_deref(), Some("C:\\work>")); - assert_eq!(view.command_started, started); - assert_eq!(view.sidebar_activity(), SidebarActivity::Running); - }); -} - -#[cfg(windows)] -#[gpui::test] -fn native_cmd_empty_enter_does_not_start_activity(cx: &mut TestAppContext) { - let (view, window, _) = open(cx); - view.update(window, |view, cx| { - view.suggest.suggest_env = crate::display::SuggestEnv::Local; - feed(view, b"C:\\work>"); - view.commit_line(cx); - assert_eq!(view.sidebar_activity(), SidebarActivity::Idle); - }); -} - -#[cfg(windows)] -#[gpui::test] -fn native_cmd_history_submission_uses_echo_without_typed_mirror(cx: &mut TestAppContext) { - let (view, window, _) = open(cx); - view.update(window, |view, cx| { - view.suggest.suggest_env = crate::display::SuggestEnv::Local; - feed(view, b"C:\\work>ping -n 8 127.0.0.1"); - view.commit_line(cx); - assert_eq!(view.sidebar_activity(), SidebarActivity::Running); - assert_eq!(view.suggest.last_committed, "ping -n 8 127.0.0.1"); - }); -} - -#[cfg(windows)] -#[gpui::test] -fn native_cmd_changed_directory_prompt_ends_command(cx: &mut TestAppContext) { - let (view, window, _) = open(cx); - view.update(window, |view, cx| { - view.suggest.suggest_env = crate::display::SuggestEnv::Local; - view.suggest.pending_command_prompt = Some("C:\\work>".into()); - view.mark_command_running(); - feed(view, b"D:\\other>"); - view.refresh_agent_screen_state(cx); - assert_eq!(view.sidebar_activity(), SidebarActivity::Running); - view.apply_prompt_process_probe( - view.command_started, - view.prompt_input_epoch, - Ok(vec![]), - cx, - ); - assert_eq!(view.sidebar_activity(), SidebarActivity::Idle); - }); -} - -#[cfg(windows)] -#[gpui::test] -fn native_cmd_alternate_screen_prompt_is_not_shell_completion(cx: &mut TestAppContext) { - let (view, window, _) = open(cx); - view.update(window, |view, cx| { - view.suggest.suggest_env = crate::display::SuggestEnv::Local; - view.suggest.pending_command_prompt = Some("C:\\work>".into()); - view.mark_command_running(); - feed(view, b"\x1b[?1049hC:\\work>"); - view.refresh_agent_screen_state(cx); - assert_eq!(view.sidebar_activity(), SidebarActivity::Running); - }); -} - -#[cfg(windows)] -#[gpui::test] -fn native_cmd_unknown_process_snapshot_does_not_end_builtin_wait(cx: &mut TestAppContext) { - let (view, window, _) = open(cx); - view.update(window, |view, cx| { - view.suggest.suggest_env = crate::display::SuggestEnv::Local; - view.suggest.pending_command_prompt = Some("C:\\work>".into()); - view.mark_command_running(); - view.command_started = Some(std::time::Instant::now() - std::time::Duration::from_secs(5)); - view.session.as_mut().unwrap().shell_pid = u32::MAX; - feed(view, b"Value: "); - view.refresh_agent_screen_state(cx); - assert_eq!(view.sidebar_activity(), SidebarActivity::Running); - }); -} - -#[cfg(windows)] -#[gpui::test] -fn native_cmd_runtime_enter_uses_the_same_submission_boundary(cx: &mut TestAppContext) { - let (view, window, _) = open(cx); - view.update(window, |view, cx| { - view.suggest.suggest_env = crate::display::SuggestEnv::Local; - feed(view, b"C:\\work>pause"); - view.runtime_send_key(crate::runtime_api::RuntimeKey::Enter, Default::default(), 1, cx) - .unwrap(); - assert_eq!(view.sidebar_activity(), SidebarActivity::Running); - assert_eq!(view.suggest.pending_command_prompt.as_deref(), Some("C:\\work>")); - }); -} - -#[cfg(windows)] -#[gpui::test] -fn native_cmd_runtime_prompt_captures_echo_before_submitting(cx: &mut TestAppContext) { - let (view, window, _) = open(cx); - view.update(window, |view, cx| { - view.suggest.suggest_env = crate::display::SuggestEnv::Local; - feed(view, b"C:\\work>"); - view.runtime_prompt("pause".into(), true, cx).unwrap(); - feed(view, b"pause"); - view.flush_pending_runtime_submit(cx); - assert_eq!(view.suggest.pending_command_prompt.as_deref(), Some("C:\\work>")); - feed(view, b"\r\nPress any key to continue . . ."); - view.refresh_agent_screen_state(cx); - assert_eq!(view.sidebar_activity(), SidebarActivity::Running); - feed(view, b"\r\nC:\\work>"); - view.refresh_agent_screen_state(cx); - assert_eq!(view.sidebar_activity(), SidebarActivity::Running); - view.apply_prompt_process_probe( - view.command_started, - view.prompt_input_epoch, - Ok(vec![]), - cx, - ); - assert_eq!(view.sidebar_activity(), SidebarActivity::Idle); - }); -} - -#[cfg(windows)] -#[gpui::test] -fn native_cmd_prompt_probe_requires_current_successful_process_evidence(cx: &mut TestAppContext) { - let (view, window, _) = open(cx); - view.update(window, |view, cx| { - view.suggest.suggest_env = crate::display::SuggestEnv::Local; - view.suggest.pending_command_prompt = Some("C:\\work>".into()); - view.mark_command_running(); - let started = view.command_started; - feed(view, b"C:\\work>"); - let root = crate::process_tree::ProcessEntry { - pid: 1, - parent_pid: 0, - executable: "cmd.exe".into(), - depth: 0, - }; - let child = crate::process_tree::ProcessEntry { - pid: 2, - parent_pid: 1, - executable: "python.exe".into(), - depth: 1, - }; - view.apply_prompt_process_probe(started, 0, Err("snapshot unavailable".into()), cx); - assert_eq!(view.sidebar_activity(), SidebarActivity::Running); - view.apply_prompt_process_probe(started, 0, Ok(vec![root.clone(), child]), cx); - assert_eq!(view.sidebar_activity(), SidebarActivity::Running); - view.apply_prompt_process_probe(None, 0, Ok(vec![root.clone()]), cx); - assert_eq!(view.sidebar_activity(), SidebarActivity::Running, "stale command result"); - feed(view, b"still executing"); - view.apply_prompt_process_probe(started, 0, Ok(vec![root.clone()]), cx); - assert_eq!( - view.sidebar_activity(), - SidebarActivity::Running, - "prompt changed during probe" - ); - feed(view, b"\r\nC:\\work>"); - view.apply_prompt_process_probe(started, 0, Ok(vec![root]), cx); - assert_eq!(view.sidebar_activity(), SidebarActivity::Idle); - }); -} - -#[cfg(windows)] -#[gpui::test] -fn native_cmd_input_invalidates_pending_prompt_probe(cx: &mut TestAppContext) { - let (view, window, _) = open(cx); - view.update(window, |view, cx| { - view.suggest.suggest_env = crate::display::SuggestEnv::Local; - view.suggest.pending_command_prompt = Some("C:\\work>".into()); - view.mark_command_running(); - let started = view.command_started; - feed(view, b"C:\\work>"); - // The new input has reached the PTY, but its echo has not arrived yet. - view.write_input(b"pause\r".to_vec(), cx); - view.apply_prompt_process_probe( - started, - 0, - Ok(vec![crate::process_tree::ProcessEntry { - pid: 1, - parent_pid: 0, - executable: "cmd.exe".into(), - depth: 0, - }]), - cx, - ); - assert_eq!(view.sidebar_activity(), SidebarActivity::Running); - }); -} - -#[cfg(windows)] -#[gpui::test] -fn native_marker_restores_prompt_despite_background_process(cx: &mut TestAppContext) { - let (view, window, _) = open(cx); - view.update(window, |view, cx| { - view.suggest.suggest_env = crate::display::SuggestEnv::Local; - view.mark_command_running(); - let started = view.command_started; - native_prompt(view, cx); - view.apply_prompt_process_probe( - started, - 0, - Ok(vec![ - crate::process_tree::ProcessEntry { - pid: 1, - parent_pid: 0, - executable: "cmd.exe".into(), - depth: 0, - }, - crate::process_tree::ProcessEntry { - pid: 2, - parent_pid: 1, - executable: "python.exe".into(), - depth: 1, - }, - ]), - cx, - ); - assert_eq!(view.sidebar_activity(), SidebarActivity::Idle); - }); -} - -#[cfg(windows)] -#[gpui::test] -fn native_marker_disables_visible_prompt_guessing(cx: &mut TestAppContext) { - let (view, window, _) = open(cx); - view.update(window, |view, cx| { - view.suggest.suggest_env = crate::display::SuggestEnv::Local; - native_prompt(view, cx); - view.mark_command_running(); - view.suggest.pending_command_prompt = Some("C:\\work>".into()); - feed(view, b"C:\\work>"); - view.refresh_agent_screen_state(cx); - assert_eq!( - view.sidebar_activity(), - SidebarActivity::Running, - "set /p or removed PROMPT marker is not completion" - ); - }); -} - -#[cfg(windows)] -#[gpui::test] -fn native_marker_rejects_nested_shell_and_unknown_snapshot(cx: &mut TestAppContext) { - let (view, window, _) = open(cx); - view.update(window, |view, cx| { - view.suggest.suggest_env = crate::display::SuggestEnv::Local; - view.mark_command_running(); - let started = view.command_started; - native_prompt(view, cx); - view.apply_prompt_process_probe(started, 0, Err("unavailable".into()), cx); - assert_eq!(view.sidebar_activity(), SidebarActivity::Running); - let root = crate::process_tree::ProcessEntry { - pid: 1, - parent_pid: 0, - executable: "cmd.exe".into(), - depth: 0, - }; - view.apply_prompt_process_probe( - started, - 0, - Ok(vec![ - root.clone(), - crate::process_tree::ProcessEntry { - pid: 2, - parent_pid: 1, - executable: "cmd.exe".into(), - depth: 1, - }, - ]), - cx, - ); - assert_eq!(view.sidebar_activity(), SidebarActivity::Idle); - assert!(view.command_running, "outer command remains live"); - // The periodic process fallback must not undo a confirmed inner prompt. - view.session.as_mut().unwrap().shell_pid = std::process::id(); - view.suggest.last_committed = "ping -n 5 127.0.0.1".into(); - view.command_started = Some(std::time::Instant::now() - std::time::Duration::from_secs(4)); - view.reconcile_shell_activity(cx); - assert_eq!(view.sidebar_activity(), SidebarActivity::Idle); - view.command_started = started; - // A fresh outer prompt after the nested shell exits can complete the run. - native_prompt(view, cx); - view.apply_prompt_process_probe(started, 0, Ok(vec![root]), cx); - assert_eq!(view.sidebar_activity(), SidebarActivity::Idle); - }); -} - -#[cfg(windows)] -#[gpui::test] -fn native_nested_prompt_keeps_outer_run_and_agent_identity(cx: &mut TestAppContext) { - let (view, window, _) = open(cx); - view.update(window, |view, cx| { - view.suggest.suggest_env = crate::display::SuggestEnv::Local; - view.mark_command_running(); - view.active_run = Some(crate::runtime_api::begin_runtime_run()); - let run_id = view.active_run.unwrap().run_id; - let started = view.command_started; - let processes = vec![ - crate::process_tree::ProcessEntry { - pid: 1, - parent_pid: 0, - executable: "cmd.exe".into(), - depth: 0, - }, - crate::process_tree::ProcessEntry { - pid: 2, - parent_pid: 1, - executable: "cmd.exe".into(), - depth: 1, - }, - ]; - native_prompt(view, cx); - view.apply_prompt_process_probe(started, 0, Ok(processes.clone()), cx); - assert_eq!(view.sidebar_activity(), SidebarActivity::Idle); - assert_eq!(view.active_run.unwrap().run_id, run_id); - assert!(view.last_run.is_none()); - feed(view, b"C:\\work>pause"); - view.commit_line(cx); - assert_eq!(view.sidebar_activity(), SidebarActivity::Running); - view.running_program = Some("pi".into()); - let hook = crate::ai_hook::parse_remote_envelope( - b"nebula-hook/1 source=pi pane=42\n{\"kind\":\"prompt\",\"session_id\":\"nested\",\"bridge_sequence\":1}", - Some(42), - ).unwrap(); - view.handle_ai_hook(&hook, cx); - let started = view.command_started; - native_prompt(view, cx); - view.apply_prompt_process_probe(started, 0, Ok(processes), cx); - assert_eq!(view.sidebar_activity(), SidebarActivity::Running); - assert_eq!(view.running_program.as_deref(), Some("pi")); - assert!(view.agent_activity.hook_seen()); - assert_eq!(view.active_run.unwrap().run_id, run_id); - }); -} - -#[cfg(windows)] -#[gpui::test] -fn native_nested_prompt_clears_cli_progress_without_ending_outer_run(cx: &mut TestAppContext) { - let (view, window, _) = open(cx); - view.update(window, |view, cx| { - view.suggest.suggest_env = crate::display::SuggestEnv::Local; - view.mark_command_running(); - view.active_run = Some(crate::runtime_api::begin_runtime_run()); - let run_id = view.active_run.unwrap().run_id; - let started = view.command_started; - view.process_event(Event::Progress { state: 3, value: None }, cx); - native_prompt(view, cx); - view.apply_prompt_process_probe( - started, - 0, - Ok(vec![ - crate::process_tree::ProcessEntry { - pid: 1, - parent_pid: 0, - executable: "cmd.exe".into(), - depth: 0, - }, - crate::process_tree::ProcessEntry { - pid: 2, - parent_pid: 1, - executable: "cmd.exe".into(), - depth: 1, - }, - ]), - cx, - ); - assert_eq!(view.sidebar_activity(), SidebarActivity::Idle); - assert_eq!(view.progress, crate::taskbar::TaskProgress::None); - assert_eq!(view.active_run.unwrap().run_id, run_id); - assert!(view.last_run.is_none()); - }); -} - -#[cfg(windows)] -#[gpui::test] -fn native_marker_pending_probe_cannot_finish_new_input(cx: &mut TestAppContext) { - let (view, window, _) = open(cx); - view.update(window, |view, cx| { - view.suggest.suggest_env = crate::display::SuggestEnv::Local; - view.mark_command_running(); - let started = view.command_started; - native_prompt(view, cx); - view.write_input(b"pause\r".to_vec(), cx); - view.apply_prompt_process_probe(started, 0, Ok(vec![]), cx); - assert_eq!(view.sidebar_activity(), SidebarActivity::Running); - }); -} - -#[cfg(windows)] -#[gpui::test] -fn native_prompt_survives_typing_before_process_probe_returns(cx: &mut TestAppContext) { - let (view, window, _) = open(cx); - view.update(window, |view, cx| { - view.suggest.suggest_env = crate::display::SuggestEnv::Local; - view.mark_command_running(); - let started = view.command_started; - native_prompt(view, cx); - view.write_input(b"echo next".to_vec(), cx); - let root = crate::process_tree::ProcessEntry { - pid: 1, - parent_pid: 0, - executable: "cmd.exe".into(), - depth: 0, - }; - view.apply_prompt_process_probe(started, 0, Ok(vec![root.clone()]), cx); - assert_eq!( - view.sidebar_activity(), - SidebarActivity::Running, - "old input snapshot is discarded" - ); - view.apply_prompt_process_probe(started, 1, Ok(vec![root]), cx); - assert_eq!( - view.sidebar_activity(), - SidebarActivity::Idle, - "typing without submitting must not lose the only prompt marker" - ); - }); -} - -#[cfg(windows)] -#[gpui::test] -fn native_queued_prompt_cannot_finish_encoded_submission(cx: &mut TestAppContext) { - for bytes in [&b"pause\r"[..], &b"\x1b[13;28;13;1;0;1_\x1b[13;28;13;0;0;1_"[..]] { - let (view, window, _) = open(cx); - view.update(window, |view, cx| { - view.mark_command_running(); - let started = view.command_started; - native_prompt(view, cx); - view.write_input(bytes.to_vec(), cx); - view.apply_prompt_process_probe( - started, - view.prompt_input_epoch, - Ok(vec![crate::process_tree::ProcessEntry { - pid: 1, - parent_pid: 0, - executable: "cmd.exe".into(), - depth: 0, - }]), - cx, - ); - assert_eq!(view.sidebar_activity(), SidebarActivity::Running, "{bytes:?}"); - }); - } -} - -#[cfg(windows)] -#[gpui::test] -fn native_queued_prompt_survives_editing_keys(cx: &mut TestAppContext) { - for bytes in [ - &b"\x08"[..], - &b"\x1b[D"[..], - &b"\x1b[8;14;8;1;0;1_\x1b[8;14;8;0;0;1_"[..], - &b"\x1b[65;30;97;1;0;1_\x1b[65;30;97;0;0;1_"[..], - ] { - let (view, window, _) = open(cx); - view.update(window, |view, cx| { - let (session, _input, mut events, proxy) = session::test_session_with_events(); - view.session = Some(session); - view.suggest.suggest_env = crate::display::SuggestEnv::Local; - view.mark_command_running(); - let started = view.command_started; - nebula_terminal::event_loop::StreamProcessor::default().feed( - &mut *view.session.as_ref().unwrap().term.lock(), - &proxy, - b"C:\\work>\x1b]1337;SetUserVar=pebrel_cmd_prompt=MQ==\x07", - ); - view.write_input(bytes.to_vec(), cx); - while let Ok(event) = events.try_recv() { - view.process_event(event, cx); - } - view.apply_prompt_process_probe( - started, - view.prompt_input_epoch, - Ok(vec![crate::process_tree::ProcessEntry { - pid: 1, - parent_pid: 0, - executable: "cmd.exe".into(), - depth: 0, - }]), - cx, - ); - assert_eq!(view.sidebar_activity(), SidebarActivity::Idle, "{bytes:?}"); - }); - } -} - -#[cfg(windows)] -#[gpui::test] -fn native_newline_paste_starts_command_without_recording_unconfirmed_history( - cx: &mut TestAppContext, -) { - let (view, window, _) = open(cx); - view.update(window, |view, cx| { - view.suggest.suggest_env = crate::display::SuggestEnv::Local; - feed(view, b"C:\\work>"); - native_prompt(view, cx); - view.paste_now_impl("pause\r\n", false, cx); - assert_eq!(view.sidebar_activity(), SidebarActivity::Running); - assert_eq!(view.suggest.pending_command_prompt.as_deref(), Some("C:\\work>")); - assert!(view.suggest.last_committed.is_empty(), "paste is not yet echoed shell history"); - }); -} - -#[cfg(windows)] -#[gpui::test] -fn native_paste_without_shell_submission_does_not_start_command(cx: &mut TestAppContext) { - for (screen, pasted, bracketed) in [ - ("C:\\work>", "pause", false), - ("C:\\work>", "\r\n", false), - ("Password: ", "secret\r\n", false), - ("C:\\work>", "pause\r\n", true), - ] { - let (view, window, _) = open(cx); - view.update(window, |view, cx| { - view.suggest.suggest_env = crate::display::SuggestEnv::Local; - feed(view, screen.as_bytes()); - native_prompt(view, cx); - if bracketed { - feed(view, b"\x1b[?2004h"); - } - view.paste_now_impl(pasted, false, cx); - assert_eq!( - view.sidebar_activity(), - SidebarActivity::Idle, - "{screen:?} {pasted:?} bracketed={bracketed}" - ); - }); - } -} - #[gpui::test] fn ended_command_does_not_leave_osc_progress_running(cx: &mut TestAppContext) { let (view, window, _) = open(cx); @@ -796,33 +82,6 @@ fn ended_command_does_not_leave_osc_progress_running(cx: &mut TestAppContext) { }); } -#[cfg(windows)] -#[gpui::test] -fn native_prompt_completion_clears_progress_without_another_enter(cx: &mut TestAppContext) { - let (view, window, _) = open(cx); - view.update(window, |view, cx| { - view.suggest.suggest_env = crate::display::SuggestEnv::Local; - feed(view, b"C:\\work>python synthetic-progress.py"); - view.commit_line(cx); - let started = view.command_started; - view.process_event(Event::Progress { state: 3, value: None }, cx); - native_prompt(view, cx); - view.apply_prompt_process_probe( - started, - 0, - Ok(vec![crate::process_tree::ProcessEntry { - pid: 1, - parent_pid: 0, - executable: "cmd.exe".into(), - depth: 0, - }]), - cx, - ); - assert_eq!(view.sidebar_activity(), SidebarActivity::Idle); - assert_eq!(view.progress, crate::taskbar::TaskProgress::None); - }); -} - #[gpui::test] fn ligature_changes_update_all_faces_without_replacing_the_open_session(cx: &mut TestAppContext) { let (view, window, _) = open(cx); diff --git a/nebula_app/src/main.rs b/nebula_app/src/main.rs index a2414d4..cf44c5a 100644 --- a/nebula_app/src/main.rs +++ b/nebula_app/src/main.rs @@ -195,36 +195,8 @@ fn main() -> Result<(), Box> { #[cfg(windows)] panic::attach_handler(); - // Portable builds are not necessarily on PATH. Export the exact executable - // before any PTY is created so Codex/Claude can call the supported control - // plane directly instead of scanning processes, port files, or source code. - // - // This is the fallback layer: every terminal pane gets the full identity - // contract (`TERM_PROGRAM`, pane id, bin dir, `PATH`) from `agent_env`. - // Setting it on the process too covers children spawned outside a PTY, - // which never see `tty::Options::env`. - #[cfg(windows)] if options.subcommands.is_none() - && let Ok(executable) = env::current_exe() - { - // SAFETY: startup is still single-threaded here; all child PTYs are - // created later and inherit this stable value. - unsafe { env::set_var(agent_env::CLI_ENV, executable) }; - } - - #[cfg(windows)] - if options.subcommands.is_none() && env::var_os("NEBULA_DETACHED_LAUNCH").is_some() { - // 必须在进任何消息循环之前脱离启动控制台。GPUI 以前在这条 - // FreeConsole 之前就 `return`,启动器(agent 作业对象)一退出 - // 窗口就被带走。旧壳注释同一合同。 - unsafe { - FreeConsole(); - } - } - - #[cfg(windows)] - if options.subcommands.is_none() - && let Err(error) = platform::startup::prepare_console_for_gui() + && let Err(error) = platform::startup::prepare_gui_process() { platform::startup::report_error(&error, true); return Err(error.into()); diff --git a/nebula_app/src/platform/startup.rs b/nebula_app/src/platform/startup.rs index 786155c..0d5f4d9 100644 --- a/nebula_app/src/platform/startup.rs +++ b/nebula_app/src/platform/startup.rs @@ -9,8 +9,25 @@ pub(crate) fn report_error(error: &dyn std::fmt::Display, gui_launch: bool) { #[cfg(windows)] mod console; -#[cfg(windows)] -pub(crate) use console::prepare_console_for_gui; +/// Prepare process-wide GUI state before worker threads or terminal children exist. +pub(crate) fn prepare_gui_process() -> std::io::Result<()> { + #[cfg(windows)] + { + // Portable builds may not be on PATH. Non-PTY children need the same + // executable fallback that agent_env supplies for each terminal pane. + if let Ok(executable) = std::env::current_exe() { + // SAFETY: main calls this while startup is still single-threaded. + unsafe { std::env::set_var(crate::agent_env::CLI_ENV, executable) }; + } + if std::env::var_os("NEBULA_DETACHED_LAUNCH").is_some() { + // Detach before either GUI event loop starts, so a launcher exiting + // cannot take the window down with its startup console. + unsafe { windows_sys::Win32::System::Console::FreeConsole() }; + } + console::prepare_console_for_gui()?; + } + Ok(()) +} pub fn prepare_gui() { #[cfg(target_os = "macos")] diff --git a/nebula_terminal/src/event_loop.rs b/nebula_terminal/src/event_loop.rs index 502a50b..0cff468 100644 --- a/nebula_terminal/src/event_loop.rs +++ b/nebula_terminal/src/event_loop.rs @@ -980,7 +980,7 @@ mod tests { assert_eq!(terminal.nebula_prompt_input_point(), None); } stream.feed(&mut terminal, &VoidListener, b"\x1b]133;A\x07\x1b]133;B\x07"); - terminal.resize(&TermSize::new(10, 2)); + terminal.resize(TermSize::new(10, 2)); assert_eq!(terminal.nebula_prompt_input_point(), None); } From 526b9c83221f03716d35ced445d1e766c7d55abc Mon Sep 17 00:00:00 2001 From: kuddev Date: Sun, 20 Sep 2026 15:40:50 +0800 Subject: [PATCH 8/9] fix(terminal): preserve echoed input at pending-wrap boundaries --- ...26-09-20-native-prompt-input-boundaries.md | 5 ++- nebula_app/src/display/input_state.rs | 45 ++++++++++++++++++- .../terminal/view/native_cmd_tests.rs | 13 +++++- nebula_terminal/src/term/prompt.rs | 10 +++++ 4 files changed, 68 insertions(+), 5 deletions(-) diff --git a/architecture/notes/nebula_terminal/term/2026-09-20-native-prompt-input-boundaries.md b/architecture/notes/nebula_terminal/term/2026-09-20-native-prompt-input-boundaries.md index d52a68b..6a837aa 100644 --- a/architecture/notes/nebula_terminal/term/2026-09-20-native-prompt-input-boundaries.md +++ b/architecture/notes/nebula_terminal/term/2026-09-20-native-prompt-input-boundaries.md @@ -63,7 +63,10 @@ is guessed. Resized prompts need fresh boundary evidence for exact extraction. Regressions exercise split OSC input, scrolling, reset and reflow, plus rendered custom/multiline/empty/CJK/wrapped prompts, history recall, empty Enter, paste, runtime submission and stale prompt epochs. PR #215's existing nested-shell, -process-failure and editing-key tests remain in the native suite. +process-failure and editing-key tests remain in the native suite. A filled last +cell is already part of echoed input even while the cursor is waiting to wrap. +The full-width regression failed with a 75-column prompt plus `pause` before +this correction; it also covers an empty full-width prompt at the bottom row. ## Supersedes diff --git a/nebula_app/src/display/input_state.rs b/nebula_app/src/display/input_state.rs index b6f9aa7..52de1d1 100644 --- a/nebula_app/src/display/input_state.rs +++ b/nebula_app/src/display/input_state.rs @@ -193,7 +193,13 @@ fn raw_grid_line_with_boundary( if columns == 0 { return None; } - let cursor_col = cursor.column.0.min(columns); + // A filled last cell leaves the cursor on that cell until another printable + // character wraps. Its contents already belong to the echoed input. + let cursor_col = if cursor == grid.cursor.point && grid.cursor.input_needs_wrap { + columns + } else { + cursor.column.0.min(columns) + }; if grid[cursor.line][Column(columns - 1)].flags.contains(Flags::WRAPLINE) { return None; @@ -234,7 +240,7 @@ fn raw_grid_line_with_boundary( } } - if input_start == Some(cursor) { + if input_start == Some(Point::new(cursor.line, Column(cursor_col))) { boundary = Some(text.len()); } Some((text, boundary)) @@ -413,6 +419,41 @@ mod tests { ); } + #[test] + fn semantic_prompt_input_handles_pending_wrap_and_bottom_row_scroll() { + use nebula_terminal::event::VoidListener; + use nebula_terminal::event_loop::StreamProcessor; + use nebula_terminal::grid::Grid; + + for start_row in [1, 2] { + for prompt_len in [0, 75, 79, 80, 81, 160] { + for input in ["", "pause"] { + let size = Grid::::new(2, 80, 0); + let mut terminal = Term::new(Default::default(), &size, VoidListener); + let prompt = "x".repeat(prompt_len); + let bytes = + format!("\x1b[{start_row};1H\x1b]133;A\x07{prompt}\x1b]133;B\x07{input}"); + StreamProcessor::default().feed(&mut terminal, &VoidListener, bytes.as_bytes()); + let snapshot = nebula_prompt_line_from_raw_grid( + &terminal, + terminal.grid().cursor.point, + "", + &SuggestEnv::Local, + ) + .unwrap_or_else(|| { + panic!("row {start_row}, prompt {prompt_len}, input {input:?}") + }); + assert_eq!(snapshot.prompt, prompt); + assert_eq!(snapshot.input, input); + assert_eq!( + nebula_shell_ready_from_raw_grid(&terminal, &SuggestEnv::Local), + input.is_empty() + ); + } + } + } + } + #[test] fn cmd_prompt_reconciles_the_typed_tail() { let env = SuggestEnv::Local; diff --git a/nebula_app/src/gpui_shell/terminal/view/native_cmd_tests.rs b/nebula_app/src/gpui_shell/terminal/view/native_cmd_tests.rs index b9215c6..183172a 100644 --- a/nebula_app/src/gpui_shell/terminal/view/native_cmd_tests.rs +++ b/nebula_app/src/gpui_shell/terminal/view/native_cmd_tests.rs @@ -25,7 +25,16 @@ fn root_process() -> Vec { fn custom_native_prompts_capture_recalled_input_and_keep_submission_epochs( cx: &mut gpui::TestAppContext, ) { - for prompt in ["[C:\\work] ", "[C:\\work]\r\n>", "", "工作目录 :: ", &"x".repeat(79)] { + for prompt in [ + "[C:\\work] ", + "[C:\\work]\r\n>", + "", + "工作目录 :: ", + &"x".repeat(75), + &"x".repeat(79), + &"x".repeat(80), + &"x".repeat(160), + ] { let (view, window, _) = open(cx); view.update(window, |view, cx| { let (session, _input, mut events, proxy) = session::test_session_with_events(); @@ -74,7 +83,7 @@ fn custom_native_prompts_capture_recalled_input_and_keep_submission_epochs( fn custom_native_prompts_handle_empty_submit_paste_and_runtime_without_fake_history( cx: &mut gpui::TestAppContext, ) { - for prompt in ["", "[C:\\work] ", "[C:\\work]\r\n>"] { + for prompt in ["", "[C:\\work] ", "[C:\\work]\r\n>", &"x".repeat(80), &"x".repeat(160)] { for action in ["empty", "paste", "runtime"] { let (view, window, _) = open(cx); view.update(window, |view, cx| { diff --git a/nebula_terminal/src/term/prompt.rs b/nebula_terminal/src/term/prompt.rs index 5f167ad..ac7cc16 100644 --- a/nebula_terminal/src/term/prompt.rs +++ b/nebula_terminal/src/term/prompt.rs @@ -64,6 +64,7 @@ impl Term { } /// Resolve the boundary after scrollback growth; reflow/reset discard it. + /// A column equal to the grid width denotes an empty pending-wrap boundary. pub fn nebula_prompt_input_point(&self) -> Option { if !self.nebula_prompt_active() { return None; @@ -72,6 +73,15 @@ impl Term { if line < self.grid.scrolled_out() || column.0 >= self.columns() { return None; } + // Until input is echoed, a full-width prompt's next insertion point is + // still on the filled row. This also covers the bottom row, where the + // eventual wrap will scroll the grid before displaying the input. + if self.grid.cursor.input_needs_wrap + && line == self.nebula_cursor_abs_line() + 1 + && column == Column(0) + { + return Some(Point::new(self.grid.cursor.point.line, Column(self.columns()))); + } let relative = line as i64 - self.grid.scrolled_out() as i64 - self.grid.history_size() as i64; let point = Point::new(Line(i32::try_from(relative).ok()?), column); From da6128cb9f550c4ca9ffe3bb700b2fab876afb71 Mon Sep 17 00:00:00 2001 From: kuddev Date: Sun, 20 Sep 2026 15:57:26 +0800 Subject: [PATCH 9/9] test(terminal): verify history separately from runtime command identity --- nebula_app/src/gpui_shell/terminal/suggest.rs | 8 +++++ .../terminal/view/native_cmd_tests.rs | 35 ++++++++++++++++--- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/nebula_app/src/gpui_shell/terminal/suggest.rs b/nebula_app/src/gpui_shell/terminal/suggest.rs index 2835e37..ff3831b 100644 --- a/nebula_app/src/gpui_shell/terminal/suggest.rs +++ b/nebula_app/src/gpui_shell/terminal/suggest.rs @@ -38,6 +38,14 @@ fn shared() -> MutexGuard<'static, Shared> { .unwrap_or_else(std::sync::PoisonError::into_inner) } +#[cfg(test)] +pub(super) fn history_hint_for_test( + scope: &crate::nebula_history::HistoryScope, + prefix: &str, +) -> Option { + shared().history.hint(scope, prefix).map(str::to_owned) +} + /// 重算一个 pane 的 ghost/弹窗建议。`line_override` 是 grid 读出的屏幕真值 /// (Windows 唯一行来源,见旧壳 `nebula_input_from_raw_grid` 的契约)。 pub fn update( diff --git a/nebula_app/src/gpui_shell/terminal/view/native_cmd_tests.rs b/nebula_app/src/gpui_shell/terminal/view/native_cmd_tests.rs index 183172a..b466829 100644 --- a/nebula_app/src/gpui_shell/terminal/view/native_cmd_tests.rs +++ b/nebula_app/src/gpui_shell/terminal/view/native_cmd_tests.rs @@ -83,8 +83,17 @@ fn custom_native_prompts_capture_recalled_input_and_keep_submission_epochs( fn custom_native_prompts_handle_empty_submit_paste_and_runtime_without_fake_history( cx: &mut gpui::TestAppContext, ) { - for prompt in ["", "[C:\\work] ", "[C:\\work]\r\n>", &"x".repeat(80), &"x".repeat(160)] { + let nonce = + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + for (index, prompt) in ["", "[C:\\work] ", "[C:\\work]\r\n>", &"x".repeat(80), &"x".repeat(160)] + .into_iter() + .enumerate() + { for action in ["empty", "paste", "runtime"] { + let prefix = format!("echo pebrel_native_history_{nonce}_{index}_{action}"); + let command = format!("{prefix} echoed"); + let scope = crate::nebula_history::HistoryScope::Local; + assert_eq!(suggest::history_hint_for_test(&scope, &prefix), None); let (view, window, _) = open(cx); view.update(window, |view, cx| { let (session, _input, _events, proxy) = session::test_session_with_events(); @@ -101,15 +110,33 @@ fn custom_native_prompts_handle_empty_submit_paste_and_runtime_without_fake_hist )); match action { "empty" => view.commit_line(cx), - "paste" => view.paste_now_impl("pause\r\n", false, cx), + "paste" => view.paste_now_impl(&format!("{command}\r\n"), false, cx), _ => { - view.runtime_prompt("pause".into(), true, cx).unwrap(); + view.runtime_prompt(command.clone(), true, cx).unwrap(); }, } assert_eq!(view.command_running, action != "empty", "{prompt:?} {action}"); - assert!(view.suggest.last_committed.is_empty(), "unconfirmed input is not history"); + // Runtime keeps the submitted program identity before its echo + // barrier. Persistent history must still wait for screen input. + let identity = if action == "runtime" { command.as_str() } else { "" }; + assert_eq!(view.suggest.last_committed, identity, "{prompt:?} {action}"); + assert_eq!( + suggest::history_hint_for_test(&scope, &prefix), + None, + "unconfirmed input is not history: {prompt:?} {action}" + ); if action != "empty" { assert!(view.suggest.pending_command_prompt.is_some()); + } else { + // Positive control reads the same history owner after real + // terminal echo and Enter, including pending-wrap prompts. + feed(view, command.as_bytes()); + view.commit_line(cx); + assert_eq!(view.suggest.last_committed, command); + assert_eq!( + suggest::history_hint_for_test(&scope, &prefix).as_deref(), + Some(" echoed") + ); } }); }