T40: review round 1: a notification names the session as the output stands at its place: the tap follows OSC 0 and 2, the title stack's CSI 22 t and 23 t and a full reset as alacritty does, and sends each title in order, since alacritty's own title events come after the tap's; one write and split writes post the same
CI / platforms (ubuntu-24.04) (push) Successful in 1m21s
CI / platforms (windows-latest) (push) Successful in 1m48s
CI / macos (push) Canceled after 7m24s

This commit is contained in:
ZacharyZhang-NY committed 2026-10-17 16:06:10 -04:00
1 parent 52576bb8ee
commit 22752a28db
9 files changed
+247 -15

No files matched your search

+1
View File
@@ -19,6 +19,7 @@ mod reports;
pub mod shell;
mod status;
mod tap;
mod titles;
#[cfg(unix)]
mod unix;
mod view;
+2
View File
@@ -25,6 +25,8 @@ pub enum PtyEvent {
Status(Status),
/// A notification or progress, OSC 9, 777 or 99.
Ask(Ask),
/// A title a program set, in the output's order: OSC 0 or 2, or the title stack's pop.
Title(String),
/// A full reset, `ESC c`.
Reset,
}
+56 -6
View File
@@ -17,6 +17,8 @@ struct Ran {
statuses: Vec<Option<Status>>,
/// Notifications and progress programs asked for.
asks: Vec<Ask>,
/// Titles, notifications and full resets, in the output's order.
said: Vec<String>,
text: String,
/// The first line's cells that carry a prompt's link.
prompt: Vec<usize>,
@@ -35,22 +37,37 @@ fn run(script: &str, size: (usize, usize)) -> Ran {
};
let (pty, mut events) =
spawn(&launch, size, (8, 16), config(&Settings::default())).expect("sh starts");
let (folders, marks, statuses, asks, success) = futures::executor::block_on(async {
let (mut folders, mut marks, mut statuses, mut asks, mut success) =
(Vec::new(), Vec::new(), Vec::new(), Vec::new(), false);
let (folders, marks, statuses, asks, said, success) = futures::executor::block_on(async {
let (mut folders, mut marks, mut statuses, mut asks, mut said, mut success) = (
Vec::new(),
Vec::new(),
Vec::new(),
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::Status(status) => statuses.push(Some(status)),
PtyEvent::Reset => statuses.push(None),
PtyEvent::Ask(ask) => asks.push(ask),
PtyEvent::Reset => {
said.push("reset".to_string());
statuses.push(None);
}
PtyEvent::Title(title) => said.push(format!("title {title}")),
PtyEvent::Ask(ask) => {
if let Ask::Show(notice) = &ask {
said.push(format!("show {}", notice.title));
}
asks.push(ask);
}
PtyEvent::Term(Event::ChildExit(status)) => success = status.success(),
PtyEvent::Term(Event::Exit) => break,
PtyEvent::Term(_) | PtyEvent::Mouse(_) => {}
}
}
(folders, marks, statuses, asks, success)
(folders, marks, statuses, asks, said, success)
});
let term = pty.term.lock();
let row = &term.grid()[Line(0)];
@@ -65,6 +82,7 @@ fn run(script: &str, size: (usize, usize)) -> Ran {
marks,
statuses,
asks,
said,
text: text.trim_end().to_string(),
prompt: prompt.collect(),
success,
@@ -161,6 +179,38 @@ fn osc_99_gathers_a_full_encoded_chunk_and_the_lines_base64_carries() {
assert_eq!(ran.text, "x");
}
#[test]
fn titles_reach_the_session_in_their_place_among_notifications() {
let parts = [
"\\033]2;Old\\007",
"\\033[22t",
"\\033]2;New\\007",
"\\033]9;a\\007",
"\\033[23t",
"\\033]9;b\\007",
"\\033c",
"\\033]9;c\\007",
];
let whole = run(&format!("printf '{}x'", parts.concat()), (40, 4));
let split: Vec<String> = parts
.iter()
.map(|part| format!("printf '{part}'; sleep 0.1"))
.collect();
let split = run(&format!("{}; printf x", split.join("; ")), (40, 4));
let said = [
"title Old",
"title New",
"show a",
"title Old",
"show b",
"reset",
"show c",
];
assert_eq!(whole.said, said, "one write");
assert_eq!(split.said, said, "a write a sequence");
assert_eq!((whole.text.as_str(), split.text.as_str()), ("x", "x"));
}
#[test]
fn a_long_escape_of_controls_keeps_the_pty_reading() {
let ran = run(
+69 -6
View File
@@ -14,6 +14,7 @@ use super::{
prompts::{self, Prompts},
pty::PtyEvent,
status,
titles::Titles,
};
/// The longest OSC body kept; longer ones are dropped whole.
@@ -26,6 +27,7 @@ pub(crate) struct Tapped<P> {
mouse: MouseModes,
prompts: Prompts,
kitty: Kitty,
titles: Titles,
events: UnboundedSender<PtyEvent>,
pid: u32,
last: Option<PathBuf>,
@@ -39,6 +41,7 @@ impl<P> Tapped<P> {
mouse: MouseModes::default(),
prompts: Prompts::default(),
kitty: Kitty::default(),
titles: Titles::default(),
events,
pid,
last: None,
@@ -58,9 +61,11 @@ impl<P: EventedReadWrite> io::Read for Tapped<P> {
}
let count = self.inner.reader().read(&mut buf[..take])?;
let read = &buf[..count];
let (events, pid, last, kitty) = (&self.events, self.pid, &mut self.last, &mut self.kitty);
self.scanner
.feed(read, &mut |found| report(found, events, pid, last, kitty));
let (events, pid, last) = (&self.events, self.pid, &mut self.last);
let (kitty, titles) = (&mut self.kitty, &mut self.titles);
self.scanner.feed(read, &mut |found| {
report(found, events, pid, last, kitty, titles)
});
if let Some(mode) = self.mouse.feed(read) {
log::info!("pty {pid}: mouse {mode:?}");
if events.unbounded_send(PtyEvent::Mouse(mode)).is_err() {
@@ -73,19 +78,29 @@ impl<P: EventedReadWrite> io::Read for Tapped<P> {
}
}
/// Sends the session what the scanner found: a full reset, a mark, a program's status or ask, or a folder.
/// Sends the session what the scanner found: a full reset, a title, a mark, a program's status or ask, or a folder.
fn report(
found: Found,
events: &UnboundedSender<PtyEvent>,
pid: u32,
last: &mut Option<PathBuf>,
kitty: &mut Kitty,
titles: &mut Titles,
) {
let event = match found {
Found::Reset => {
log::debug!("pty {pid}: a full reset");
titles.reset();
PtyEvent::Reset
}
Found::Push => {
titles.push();
return;
}
Found::Pop => match titles.pop() {
Some(title) => PtyEvent::Title(title),
None => return,
},
Found::Osc(body, length) => {
if let Some(mark) = body.strip_prefix(b"133;").and_then(marks::read) {
log::debug!("pty {pid}: {mark:?}");
@@ -107,6 +122,11 @@ fn report(
*last = Some(folder.clone());
}
PtyEvent::Folder(folder)
} else if let Some(rest) = body
.strip_prefix(b"0;")
.or_else(|| body.strip_prefix(b"2;"))
{
PtyEvent::Title(titles.set(rest))
} else {
return;
}
@@ -176,15 +196,18 @@ enum State {
Ground,
Escape,
Osc,
Csi,
}
/// What the scanner finds: an OSC's body with the sequence's length from OSC through ST, or a full reset.
/// What the scanner finds: an OSC's body with the sequence's length from OSC through ST, a full reset, or the title stack's push and pop.
pub(crate) enum Found<'a> {
Osc(&'a [u8], usize),
Reset,
Push,
Pop,
}
/// Finds each OSC body across reads, ended as vte ends it: at BEL, ESC, CAN or SUB, and each full reset.
/// Finds each OSC body across reads, ended as vte ends it: at BEL, ESC, CAN or SUB, each full reset, and CSI 22 t and 23 t.
#[derive(Default)]
pub(crate) struct Osc {
state: State,
@@ -192,6 +215,9 @@ pub(crate) struct Osc {
/// The sequence's bytes so far, the controls the body leaves out too.
length: usize,
overflow: bool,
/// A CSI's first parameter while it reads as a plain number, and its fields so far.
first: Option<u32>,
fields: usize,
}
impl Osc {
@@ -221,6 +247,11 @@ impl Osc {
found(Found::Reset);
State::Ground
}
(State::Escape, b'[') => {
self.first = Some(0);
self.fields = 1;
State::Csi
}
// vte runs controls in an escape, passes DEL and high bytes by, and stays there.
(State::Escape, 0x00..=0x17 | 0x19 | 0x1b..=0x1f | 0x7f..=0xff) => State::Escape,
(State::Escape, _) => State::Ground,
@@ -246,6 +277,38 @@ impl Osc {
}
State::Osc
}
// In a CSI, as in vte: CAN and SUB cancel, ESC restarts, controls run, DEL and high bytes pass by.
(State::Csi, 0x18 | 0x1a) => State::Ground,
(State::Csi, 0x1b) => State::Escape,
(State::Csi, 0x00..=0x1f | 0x7f..=0xff) => State::Csi,
(State::Csi, b'0'..=b'9') => {
if self.fields == 1 {
self.first = self.first.map(|first| {
first
.saturating_mul(10)
.saturating_add(u32::from(byte - b'0'))
});
}
State::Csi
}
(State::Csi, b';' | b':') => {
self.fields += 1;
State::Csi
}
(State::Csi, b't') => {
match self.first.filter(|_| self.fields <= PARAMS) {
Some(22) => found(Found::Push),
Some(23) => found(Found::Pop),
_ => {}
}
State::Ground
}
(State::Csi, 0x40..=0x7e) => State::Ground,
// A private marker or an intermediate makes another sequence.
(State::Csi, _) => {
self.first = None;
State::Csi
}
(State::Ground, _) => unreachable!("ground scans by position"),
};
}
+34
View File
@@ -9,6 +9,8 @@ fn scan(chunks: &[&[u8]]) -> Vec<String> {
found.push(match thing {
Found::Osc(body, _) => String::from_utf8(body.to_vec()).expect("utf-8"),
Found::Reset => "RESET".into(),
Found::Push => "PUSH".into(),
Found::Pop => "POP".into(),
})
});
}
@@ -225,3 +227,35 @@ fn the_tap_reads_mouse_modes_as_vte_does_byte_by_byte() {
}
}
}
#[test]
fn finds_the_title_stack_s_push_and_pop_as_vte_reads_them() {
assert_eq!(scan(&[b"\x1b[22;0t\x1b[23;2t"]), ["PUSH", "POP"]);
assert_eq!(
scan(&[b"\x1b[2", b"2:1\x07t\x1b[23t"]),
["PUSH", "POP"],
"across reads, a control run"
);
for other in [
&b"\x1b[?22t"[..],
b"\x1b[22$t",
b"\x1b[1;22t",
b"\x1b[t",
b"\x1b[22\x18t",
b"\x1b[24t",
] {
assert!(
scan(&[other]).is_empty(),
"{}",
String::from_utf8_lossy(other)
);
}
let mut many = b"\x1b[22".to_vec();
many.extend(std::iter::repeat_n(&b";1"[..], 32).flatten());
many.push(b't');
assert!(scan(&[&many]).is_empty(), "vte drops one past 32 fields");
assert_eq!(
scan(&[b"\x1b[22t\x1b]2;t\x07\x1b[23t"]),
["PUSH", "2;t", "POP"]
);
}
+80
View File
@@ -0,0 +1,80 @@
/// The titles alacritty saves before it drops the oldest.
const SAVED: usize = 4096;
/// A program's title as alacritty keeps it, read in the output's order: OSC 0 and 2 set it, CSI 22 t saves it, CSI 23 t brings it back, a full reset empties both.
#[derive(Default)]
pub(crate) struct Titles {
/// Empty while the program names none.
title: String,
saved: Vec<String>,
}
impl Titles {
/// Sets the title an OSC 0 or 2 body past its number names: its UTF-8 parameters joined by `;` and trimmed, as alacritty joins them.
pub fn set(&mut self, rest: &[u8]) -> String {
let parts: Vec<&str> = rest
.split(|&byte| byte == b';')
.filter_map(|part| std::str::from_utf8(part).ok())
.collect();
self.title = parts.join(";").trim().to_owned();
self.title.clone()
}
/// Saves the title: CSI 22 t.
pub fn push(&mut self) {
if self.saved.len() == SAVED {
self.saved.remove(0);
}
self.saved.push(self.title.clone());
}
/// Brings the last title saved back: CSI 23 t; none when none was saved.
pub fn pop(&mut self) -> Option<String> {
let title = self.saved.pop()?;
self.title.clone_from(&title);
Some(title)
}
/// A full reset empties the title and those saved.
pub fn reset(&mut self) {
self.title.clear();
self.saved.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_title_reads_as_alacritty_joins_it() {
let mut titles = Titles::default();
assert_eq!(titles.set(b" a;b "), "a;b");
assert_eq!(
titles.set(b"a;\xff;b"),
"a;b",
"a part not in UTF-8 passes by"
);
assert_eq!(titles.set(b""), "");
}
#[test]
fn a_title_saved_comes_back_and_a_reset_empties_both() {
let mut titles = Titles::default();
assert_eq!(titles.pop(), None, "none saved");
titles.set(b"shell");
titles.push();
titles.set(b"vim");
assert_eq!(titles.pop().as_deref(), Some("shell"));
assert_eq!(titles.pop(), None);
titles.push();
titles.reset();
assert_eq!(titles.pop(), None, "a reset empties those saved");
for at in 0..=SAVED {
titles.set(at.to_string().as_bytes());
titles.push();
}
assert_eq!(titles.saved.len(), SAVED);
assert_eq!(titles.saved[0], "1", "the oldest went");
}
}
+2
View File
@@ -276,8 +276,10 @@ impl TerminalView {
self.ask(ask, cx);
self.restate(cx);
}
PtyEvent::Title(title) => self.title = title,
PtyEvent::Reset => {
self.records.reset();
self.title.clear();
self.restate(cx);
}
}