Merge PR #215 and track custom CMD input through semantic prompt boundaries

This commit is contained in:
kuddev committed 2026-09-20 15:13:58 +08:00
commit f7f3353d73
18 files changed
+1643 -106

No files matched your search

@@ -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.
+59 -5
View File
@@ -131,7 +131,15 @@ pub(crate) fn nebula_prompt_line_from_raw_grid<T: EventListener>(
typed_tail: &str,
env: &SuggestEnv,
) -> Option<PromptLineSnapshot> {
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<T: EventListener>(
terminal: &Term<T>,
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<T: EventListener>(
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<T: EventListener>(terminal: &Term<T>, cursor: Point) -> Option<String> {
raw_grid_line_with_boundary(terminal, cursor, None).map(|(text, _)| text)
}
fn raw_grid_line_with_boundary<T: EventListener>(
terminal: &Term<T>,
cursor: Point,
input_start: Option<Point>,
) -> Option<(String, Option<usize>)> {
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<T: EventListener>(terminal: &Term<T>, 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<T: EventListener>(terminal: &Term<T>, 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;
@@ -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<Event>,
wake_pending: Arc<AtomicBool>,
pub(super) native_prompt: NativePromptState,
}
#[derive(Clone, Default)]
pub(super) struct NativePromptState(Arc<Mutex<NativePrompt>>);
/// 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;
+27 -8
View File
@@ -63,6 +63,7 @@ impl Dimensions for GridSize {
pub struct TerminalSession {
pub term: Arc<FairMutex<Term<EventProxy>>>,
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<nebula_terminal::event_loop::Msg>) {
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<nebula_terminal::event_loop::Msg>,
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,
))
}
@@ -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<std::time::Instant>,
/// 上次跑进程树探测的时刻,用于节流。
last_process_probe: Option<std::time::Instant>,
prompt_process_probe: Option<gpui::Task<()>>,
prompt_input_epoch: u64,
native_prompt_seen: bool,
native_prompt_epoch: Option<u64>,
last_prompt_process_probe: Option<std::time::Instant>,
active_run: Option<crate::runtime_api::RuntimePaneRun>,
last_run: Option<crate::runtime_api::RuntimeRunOutcome>,
/// 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<u8>, cx: &mut Context<Self>) {
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<Self>) {
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) {
@@ -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 };
@@ -11,13 +11,24 @@ impl TerminalView {
/// line_buf 在光标移动/Tab 补全后就是拼接垃圾,不能进历史。Agent 已在
/// 前台时保留最初 shell 提示符,内部交互的 Enter 不得覆盖退出证据。
pub(super) fn commit_line(&mut self, cx: &mut Context<Self>) {
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>) {
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 两次
/// 取锁之间时拼成“旧提示 + 新光标”的跳动帧。
@@ -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<u8> {
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<crate::process_tree::ProcessEntry> {
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());
}
});
}
}
}
@@ -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>) {
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<Self>) {
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<std::time::Instant>,
input_epoch: u64,
result: Result<Vec<crate::process_tree::ProcessEntry>, String>,
cx: &mut Context<Self>,
) {
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;
@@ -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(),
@@ -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);
+36
View File
@@ -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);
+7 -3
View File
@@ -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"),
+6 -78
View File
@@ -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<T> {
/// 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<T> Term<T> {
}
}
/// 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<D: Dimensions>(config: Config, dimensions: &D, event_proxy: T) -> Term<T> {
let num_cols = dimensions.columns();
let num_lines = dimensions.screen_lines();
@@ -472,6 +397,7 @@ impl<T> Term<T> {
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<T> Term<T> {
// 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<T: EventListener> Handler for Term<T> {
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());
+118
View File
@@ -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<T> Term<T> {
/// 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<Point> {
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
}
}
@@ -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);
}
}
+2 -1
View File
@@ -288,7 +288,8 @@ fn spawn_shell(config: &Options, conpty: &Conpty) -> Result<ChildExitWatcher> {
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;
+1
View File
@@ -14,6 +14,7 @@ use crate::tty::{ChildEvent, EventedPty, EventedReadWrite, Options, Shell};
mod blocking;
mod child;
mod cmd_prompt;
mod conpty;
mod environment;