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

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

No files matched your search

+36
View File
@@ -227,6 +227,7 @@ impl StreamProcessor {
OscEvent::PromptMark => {
terminal.nebula_add_prompt_mark();
},
OscEvent::PromptInput => terminal.nebula_mark_prompt_input(),
OscEvent::InlineImage { data, width, height } => {
let (cell_w, cell_h) = self.window_size.map_or((9.0, 20.0), |ws| {
(f32::from(ws.cell_width), f32::from(ws.cell_height))
@@ -948,6 +949,41 @@ mod tests {
}
}
#[test]
fn semantic_input_boundary_survives_chunking_and_scrolling() {
use crate::index::{Column, Line, Point};
let bytes = b"\x1b]133;A\x07[first]\r\n>\x1b]133;B\x07pause";
for split in 0..=bytes.len() {
let mut terminal = Term::new(Config::default(), &TermSize::new(20, 2), VoidListener);
let mut stream = StreamProcessor::default();
stream.feed(&mut terminal, &VoidListener, &bytes[..split]);
stream.feed(&mut terminal, &VoidListener, &bytes[split..]);
assert_eq!(terminal.nebula_prompt_input_point(), Some(Point::new(Line(1), Column(1))));
stream.feed(&mut terminal, &VoidListener, b"\r\n");
assert_eq!(terminal.nebula_prompt_input_point(), Some(Point::new(Line(0), Column(1))));
stream.feed(&mut terminal, &VoidListener, b"\x1b]133;C\x07");
assert_eq!(terminal.nebula_prompt_input_point(), None);
}
}
#[test]
fn input_boundaries_require_a_prompt_and_do_not_survive_reflow_or_reset() {
let mut terminal = Term::new(Config::default(), &TermSize::new(20, 2), VoidListener);
let mut stream = StreamProcessor::default();
stream.feed(&mut terminal, &VoidListener, b"\x1b]133;B\x07");
assert_eq!(terminal.nebula_prompt_input_point(), None);
for ending in [b"\x1bc".as_slice(), b"\x1b]133;D;0\x07", b"\x1b]133;A\x07"] {
stream.feed(&mut terminal, &VoidListener, b"\x1b]133;A\x07\x1b]133;B\x07");
assert!(terminal.nebula_prompt_input_point().is_some());
stream.feed(&mut terminal, &VoidListener, ending);
assert_eq!(terminal.nebula_prompt_input_point(), None);
}
stream.feed(&mut terminal, &VoidListener, b"\x1b]133;A\x07\x1b]133;B\x07");
terminal.resize(&TermSize::new(10, 2));
assert_eq!(terminal.nebula_prompt_input_point(), None);
}
#[test]
fn shell_semantic_events_track_the_active_prompt() {
let size = TermSize::new(80, 24);
+7 -3
View File
@@ -43,6 +43,8 @@ pub enum OscEvent {
Cwd(String),
/// OSC 133;A — the shell is about to draw a prompt (semantic zone start).
PromptMark,
/// OSC 133;B — the prompt ended; subsequent cells contain shell input.
PromptInput,
/// OSC 133;C — a command started executing.
CommandStart,
/// OSC 133;D — the command finished. `exit_code` is the first parameter
@@ -207,12 +209,14 @@ impl CwdSniffer {
}
if let Some(rest) = self.payload.strip_prefix(b"133;") {
// Semantic prompt zones (FinalTerm). `A` may carry kitty-style
// `;key=value` params — accept those too. B (command start being
// typed) has no consumer yet.
// `;key=value` params — accept those too.
let phased = |ch: u8| rest.first() == Some(&ch) && (rest.len() == 1 || rest[1] == b';');
if phased(b'A') {
return Some(OscEvent::PromptMark);
}
if phased(b'B') {
return Some(OscEvent::PromptInput);
}
if phased(b'C') {
return Some(OscEvent::CommandStart);
}
@@ -557,7 +561,7 @@ mod tests {
#[test]
fn osc133_other_phases_ignored() {
// B (command line start) has no consumer; C/D became events.
assert!(events(b"\x1b]133;B\x07").is_empty());
assert_eq!(events(b"\x1b]133;B\x07"), vec![(8, OscEvent::PromptInput)]);
assert_eq!(events(b"\x1b]133;C\x07"), vec![(8, OscEvent::CommandStart)]);
assert_eq!(
events(b"\x1b]133;D;0\x07"),
+6 -78
View File
@@ -33,6 +33,7 @@ mod damage;
mod keyboard;
#[cfg(test)]
mod keyboard_contract_tests;
mod prompt;
mod renderable;
pub mod search;
@@ -231,6 +232,8 @@ pub struct Term<T> {
/// Whether OSC 133 currently identifies this pane as accepting shell input.
nebula_prompt_active: bool,
/// OSC 133;B input start in absolute grid coordinates.
nebula_prompt_input: Option<(usize, Column)>,
/// One-shot suppression of the next primary-DA answer: the side-loaded
/// ConPTY host's bring-up DA1 query was already answered by the response
@@ -356,84 +359,6 @@ impl<T> Term<T> {
}
}
/// The cursor row in the grid's absolute line numbering (see
/// [`Grid::scrolled_out`]): stable across scrollback growth, so overlays
/// (prompt marks, inline images) can anchor to it.
pub fn nebula_cursor_abs_line(&self) -> usize {
self.grid.scrolled_out()
+ self.grid.history_size()
+ self.grid.cursor.point.line.0.max(0) as usize
}
/// Record a shell prompt row (OSC 133;A) at the current cursor line.
///
/// Called by the PTY reader between `parser.advance` slices, so the cursor
/// sits exactly on the fresh prompt row. Marks only make sense on the
/// primary screen — the alternate screen has no scrollback to jump.
pub fn nebula_add_prompt_mark(&mut self) {
if self.mode.contains(TermMode::ALT_SCREEN) {
return;
}
let abs = self.nebula_cursor_abs_line();
// A screen redraw (clear, resize) can re-emit a mark for the same or
// an earlier row; drop those so the deque stays strictly increasing.
while self.nebula_prompt_marks.back().is_some_and(|&m| m >= abs) {
self.nebula_prompt_marks.pop_back();
}
// Prune marks whose rows have scrolled out of history entirely.
let floor = self.grid.scrolled_out();
while self.nebula_prompt_marks.front().is_some_and(|&m| m < floor) {
self.nebula_prompt_marks.pop_front();
}
self.nebula_prompt_marks.push_back(abs);
self.nebula_prompt_active = true;
}
pub fn nebula_end_prompt(&mut self) {
self.nebula_prompt_active = false;
}
pub fn nebula_prompt_active(&self) -> bool {
self.nebula_prompt_active && !self.mode.contains(TermMode::ALT_SCREEN)
}
/// Scroll the viewport to the previous (`up`) or next shell prompt mark.
///
/// Returns whether the viewport moved, so callers know to redraw.
pub fn nebula_prompt_jump(&mut self, up: bool) -> bool
where
T: EventListener,
{
if self.mode.contains(TermMode::ALT_SCREEN) || self.nebula_prompt_marks.is_empty() {
return false;
}
let scrolled_out = self.grid.scrolled_out();
let history = self.grid.history_size();
// Absolute line currently shown at the top of the viewport.
let top_abs = scrolled_out + history - self.grid.display_offset();
let target = if up {
self.nebula_prompt_marks.iter().rev().find(|&&m| m < top_abs)
} else {
self.nebula_prompt_marks.iter().find(|&&m| m > top_abs)
};
let Some(&mark) = target else { return false };
// Put the mark's row at the viewport top: offset = history - relative
// row. Marks on the visible screen clamp to 0 (bottom), long-gone
// marks clamp to the scrollback top.
let offset = (scrolled_out + history).saturating_sub(mark).min(history);
let delta = offset as i32 - self.grid.display_offset() as i32;
if delta == 0 {
return false;
}
self.scroll_display(Scroll::Delta(delta));
true
}
pub fn new<D: Dimensions>(config: Config, dimensions: &D, event_proxy: T) -> Term<T> {
let num_cols = dimensions.columns();
let num_lines = dimensions.screen_lines();
@@ -472,6 +397,7 @@ impl<T> Term<T> {
keyboard_mode_stack: Default::default(),
nebula_prompt_marks: Default::default(),
nebula_prompt_active: false,
nebula_prompt_input: None,
active_charset: Default::default(),
vi_mode_cursor: Default::default(),
cursor_style: Default::default(),
@@ -841,6 +767,7 @@ impl<T> Term<T> {
// Reflow rewraps history rows, so absolute prompt-mark lines no longer
// match; drop them rather than jump to shifted positions.
self.nebula_prompt_marks.clear();
self.nebula_prompt_input = None;
// Invalidate selection and tabs only when necessary.
if old_cols != num_cols {
@@ -2069,6 +1996,7 @@ impl<T: EventListener> Handler for Term<T> {
self.grid.reset();
self.inactive_grid.reset();
self.nebula_prompt_marks.clear();
self.nebula_prompt_input = None;
self.nebula_prompt_active = false;
self.scroll_region = Line(0)..Line(self.screen_lines() as i32);
self.tabs = TabStops::new(self.columns());
+118
View File
@@ -0,0 +1,118 @@
//! Semantic prompt marks and input boundaries owned by the terminal grid.
use super::{Term, TermMode};
use crate::event::EventListener;
use crate::grid::{Dimensions, Scroll};
use crate::index::{Column, Line, Point};
impl<T> Term<T> {
/// The cursor row in the grid's absolute line numbering (see
/// [`Grid::scrolled_out`]): stable across scrollback growth, so overlays
/// (prompt marks, inline images) can anchor to it.
pub fn nebula_cursor_abs_line(&self) -> usize {
self.grid.scrolled_out()
+ self.grid.history_size()
+ self.grid.cursor.point.line.0.max(0) as usize
}
/// Record a shell prompt row (OSC 133;A) at the current cursor line.
///
/// Called by the PTY reader between `parser.advance` slices, so the cursor
/// sits exactly on the fresh prompt row. Marks only make sense on the
/// primary screen — the alternate screen has no scrollback to jump.
pub fn nebula_add_prompt_mark(&mut self) {
if self.mode.contains(TermMode::ALT_SCREEN) {
return;
}
let abs = self.nebula_cursor_abs_line();
// A screen redraw (clear, resize) can re-emit a mark for the same or
// an earlier row; drop those so the deque stays strictly increasing.
while self.nebula_prompt_marks.back().is_some_and(|&m| m >= abs) {
self.nebula_prompt_marks.pop_back();
}
// Prune marks whose rows have scrolled out of history entirely.
let floor = self.grid.scrolled_out();
while self.nebula_prompt_marks.front().is_some_and(|&m| m < floor) {
self.nebula_prompt_marks.pop_front();
}
self.nebula_prompt_marks.push_back(abs);
self.nebula_prompt_active = true;
self.nebula_prompt_input = None;
}
pub fn nebula_end_prompt(&mut self) {
self.nebula_prompt_active = false;
self.nebula_prompt_input = None;
}
pub fn nebula_prompt_active(&self) -> bool {
self.nebula_prompt_active && !self.mode.contains(TermMode::ALT_SCREEN)
}
/// Capture OSC 133;B between parser slices, before input is echoed.
pub fn nebula_mark_prompt_input(&mut self) {
if self.nebula_prompt_active() {
let mut line = self.nebula_cursor_abs_line();
let mut column = self.grid.cursor.point.column;
if self.grid.cursor.input_needs_wrap {
line += 1;
column = Column(0);
}
self.nebula_prompt_input = Some((line, column));
}
}
/// Resolve the boundary after scrollback growth; reflow/reset discard it.
pub fn nebula_prompt_input_point(&self) -> Option<Point> {
if !self.nebula_prompt_active() {
return None;
}
let (line, column) = self.nebula_prompt_input?;
if line < self.grid.scrolled_out() || column.0 >= self.columns() {
return None;
}
let relative = line as i64
- self.grid.scrolled_out() as i64
- self.grid.history_size() as i64;
let point = Point::new(Line(i32::try_from(relative).ok()?), column);
(point.line >= self.grid.topmost_line() && point.line <= self.grid.bottommost_line())
.then_some(point)
}
/// Scroll the viewport to the previous (`up`) or next shell prompt mark.
///
/// Returns whether the viewport moved, so callers know to redraw.
pub fn nebula_prompt_jump(&mut self, up: bool) -> bool
where
T: EventListener,
{
if self.mode.contains(TermMode::ALT_SCREEN) || self.nebula_prompt_marks.is_empty() {
return false;
}
let scrolled_out = self.grid.scrolled_out();
let history = self.grid.history_size();
// Absolute line currently shown at the top of the viewport.
let top_abs = scrolled_out + history - self.grid.display_offset();
let target = if up {
self.nebula_prompt_marks.iter().rev().find(|&&m| m < top_abs)
} else {
self.nebula_prompt_marks.iter().find(|&&m| m > top_abs)
};
let Some(&mark) = target else { return false };
// Put the mark's row at the viewport top: offset = history - relative
// row. Marks on the visible screen clamp to 0 (bottom), long-gone
// marks clamp to the scrollback top.
let offset = (scrolled_out + history).saturating_sub(mark).min(history);
let delta = offset as i32 - self.grid.display_offset() as i32;
if delta == 0 {
return false;
}
self.scroll_display(Scroll::Delta(delta));
true
}
}
@@ -0,0 +1,91 @@
use crate::tty::Options;
const MARK: &str = "\x1b]1337;SetUserVar=pebrel_cmd_prompt=MQ==\x07";
const START: &str = "\x1b]133;A\x07";
const END: &str = "\x1b]133;B\x07\x1b]1337;SetUserVar=pebrel_cmd_prompt=MQ==\x07";
pub(super) fn prepare(config: &Options) -> Options {
let mut prepared = config.clone();
let Some(shell) = &config.shell else { return prepared };
let name = shell.program().rsplit(['/', '\\']).next().unwrap_or_default();
if !name.eq_ignore_ascii_case("cmd") && !name.eq_ignore_ascii_case("cmd.exe") {
return prepared;
}
let prompt = config
.env
.iter()
.find(|(key, _)| key.eq_ignore_ascii_case("PROMPT"))
.map(|(_, value)| value.clone())
.or_else(|| (!config.env_is_complete).then(|| std::env::var("PROMPT").ok()).flatten())
.unwrap_or_else(|| "$P$G".into());
prepared.env.retain(|key, _| !key.eq_ignore_ascii_case("PROMPT"));
// 只改子进程的环境副本;嵌套启动或重复准备不能不断叠加标记。
let prompt = if prompt.starts_with(START) && prompt.ends_with(END) {
prompt
} else {
let prompt = prompt.strip_prefix(MARK).unwrap_or(&prompt);
format!("{START}{prompt}{END}")
};
prepared.env.insert("PROMPT".into(), prompt);
prepared
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tty::Shell;
fn cmd() -> Options {
Options {
shell: Some(Shell::new("C:\\Windows\\System32\\CMD.EXE".into(), vec!["/d".into()])),
env_is_complete: true,
..Options::default()
}
}
#[test]
fn native_prompt_preserves_custom_text_without_mutating_caller() {
let mut config = cmd();
config.env.insert("Prompt".into(), "[$P]$_$G".into());
let prepared = prepare(&config);
assert_eq!(prepared.env.get("PROMPT").unwrap(), &format!("{START}[$P]$_$G{END}"));
assert!(!prepared.env.contains_key("Prompt"));
assert_eq!(config.env.get("Prompt").unwrap(), "[$P]$_$G");
}
#[test]
fn native_prompt_defaults_and_is_idempotent() {
let prepared = prepare(&cmd());
assert_eq!(prepared.env.get("PROMPT").unwrap(), &format!("{START}$P$G{END}"));
assert_eq!(prepare(&prepared), prepared);
}
#[test]
fn native_prompt_does_not_instrument_other_shells() {
for name in ["powershell.exe", "pwsh", "wsl.exe", "mycmd.exe"] {
let mut config = cmd();
config.shell = Some(Shell::new(name.into(), vec![]));
assert_eq!(prepare(&config), config);
}
let mut config = cmd();
config.shell = None;
assert_eq!(prepare(&config), config);
}
#[test]
fn native_prompt_preserves_explicit_empty_prompt() {
let mut config = cmd();
config.env.insert("PROMPT".into(), String::new());
assert_eq!(prepare(&config).env.get("PROMPT").unwrap(), &format!("{START}{END}"));
}
#[test]
fn native_prompt_upgrades_a_prefix_only_marker_without_duplicating_it() {
let mut config = cmd();
config.env.insert("PROMPT".into(), format!("{MARK}[$P]$S"));
let prepared = prepare(&config);
assert_eq!(prepared.env.get("PROMPT").unwrap(), &format!("{START}[$P]$S{END}"));
assert_eq!(prepare(&prepared), prepared);
}
}
+2 -1
View File
@@ -288,7 +288,8 @@ fn spawn_shell(config: &Options, conpty: &Conpty) -> Result<ChildExitWatcher> {
let mut cmdline = win32_string(&cmdline(config));
let cwd = config.working_directory.as_ref().map(win32_string);
let mut creation_flags = EXTENDED_STARTUPINFO_PRESENT;
let custom_env_block = convert_custom_env(&config.env, config.env_is_complete);
let child_config = super::cmd_prompt::prepare(config);
let custom_env_block = convert_custom_env(&child_config.env, child_config.env_is_complete);
let custom_env_block_pointer = match &custom_env_block {
Some(custom_env_block) => {
creation_flags |= CREATE_UNICODE_ENVIRONMENT;
+1
View File
@@ -14,6 +14,7 @@ use crate::tty::{ChildEvent, EventedPty, EventedReadWrite, Options, Shell};
mod blocking;
mod child;
mod cmd_prompt;
mod conpty;
mod environment;