T34: prompts and commands carry OSC 133 marks: the tap reads every OSC body and hands 133's marks to the session, which keeps its last command's status; zsh, bash, fish and PowerShell mark what the shell leaves unmarked, and the user's prompt still sees the status
CI / platforms (ubuntu-24.04) (push) Successful in 1m2s
CI / platforms (windows-latest) (push) Successful in 1m45s
CI / macos (push) Successful in 12m10s

This commit is contained in:
ZacharyZhang-NY committed 2026-10-16 22:16:50 -04:00
1 parent cccfb2c82b
commit c53b540f42
18 files changed
+359 -88

No files matched your search

+90
View File
@@ -0,0 +1,90 @@
/// A semantic prompt mark a program sends, OSC 133.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Mark {
/// A prompt starts: A, or N.
Prompt,
/// The prompt ends and input starts: B, or I.
Input,
/// Input ends and the command's output starts: C.
Output,
/// The command ended, with the status it gave: D.
Done(Option<i32>),
}
/// The mark in an OSC 133 body past `133;`; none for one the terminal takes no action on.
pub(crate) fn read(body: &[u8]) -> Option<Mark> {
let shown = || String::from_utf8_lossy(body);
let Some((&letter, rest)) = body.split_first() else {
log::warn!("pty: OSC 133 without a mark");
return None;
};
let options = match rest {
[] => rest,
[b';', options @ ..] => options,
_ => {
log::warn!("pty: OSC 133;{} is no mark", shown());
return None;
}
};
match letter {
b'A' | b'N' => Some(Mark::Prompt),
b'B' | b'I' => Some(Mark::Input),
b'C' => Some(Mark::Output),
b'D' => Some(Mark::Done(status(options))),
// L's fresh line needs the cursor, unseen before the parser; P adds nothing to A.
b'L' | b'P' => {
log::debug!("pty: OSC 133;{} takes no action here", shown());
None
}
_ => {
log::warn!("pty: OSC 133;{} is no mark", shown());
None
}
}
}
/// D's status, its first option; none when the program gave none or no number.
fn status(options: &[u8]) -> Option<i32> {
let first = options.split(|&byte| byte == b';').next()?;
if first.is_empty() {
return None;
}
let status = std::str::from_utf8(first)
.ok()
.and_then(|text| text.parse().ok());
if status.is_none() {
log::warn!(
"pty: OSC 133;D with a status that is no number ({})",
String::from_utf8_lossy(first)
);
}
status
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn each_mark_reads_with_its_options_as_ghostty_reads_it() {
assert_eq!(read(b"A"), Some(Mark::Prompt));
assert_eq!(read(b"A;click_events=1"), Some(Mark::Prompt));
assert_eq!(read(b"N;aid=7"), Some(Mark::Prompt));
assert_eq!(read(b"B"), Some(Mark::Input));
assert_eq!(read(b"I"), Some(Mark::Input));
assert_eq!(read(b"C;cmdline_url=false"), Some(Mark::Output));
assert_eq!(read(b"D;1"), Some(Mark::Done(Some(1))));
assert_eq!(read(b"D;127;aid=foo"), Some(Mark::Done(Some(127))));
assert_eq!(read(b"D;-1"), Some(Mark::Done(Some(-1))));
assert_eq!(read(b"D"), Some(Mark::Done(None)));
assert_eq!(read(b"D;"), Some(Mark::Done(None)));
assert_eq!(read(b"D;abc"), Some(Mark::Done(None)));
}
#[test]
fn a_mark_the_terminal_takes_no_action_on_reads_as_none() {
for body in [&b""[..], b"L", b"P;k=s", b"AB", b"Q", b"a"] {
assert_eq!(read(body), None, "{}", String::from_utf8_lossy(body));
}
}
}
+1
View File
@@ -8,6 +8,7 @@ pub mod keys;
mod links;
#[cfg(target_os = "macos")]
pub mod mac;
mod marks;
pub mod mouse;
mod paint;
mod pointer;
+19 -4
View File
@@ -11,7 +11,7 @@ use alacritty_terminal::{
};
use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender, unbounded};
use super::{mouse::MouseMode, tap::Tapped};
use super::{marks::Mark, mouse::MouseMode, tap::Tapped};
use crate::settings::{CursorStyle, Settings};
/// What a session hears from its pty.
@@ -20,6 +20,7 @@ pub enum PtyEvent {
Term(Event),
Folder(PathBuf),
Mouse(MouseMode),
Mark(Mark),
}
/// Hands the grid's events to the session; `pid` names the program in logs.
@@ -256,6 +257,7 @@ mod tests {
struct Ran {
folders: Vec<PathBuf>,
marks: Vec<Mark>,
text: String,
success: bool,
pid: u32,
@@ -272,23 +274,25 @@ mod tests {
};
let (pty, mut events) =
spawn(&launch, size, (8, 16), config(&Settings::default())).expect("sh starts");
let (folders, success) = futures::executor::block_on(async {
let (mut folders, mut success) = (Vec::new(), false);
let (folders, marks, success) = futures::executor::block_on(async {
let (mut folders, mut marks, mut success) = (Vec::new(), Vec::new(), false);
while let Some(event) = events.next().await {
match event {
PtyEvent::Folder(folder) => folders.push(folder),
PtyEvent::Mark(mark) => marks.push(mark),
PtyEvent::Term(Event::ChildExit(status)) => success = status.success(),
PtyEvent::Term(Event::Exit) => break,
PtyEvent::Term(_) | PtyEvent::Mouse(_) => {}
}
}
(folders, success)
(folders, marks, success)
});
let term = pty.term.lock();
let row = &term.grid()[Line(0)];
let text: String = (0..24).map(|column| row[Column(column)].c).collect();
Ran {
folders,
marks,
text: text.trim_end().to_string(),
success,
pid: pty.pid,
@@ -314,6 +318,17 @@ mod tests {
assert_eq!(ran.text, "x");
}
#[test]
fn osc_133_reaches_the_session_as_marks() {
let ran = run(
"printf '\\033]133;A\\007>\\033]133;B\\007\\033]133;C\\033\\\\x\\033]133;D;3\\007'",
(40, 4),
);
let marks = [Mark::Prompt, Mark::Input, Mark::Output, Mark::Done(Some(3))];
assert_eq!(ran.marks, marks);
assert_eq!(ran.text, ">x");
}
#[test]
fn the_foreground_is_the_program_s_own_group() {
let launch = Launch {
+29 -5
View File
@@ -6,7 +6,7 @@ use futures::channel::mpsc::TryRecvError;
use super::*;
use crate::{
settings::Settings,
term::{Events, Pty, PtyEvent, config, spawn},
term::{Events, Pty, PtyEvent, config, marks::Mark, spawn},
};
/// A folder name with a space, a percent, a bell and a newline.
@@ -43,6 +43,7 @@ struct Run {
start: PathBuf,
folders: Vec<PathBuf>,
titles: Vec<String>,
marks: Vec<Mark>,
deadline: Instant,
}
@@ -79,6 +80,7 @@ impl Run {
start,
folders: Vec::new(),
titles: Vec::new(),
marks: Vec::new(),
deadline: Instant::now() + Duration::from_secs(30),
}
}
@@ -88,15 +90,17 @@ impl Run {
while !done(self) {
assert!(
Instant::now() < self.deadline,
"{}: no {what}; folders {:?}, titles {:?}, screen\n{}",
"{}: no {what}; folders {:?}, titles {:?}, marks {:?}, screen\n{}",
self.shell,
self.folders,
self.titles,
self.marks,
self.screen()
);
match self.events.try_recv() {
Ok(PtyEvent::Folder(folder)) => self.folders.push(folder),
Ok(PtyEvent::Term(Event::Title(title))) => self.titles.push(title),
Ok(PtyEvent::Mark(mark)) => self.marks.push(mark),
Ok(PtyEvent::Term(Event::PtyWrite(reply))) => self.pty.write(reply.into_bytes()),
Ok(PtyEvent::Term(Event::ChildExit(status))) => {
panic!("{} ended, {status}:\n{}", self.shell, self.screen())
@@ -142,6 +146,13 @@ impl Run {
self.until("inner folder", |run| run.folders.contains(&inner));
}
/// Types `line`, the last command sent, which fails with 1; its marks follow its earlier ones.
fn fails(&mut self, line: &str) {
self.send(line);
let after = [Mark::Output, Mark::Done(Some(1)), Mark::Prompt, Mark::Input];
self.until("marks of the failure", |run| run.marks.ends_with(&after));
}
fn finish(mut self) {
self.send("exit");
loop {
@@ -193,7 +204,10 @@ fn a_script_it_cannot_read_fails_the_install() {
#[test]
fn zsh_reports_its_folder_byte_for_byte_and_its_titles() {
let mut run = Run::start(&installed("zsh"), &[]);
// A theme that sets PS1 in its own hook, which runs after ours.
let theme =
"autoload -Uz add-zsh-hook\n_theme() { PS1='THEME %? > ' }\nadd-zsh-hook precmd _theme";
let mut run = Run::start(&installed("zsh"), &[(".zshrc", theme)]);
run.walk("(( $+functions[_ely_precmd] )) && echo ELY-LOAD''ED");
run.until("title", |run| {
run.titles.iter().any(|title| title == "in ner%xy")
@@ -203,6 +217,10 @@ fn zsh_reports_its_folder_byte_for_byte_and_its_titles() {
"{:?}",
run.titles
);
run.fails("false");
run.until("the theme's prompt", |run| {
run.shows("THEME 0 > false") && run.shows("THEME 1 >")
});
run.finish();
}
@@ -215,8 +233,9 @@ fn bash_reports_its_folder_byte_for_byte_and_keeps_the_status() {
run.until("title", |run| {
run.titles.iter().any(|title| title == "in ner%xy")
});
run.send("false");
run.fails("false");
run.until("kept status", |run| run.shows("USER_STATUS=1"));
assert!(run.shows("$ false"), "{}", run.screen());
run.finish();
}
@@ -228,6 +247,10 @@ fn fish_loads_its_script_and_restores_its_data_dirs() {
run.send(r#"echo "DIRS[$ELY_FISH_XDG_DATA_DIRS][$XDG_DATA_DIRS]""#);
let restored = format!("DIRS[][{previous}]");
run.until("restored dirs", |run| run.shows(&restored));
// The prompt hears each command's own status, none failed so far.
assert!(!run.shows("[1]"), "{}", run.screen());
run.fails("false");
run.until("the failed status", |run| run.shows("[1]>"));
run.finish();
}
@@ -244,8 +267,9 @@ fn powershell_reports_its_folder_byte_for_byte_and_keeps_the_status() {
run.until("title", |run| {
run.titles.iter().any(|title| title == "in ner%xy")
});
run.send("/usr/bin/false");
run.fails("/usr/bin/false");
run.until("kept status", |run| run.shows("USER_SUCCESS=False"));
assert!(run.shows("> /usr/bin/false"), "{}", run.screen());
run.finish();
}
+16 -9
View File
@@ -7,7 +7,7 @@ use alacritty_terminal::{
use futures::channel::mpsc::UnboundedSender;
use polling::{Event, PollMode, Poller};
use super::{mouse::MouseMode, pty::PtyEvent};
use super::{marks, mouse::MouseMode, pty::PtyEvent};
/// The longest OSC body kept; longer ones are dropped whole.
const MOST: usize = 4096;
@@ -15,7 +15,7 @@ const MOST: usize = 4096;
/// A pty whose reader shows each byte to its scanners before the parser.
pub(crate) struct Tapped<P> {
pub inner: P,
scanner: Osc7,
scanner: Osc,
mouse: MouseModes,
events: UnboundedSender<PtyEvent>,
pid: u32,
@@ -26,7 +26,7 @@ impl<P> Tapped<P> {
pub fn new(inner: P, events: UnboundedSender<PtyEvent>, pid: u32) -> Self {
Self {
inner,
scanner: Osc7::default(),
scanner: Osc::default(),
mouse: MouseModes::default(),
events,
pid,
@@ -40,7 +40,14 @@ impl<P: EventedReadWrite> io::Read for Tapped<P> {
let count = self.inner.reader().read(buf)?;
let (events, pid, last) = (&self.events, self.pid, &mut self.last);
self.scanner.feed(&buf[..count], &mut |body| {
let Some(folder) = folder(body) else {
if let Some(mark) = body.strip_prefix(b"133;").and_then(marks::read) {
log::debug!("pty {pid}: {mark:?}");
if events.unbounded_send(PtyEvent::Mark(mark)).is_err() {
log::debug!("pty {pid}: a mark after its session closed");
}
return;
}
let Some(folder) = body.strip_prefix(b"7;").and_then(folder) else {
return;
};
if last.as_ref() == Some(&folder) {
@@ -114,15 +121,15 @@ enum State {
OscEscape,
}
/// Finds `ESC ] 7 ; body` ended by BEL or `ESC \`, across reads.
/// Finds each OSC body, `ESC ] body` ended by BEL or `ESC \`, across reads.
#[derive(Default)]
pub(crate) struct Osc7 {
pub(crate) struct Osc {
state: State,
body: Vec<u8>,
overflow: bool,
}
impl Osc7 {
impl Osc {
pub fn feed(&mut self, bytes: &[u8], found: &mut impl FnMut(&[u8])) {
let mut rest = bytes;
while !rest.is_empty() {
@@ -175,8 +182,8 @@ impl Osc7 {
fn finish(&mut self, found: &mut impl FnMut(&[u8])) {
if self.overflow {
log::warn!("pty: an OSC over {MOST} bytes dropped");
} else if let Some(body) = self.body.strip_prefix(b"7;") {
found(body);
} else {
found(&self.body);
}
self.body.clear();
}
+14 -8
View File
@@ -1,7 +1,7 @@
use super::*;
fn scan(chunks: &[&[u8]]) -> Vec<String> {
let mut scanner = Osc7::default();
let mut scanner = Osc::default();
let mut found = Vec::new();
for chunk in chunks {
scanner.feed(chunk, &mut |body| {
@@ -12,17 +12,23 @@ fn scan(chunks: &[&[u8]]) -> Vec<String> {
}
#[test]
fn finds_osc_7_ended_by_bel_or_st_across_reads() {
assert_eq!(scan(&[b"ab\x1b]7;file:///a\x07cd"]), ["file:///a"]);
assert_eq!(scan(&[b"\x1b]7;file:", b"///b\x1b", b"\\"]), ["file:///b"]);
assert_eq!(scan(&[b"\x1b", b"]7;x\x07\x1b]7;y\x1b\\"]), ["x", "y"]);
fn finds_each_osc_body_ended_by_bel_or_st_across_reads() {
assert_eq!(scan(&[b"ab\x1b]7;file:///a\x07cd"]), ["7;file:///a"]);
assert_eq!(
scan(&[b"\x1b]7;file:", b"///b\x1b", b"\\"]),
["7;file:///b"]
);
assert_eq!(
scan(&[b"\x1b", b"]133;A\x07\x1b]0;t\x1b\\"]),
["133;A", "0;t"]
);
}
#[test]
fn other_sequences_and_cancels_pass() {
assert!(scan(&[b"\x1b]0;title\x07\x1b[31m"]).is_empty());
assert!(scan(&[b"\x1b[31m\x1bc"]).is_empty());
assert!(scan(&[b"\x1b]7;file:///a\x18\x07"]).is_empty());
assert_eq!(scan(&[b"\x1b]7;lost\x1b]7;kept\x07"]), ["kept"]);
assert_eq!(scan(&[b"\x1b]7;lost\x1b]133;D;1\x07"]), ["133;D;1"]);
}
#[test]
@@ -31,7 +37,7 @@ fn an_overlong_body_is_dropped() {
long.extend(std::iter::repeat_n(b'a', MOST + 1));
long.push(0x07);
assert!(scan(&[&long]).is_empty());
assert_eq!(scan(&[&long, b"\x1b]7;b\x07"]), ["b"]);
assert_eq!(scan(&[&long, b"\x1b]7;b\x07"]), ["7;b"]);
}
#[test]
+5
View File
@@ -22,6 +22,7 @@ use super::{
frame::frame,
ink::Ink,
input::{self, Find},
marks::Mark,
mouse::MouseMode,
paint::{self, Look},
pointer::Pointer,
@@ -48,6 +49,8 @@ pub enum TerminalEvent {
Title(String),
Folder(PathBuf),
Bell,
/// A command the shell ran ended, with the status it gave.
Finished(Option<i32>),
Exited(ExitStatus),
}
@@ -218,6 +221,8 @@ impl TerminalView {
}
PtyEvent::Term(event) => self.term_event(event, cx),
PtyEvent::Mouse(mode) => self.mouse = mode,
PtyEvent::Mark(Mark::Done(status)) => cx.emit(TerminalEvent::Finished(status)),
PtyEvent::Mark(_) => {}
}
}
// ponytail: a full search per output batch while find is open; search only new lines if it lags.