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 new file mode 100644 index 0000000..d52a68b --- /dev/null +++ b/architecture/notes/nebula_terminal/term/2026-09-20-native-prompt-input-boundaries.md @@ -0,0 +1,75 @@ +# Native prompt input boundaries + +## Status + +Implemented while integrating PR #215. + +## Context + +Native CMD does not provide a pre-execution callback like the managed +PowerShell integration. PR #215 uses a prompt marker and input epochs to keep +command activity independent of Clink, including queued PTY events and nested +shells. Its text-based submission fallback still required a conventional +`C:\path>` prompt, even though the injected PROMPT preserved arbitrary text. +Custom, multiline and empty prompts therefore failed to start builtin-command +activity. History recall also cannot be recovered from a keystroke mirror. + +## Evidence + +- The shared input snapshot rejected `[C:\work] pause`, `>pause` and `pause` + when those were the actual rendered custom prompt and input. +- Attaching activity to receipt of a queued UI event can misattribute a prompt + from before Enter to the command currently running. +- PTY parsing already handles semantic OSC 133 markers between parser slices, + where the cursor position is exact; that is the appropriate coordinate owner. + +## Decision + +CMD's child-only PROMPT receives OSC 133 A/B around the user's prompt, followed +by its native prompt marker. Repeated preparation is idempotent, including an +inherited prefix-only marker from the initial PR implementation. + +The terminal records the B input boundary in absolute grid coordinates. The +shared input adapter extracts echoed text from that boundary, including wrapped +lines, without depending on a prompt suffix or the keystroke mirror. Prompt +navigation and input-boundary methods share the `term/prompt.rs` responsibility. +Reset, reflow, command events and input that can submit/invalidate the prompt +discard the boundary. Alternate-screen content cannot expose a primary prompt. + +The application's native input epoch remains the activity authority. A B marker +does not finish a command or emit a command result; completion still requires +the current native marker plus successful process evidence. Nested interactive +shells retain their outer runtime run. Paste captures activity before echo and +does not fabricate command history. + +## Rejected alternatives + +- Assuming every `>` or user-entered line is a shell prompt can misclassify REPLs. +- Reconstructing commands from keys fails after history recall and editing. +- Treating a prompt as unconditional command completion loses nested runs and + allows stale queued output to finish a later submission. +- Keeping coordinates across reflow without a mapping can read unrelated text. + +## Consequences + +No dependency, background service or persistent format is added. One optional +coordinate accompanies existing terminal prompt state. The input adapter keeps +its bounded wrapped-line scan. User changes that remove all injected markers +fall back to existing conservative text detection; no arbitrary prompt grammar +is guessed. Resized prompts need fresh boundary evidence for exact extraction. + +## Validation + +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. + +## Supersedes + +None. + +## Revisit when + +Grid reflow exposes stable anchor remapping, or a native CMD pre-execution hook +can supply authoritative input and exit codes without overriding user behavior. diff --git a/nebula_app/src/display/input_state.rs b/nebula_app/src/display/input_state.rs index 6d54e5c..c373a9d 100644 --- a/nebula_app/src/display/input_state.rs +++ b/nebula_app/src/display/input_state.rs @@ -131,7 +131,15 @@ pub(crate) fn nebula_prompt_line_from_raw_grid( typed_tail: &str, env: &SuggestEnv, ) -> Option { - let text = raw_grid_logical_line(terminal, cursor)?; + let input_start = terminal.nebula_prompt_input_point(); + let (text, boundary) = raw_grid_line_with_boundary(terminal, cursor, input_start)?; + if let Some(boundary) = boundary { + let (prompt, input) = text.split_at(boundary); + return Some(PromptLineSnapshot { prompt: prompt.to_owned(), input: input.to_owned() }); + } + if input_start.is_some() { + return None; + } prompt_line_snapshot(&text, typed_tail, env, terminal.nebula_prompt_active()) } @@ -141,12 +149,17 @@ 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, + ) { + return line.input.trim().is_empty(); + } let Some(text) = raw_grid_logical_line(terminal, terminal.grid().cursor.point) else { return false; }; - if let Some(line) = prompt_line_snapshot(&text, "", env, terminal.nebula_prompt_active()) { - return line.input.trim().is_empty(); - } let prompt = text.trim_end(); let Some(marker) = prompt.chars().next_back() else { return false }; safe_shell_prompt_marker(prompt, marker, env) @@ -158,12 +171,23 @@ 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)) } fn raw_grid_logical_line(terminal: &Term, cursor: Point) -> Option { + raw_grid_line_with_boundary(terminal, cursor, None).map(|(text, _)| text) +} + +fn raw_grid_line_with_boundary( + terminal: &Term, + cursor: Point, + input_start: Option, +) -> Option<(String, Option)> { let grid = terminal.grid(); if !raw_grid_line_is_readable(cursor.line, grid.topmost_line(), grid.bottommost_line()) { return None; @@ -198,9 +222,13 @@ fn raw_grid_logical_line(terminal: &Term, cursor: Point) -> } let mut text = String::with_capacity(columns); + let mut boundary = None; for row in first_row..=cursor.line.0 { let row_end = if row == cursor.line.0 { cursor_col } else { columns }; for col in 0..row_end { + if input_start == Some(Point::new(Line(row), Column(col))) { + boundary = Some(text.len()); + } let cell: &Cell = &grid[Line(row)][Column(col)]; if cell.flags.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) { continue; @@ -209,7 +237,10 @@ fn raw_grid_logical_line(terminal: &Term, cursor: Point) -> } } - Some(text) + if input_start == Some(cursor) { + boundary = Some(text.len()); + } + Some((text, boundary)) } fn prompt_line_snapshot( @@ -226,6 +257,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 +287,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 221f180..9078599 100644 --- a/nebula_app/src/gpui_shell/terminal/view.rs +++ b/nebula_app/src/gpui_shell/terminal/view.rs @@ -9,6 +9,8 @@ mod confirmation; mod cwd_report; mod image_paste; mod layout; +#[cfg(all(test, windows, feature = "gpui-test-support"))] +mod native_cmd_tests; mod notifications; #[cfg(all(test, feature = "gpui-test-support"))] mod output_tests; @@ -311,6 +313,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. @@ -661,6 +668,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(); }, @@ -712,6 +725,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); @@ -719,11 +733,18 @@ 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. + let preserves_prompt = super::event_mailbox::preserves_native_prompt(&bytes); + session.native_prompt.observe_input(self.prompt_input_epoch, preserves_prompt); + if !preserves_prompt { + term.nebula_end_prompt(); + } term.scroll_display(Scroll::Bottom); term.selection = None; } session.notifier.notify(bytes); } + self.sync_native_prompt(); self.restart_cursor_blink(cx); cx.notify(); } @@ -984,6 +1005,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/native_cmd_tests.rs b/nebula_app/src/gpui_shell/terminal/view/native_cmd_tests.rs new file mode 100644 index 0000000..5014be7 --- /dev/null +++ b/nebula_app/src/gpui_shell/terminal/view/native_cmd_tests.rs @@ -0,0 +1,102 @@ +//! Native CMD input comes from the marked grid, including history recall. +use super::*; +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() +} + +fn root_process() -> Vec { + vec![crate::process_tree::ProcessEntry { + pid: 1, + parent_pid: 0, + depth: 0, + executable: "cmd.exe".into(), + }] +} + +#[gpui::test] +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)] { + 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; + let mut stream = StreamProcessor::default(); + stream.feed( + &mut view.session.as_ref().unwrap().term.lock(), + &proxy, + &prompt_bytes(prompt, "pause"), + ); + view.suggest.line_buf.clear(); + view.commit_line(cx); + assert_eq!(view.suggest.last_committed, "pause", "prompt {prompt:?}"); + assert!(view.command_running); + view.write_input(b"\r".to_vec(), cx); + while let Ok(event) = events.try_recv() { + view.process_event(event, cx); + } + view.apply_prompt_process_probe( + view.command_started, + view.prompt_input_epoch, + Ok(root_process()), + cx, + ); + assert!(view.command_running, "queued old prompt must not finish pause"); + + let mut next = b"\r\n".to_vec(); + next.extend(prompt_bytes(prompt, "")); + stream.feed(&mut view.session.as_ref().unwrap().term.lock(), &proxy, &next); + while let Ok(event) = events.try_recv() { + view.process_event(event, cx); + } + view.apply_prompt_process_probe( + view.command_started, + view.prompt_input_epoch, + Ok(root_process()), + cx, + ); + assert!(!view.command_running, "fresh prompt ends the builtin wait"); + }); + } +} + +#[gpui::test] +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 action in ["empty", "paste", "runtime"] { + 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; + StreamProcessor::default().feed( + &mut view.session.as_ref().unwrap().term.lock(), + &proxy, + &prompt_bytes(prompt, ""), + ); + assert!(crate::display::nebula_shell_ready_from_raw_grid( + &view.session.as_ref().unwrap().term.lock(), + &view.suggest.suggest_env, + )); + 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(); }, + } + assert_eq!(view.command_running, action != "empty", "{prompt:?} {action}"); + assert!(view.suggest.last_committed.is_empty(), "unconfirmed input is not history"); + if action != "empty" { + assert!(view.suggest.pending_command_prompt.is_some()); + } + }); + } + } +} 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 28c1991..19364aa 100644 --- a/nebula_app/src/gpui_shell/terminal/view/startup.rs +++ b/nebula_app/src/gpui_shell/terminal/view/startup.rs @@ -243,6 +243,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 4b490b6..11c789d 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 ligature_changes_update_all_faces_without_replacing_the_open_session(cx: &mut TestAppContext) { let (view, window, _) = open(cx); diff --git a/nebula_terminal/src/event_loop.rs b/nebula_terminal/src/event_loop.rs index 4abca5b..502a50b 100644 --- a/nebula_terminal/src/event_loop.rs +++ b/nebula_terminal/src/event_loop.rs @@ -227,6 +227,7 @@ impl StreamProcessor { OscEvent::PromptMark => { terminal.nebula_add_prompt_mark(); }, + OscEvent::PromptInput => terminal.nebula_mark_prompt_input(), OscEvent::InlineImage { data, width, height } => { let (cell_w, cell_h) = self.window_size.map_or((9.0, 20.0), |ws| { (f32::from(ws.cell_width), f32::from(ws.cell_height)) @@ -948,6 +949,41 @@ mod tests { } } + #[test] + fn semantic_input_boundary_survives_chunking_and_scrolling() { + use crate::index::{Column, Line, Point}; + + let bytes = b"\x1b]133;A\x07[first]\r\n>\x1b]133;B\x07pause"; + for split in 0..=bytes.len() { + let mut terminal = Term::new(Config::default(), &TermSize::new(20, 2), VoidListener); + let mut stream = StreamProcessor::default(); + stream.feed(&mut terminal, &VoidListener, &bytes[..split]); + stream.feed(&mut terminal, &VoidListener, &bytes[split..]); + assert_eq!(terminal.nebula_prompt_input_point(), Some(Point::new(Line(1), Column(1)))); + stream.feed(&mut terminal, &VoidListener, b"\r\n"); + assert_eq!(terminal.nebula_prompt_input_point(), Some(Point::new(Line(0), Column(1)))); + stream.feed(&mut terminal, &VoidListener, b"\x1b]133;C\x07"); + assert_eq!(terminal.nebula_prompt_input_point(), None); + } + } + + #[test] + fn input_boundaries_require_a_prompt_and_do_not_survive_reflow_or_reset() { + let mut terminal = Term::new(Config::default(), &TermSize::new(20, 2), VoidListener); + let mut stream = StreamProcessor::default(); + stream.feed(&mut terminal, &VoidListener, b"\x1b]133;B\x07"); + assert_eq!(terminal.nebula_prompt_input_point(), None); + for ending in [b"\x1bc".as_slice(), b"\x1b]133;D;0\x07", b"\x1b]133;A\x07"] { + stream.feed(&mut terminal, &VoidListener, b"\x1b]133;A\x07\x1b]133;B\x07"); + assert!(terminal.nebula_prompt_input_point().is_some()); + stream.feed(&mut terminal, &VoidListener, ending); + 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)); + assert_eq!(terminal.nebula_prompt_input_point(), None); + } + #[test] fn shell_semantic_events_track_the_active_prompt() { let size = TermSize::new(80, 24); diff --git a/nebula_terminal/src/osc_cwd.rs b/nebula_terminal/src/osc_cwd.rs index b089f2c..e41cead 100644 --- a/nebula_terminal/src/osc_cwd.rs +++ b/nebula_terminal/src/osc_cwd.rs @@ -43,6 +43,8 @@ pub enum OscEvent { Cwd(String), /// OSC 133;A — the shell is about to draw a prompt (semantic zone start). PromptMark, + /// OSC 133;B — the prompt ended; subsequent cells contain shell input. + PromptInput, /// OSC 133;C — a command started executing. CommandStart, /// OSC 133;D — the command finished. `exit_code` is the first parameter @@ -207,12 +209,14 @@ impl CwdSniffer { } if let Some(rest) = self.payload.strip_prefix(b"133;") { // Semantic prompt zones (FinalTerm). `A` may carry kitty-style - // `;key=value` params — accept those too. B (command start being - // typed) has no consumer yet. + // `;key=value` params — accept those too. let phased = |ch: u8| rest.first() == Some(&ch) && (rest.len() == 1 || rest[1] == b';'); if phased(b'A') { return Some(OscEvent::PromptMark); } + if phased(b'B') { + return Some(OscEvent::PromptInput); + } if phased(b'C') { return Some(OscEvent::CommandStart); } @@ -557,7 +561,7 @@ mod tests { #[test] fn osc133_other_phases_ignored() { // B (command line start) has no consumer; C/D became events. - assert!(events(b"\x1b]133;B\x07").is_empty()); + assert_eq!(events(b"\x1b]133;B\x07"), vec![(8, OscEvent::PromptInput)]); assert_eq!(events(b"\x1b]133;C\x07"), vec![(8, OscEvent::CommandStart)]); assert_eq!( events(b"\x1b]133;D;0\x07"), diff --git a/nebula_terminal/src/term/mod.rs b/nebula_terminal/src/term/mod.rs index 7f8d51d..9517799 100644 --- a/nebula_terminal/src/term/mod.rs +++ b/nebula_terminal/src/term/mod.rs @@ -33,6 +33,7 @@ mod damage; mod keyboard; #[cfg(test)] mod keyboard_contract_tests; +mod prompt; mod renderable; pub mod search; @@ -231,6 +232,8 @@ pub struct Term { /// Whether OSC 133 currently identifies this pane as accepting shell input. nebula_prompt_active: bool, + /// OSC 133;B input start in absolute grid coordinates. + nebula_prompt_input: Option<(usize, Column)>, /// One-shot suppression of the next primary-DA answer: the side-loaded /// ConPTY host's bring-up DA1 query was already answered by the response @@ -356,84 +359,6 @@ impl Term { } } - /// The cursor row in the grid's absolute line numbering (see - /// [`Grid::scrolled_out`]): stable across scrollback growth, so overlays - /// (prompt marks, inline images) can anchor to it. - pub fn nebula_cursor_abs_line(&self) -> usize { - self.grid.scrolled_out() - + self.grid.history_size() - + self.grid.cursor.point.line.0.max(0) as usize - } - - /// Record a shell prompt row (OSC 133;A) at the current cursor line. - /// - /// Called by the PTY reader between `parser.advance` slices, so the cursor - /// sits exactly on the fresh prompt row. Marks only make sense on the - /// primary screen — the alternate screen has no scrollback to jump. - pub fn nebula_add_prompt_mark(&mut self) { - if self.mode.contains(TermMode::ALT_SCREEN) { - return; - } - let abs = self.nebula_cursor_abs_line(); - - // A screen redraw (clear, resize) can re-emit a mark for the same or - // an earlier row; drop those so the deque stays strictly increasing. - while self.nebula_prompt_marks.back().is_some_and(|&m| m >= abs) { - self.nebula_prompt_marks.pop_back(); - } - // Prune marks whose rows have scrolled out of history entirely. - let floor = self.grid.scrolled_out(); - while self.nebula_prompt_marks.front().is_some_and(|&m| m < floor) { - self.nebula_prompt_marks.pop_front(); - } - - self.nebula_prompt_marks.push_back(abs); - self.nebula_prompt_active = true; - } - - pub fn nebula_end_prompt(&mut self) { - self.nebula_prompt_active = false; - } - - pub fn nebula_prompt_active(&self) -> bool { - self.nebula_prompt_active && !self.mode.contains(TermMode::ALT_SCREEN) - } - - /// Scroll the viewport to the previous (`up`) or next shell prompt mark. - /// - /// Returns whether the viewport moved, so callers know to redraw. - pub fn nebula_prompt_jump(&mut self, up: bool) -> bool - where - T: EventListener, - { - if self.mode.contains(TermMode::ALT_SCREEN) || self.nebula_prompt_marks.is_empty() { - return false; - } - - let scrolled_out = self.grid.scrolled_out(); - let history = self.grid.history_size(); - // Absolute line currently shown at the top of the viewport. - let top_abs = scrolled_out + history - self.grid.display_offset(); - - let target = if up { - self.nebula_prompt_marks.iter().rev().find(|&&m| m < top_abs) - } else { - self.nebula_prompt_marks.iter().find(|&&m| m > top_abs) - }; - let Some(&mark) = target else { return false }; - - // Put the mark's row at the viewport top: offset = history - relative - // row. Marks on the visible screen clamp to 0 (bottom), long-gone - // marks clamp to the scrollback top. - let offset = (scrolled_out + history).saturating_sub(mark).min(history); - let delta = offset as i32 - self.grid.display_offset() as i32; - if delta == 0 { - return false; - } - self.scroll_display(Scroll::Delta(delta)); - true - } - pub fn new(config: Config, dimensions: &D, event_proxy: T) -> Term { let num_cols = dimensions.columns(); let num_lines = dimensions.screen_lines(); @@ -472,6 +397,7 @@ impl Term { keyboard_mode_stack: Default::default(), nebula_prompt_marks: Default::default(), nebula_prompt_active: false, + nebula_prompt_input: None, active_charset: Default::default(), vi_mode_cursor: Default::default(), cursor_style: Default::default(), @@ -841,6 +767,7 @@ impl Term { // Reflow rewraps history rows, so absolute prompt-mark lines no longer // match; drop them rather than jump to shifted positions. self.nebula_prompt_marks.clear(); + self.nebula_prompt_input = None; // Invalidate selection and tabs only when necessary. if old_cols != num_cols { @@ -2069,6 +1996,7 @@ impl Handler for Term { self.grid.reset(); self.inactive_grid.reset(); self.nebula_prompt_marks.clear(); + self.nebula_prompt_input = None; self.nebula_prompt_active = false; self.scroll_region = Line(0)..Line(self.screen_lines() as i32); self.tabs = TabStops::new(self.columns()); diff --git a/nebula_terminal/src/term/prompt.rs b/nebula_terminal/src/term/prompt.rs new file mode 100644 index 0000000..0ff1dcc --- /dev/null +++ b/nebula_terminal/src/term/prompt.rs @@ -0,0 +1,118 @@ +//! Semantic prompt marks and input boundaries owned by the terminal grid. +use super::{Term, TermMode}; +use crate::event::EventListener; +use crate::grid::{Dimensions, Scroll}; +use crate::index::{Column, Line, Point}; + +impl Term { + /// The cursor row in the grid's absolute line numbering (see + /// [`Grid::scrolled_out`]): stable across scrollback growth, so overlays + /// (prompt marks, inline images) can anchor to it. + pub fn nebula_cursor_abs_line(&self) -> usize { + self.grid.scrolled_out() + + self.grid.history_size() + + self.grid.cursor.point.line.0.max(0) as usize + } + + /// Record a shell prompt row (OSC 133;A) at the current cursor line. + /// + /// Called by the PTY reader between `parser.advance` slices, so the cursor + /// sits exactly on the fresh prompt row. Marks only make sense on the + /// primary screen — the alternate screen has no scrollback to jump. + pub fn nebula_add_prompt_mark(&mut self) { + if self.mode.contains(TermMode::ALT_SCREEN) { + return; + } + let abs = self.nebula_cursor_abs_line(); + + // A screen redraw (clear, resize) can re-emit a mark for the same or + // an earlier row; drop those so the deque stays strictly increasing. + while self.nebula_prompt_marks.back().is_some_and(|&m| m >= abs) { + self.nebula_prompt_marks.pop_back(); + } + // Prune marks whose rows have scrolled out of history entirely. + let floor = self.grid.scrolled_out(); + while self.nebula_prompt_marks.front().is_some_and(|&m| m < floor) { + self.nebula_prompt_marks.pop_front(); + } + + self.nebula_prompt_marks.push_back(abs); + self.nebula_prompt_active = true; + self.nebula_prompt_input = None; + } + + pub fn nebula_end_prompt(&mut self) { + self.nebula_prompt_active = false; + self.nebula_prompt_input = None; + } + + pub fn nebula_prompt_active(&self) -> bool { + self.nebula_prompt_active && !self.mode.contains(TermMode::ALT_SCREEN) + } + + /// Capture OSC 133;B between parser slices, before input is echoed. + pub fn nebula_mark_prompt_input(&mut self) { + if self.nebula_prompt_active() { + let mut line = self.nebula_cursor_abs_line(); + let mut column = self.grid.cursor.point.column; + if self.grid.cursor.input_needs_wrap { + line += 1; + column = Column(0); + } + self.nebula_prompt_input = Some((line, column)); + } + } + + /// Resolve the boundary after scrollback growth; reflow/reset discard it. + pub fn nebula_prompt_input_point(&self) -> Option { + if !self.nebula_prompt_active() { + return None; + } + let (line, column) = self.nebula_prompt_input?; + 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 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) + } + + /// Scroll the viewport to the previous (`up`) or next shell prompt mark. + /// + /// Returns whether the viewport moved, so callers know to redraw. + pub fn nebula_prompt_jump(&mut self, up: bool) -> bool + where + T: EventListener, + { + if self.mode.contains(TermMode::ALT_SCREEN) || self.nebula_prompt_marks.is_empty() { + return false; + } + + let scrolled_out = self.grid.scrolled_out(); + let history = self.grid.history_size(); + // Absolute line currently shown at the top of the viewport. + let top_abs = scrolled_out + history - self.grid.display_offset(); + + let target = if up { + self.nebula_prompt_marks.iter().rev().find(|&&m| m < top_abs) + } else { + self.nebula_prompt_marks.iter().find(|&&m| m > top_abs) + }; + let Some(&mark) = target else { return false }; + + // Put the mark's row at the viewport top: offset = history - relative + // row. Marks on the visible screen clamp to 0 (bottom), long-gone + // marks clamp to the scrollback top. + let offset = (scrolled_out + history).saturating_sub(mark).min(history); + let delta = offset as i32 - self.grid.display_offset() as i32; + if delta == 0 { + return false; + } + self.scroll_display(Scroll::Delta(delta)); + true + } + +} 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..8d40727 --- /dev/null +++ b/nebula_terminal/src/tty/windows/cmd_prompt.rs @@ -0,0 +1,91 @@ +use crate::tty::Options; + +const MARK: &str = "\x1b]1337;SetUserVar=pebrel_cmd_prompt=MQ==\x07"; +const START: &str = "\x1b]133;A\x07"; +const END: &str = "\x1b]133;B\x07\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(START) && prompt.ends_with(END) { + prompt + } else { + let prompt = prompt.strip_prefix(MARK).unwrap_or(&prompt); + format!("{START}{prompt}{END}") + }; + prepared.env.insert("PROMPT".into(), prompt); + prepared +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tty::Shell; + + 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!("{START}[$P]$_$G{END}")); + 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!("{START}$P$G{END}")); + 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(), &format!("{START}{END}")); + } + + #[test] + fn native_prompt_upgrades_a_prefix_only_marker_without_duplicating_it() { + let mut config = cmd(); + config.env.insert("PROMPT".into(), format!("{MARK}[$P]$S")); + let prepared = prepare(&config); + assert_eq!(prepared.env.get("PROMPT").unwrap(), &format!("{START}[$P]$S{END}")); + assert_eq!(prepare(&prepared), prepared); + } +} 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 bc4fb68..3fc4784 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;