Merge PR #215 and track custom CMD input through semantic prompt boundaries
This commit is contained in:
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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -14,6 +14,7 @@ use crate::tty::{ChildEvent, EventedPty, EventedReadWrite, Options, Shell};
|
||||
|
||||
mod blocking;
|
||||
mod child;
|
||||
mod cmd_prompt;
|
||||
mod conpty;
|
||||
mod environment;
|
||||
|
||||
|
||||
Reference in new issue
Block a user