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
@@ -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