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] 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;