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

+2 -2
View File
@@ -34,7 +34,7 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
- `src/main.rs`: start. `src/lib.rs`: the modules, public so tests and examples reach them. `src/app.rs`: fonts, settings, integration, then the main window, at the size the settings keep, which it keeps as it settles. `src/menus.rs`: the menu bar, the keys of Quit, the font's size and Minimize, and what they do. `src/blur.rs`: what the window shows of the desktop, from the opacity and blur settings, set as they change; `blur/macos` gives gpui's NSVisualEffectView a material that blurs; `blur/wayland` asks a Wayland compositor for blur behind the window's surface through `ext_background_effect_v1`; `blur/x11` names the window's pane in `_KDE_NET_WM_BLUR_BEHIND_REGION`. `src/workspace.rs`: the window's content, Ely's `ColumnShell` with the sessions, terminal and right columns, their toggles, the right column's Git and Files switch, the title, and the sessions' lifecycle. `src/workspace/widths.rs`: the side columns' widths, which a drag on their border sets and the settings keep once it rests. `src/workspace/events.rs`: what a session's terminal reports: title, folder, bell, the state its row shows, the program's exit; and a press on a notification a session posted. `src/sessions.rs`: a session and its row, with the mark of its status and what hover shows of it. `src/files.rs`: the Files panel, the shown session's folder as Ely's `Tree` with Git's marks. `src/git.rs`: git through its command line: Files' marks, and the Git panel's status, stage, unstage, discard, commit and log; `src/git/diff.rs` reads git's diff into Ely's stretches. `src/git/panel.rs`: the Git panel's state and actions; `src/git/panel/view.rs`: how it draws.
- `src/settings`: the model, its ranges, and `SettingsStore`, the global that loads, checks and saves `settings.json`; `window` is the settings window, `window/appearance` its Appearance page, `window/terminal` its Terminal page. `tests/fonts.rs` brings its own main: gpui builds its Mac platform only on the main thread.
- `src/themes`: built-in terminal themes, the chrome palette each maps to, and `apply`, which dresses the app for the system's appearance; `omarchy`, on Linux, wears Omarchy 4's current theme and follows its switches.
- `src/term`: the terminal engine. `pty` starts a program through alacritty's event loop; `tap` wraps the pty so its reader scans OSC 7, 9, 99, 133, 777 and 7501, full resets and the mouse modes before the parser; `marks` reads OSC 133's prompt marks; `status` reads OSC 7501 and keeps a terminal's records; `notify` reads OSC 9, 777 and 99, the notifications and OSC 9;4's progress, and gathers OSC 99's chunks; `reports` is the view's side of what programs report: it keeps OSC 7501's records, maps OSC 9;4, answers OSC 99's query and alive, and posts the system notifications its session posts; `prompts` turns each mark into an OSC 8 link the parser keeps in a prompt's cells, and finds the prompt to jump to; `herdr` follows herdr's focused pane and its agents' states through herdr's socket while herdr's client runs in the session: `herdr/unix` finds the client leading the pty's foreground, `herdr/windows` under the session's shell, and asks its named pipe. `unix` starts the program itself, so a failed `chdir` fails the start; Windows uses alacritty's ConPTY. `shell` writes the integration scripts at start and builds each shell's launch. `frame` reads the grid into styled rows, `boxes` turns box drawing, block elements and powerline arrows into rects and polygons (`boxes/outline` strokes curves and diagonals into polygons), `ink` resolves colors, `paint` draws rows, decorations and the cursor, `view` is the entity: pty events, metrics, blink, resize. `zoom` holds the points Bigger and Smaller add to the Size setting, unsaved. `keys` turns key events into bytes, the kitty protocol and, in `keys/legacy`, xterm's encoding, with no gpui types; `input` is the view's key listeners, the bindings that keep Tab and F6 from Ely's focus moves, and its IME handler; `mac` reads the Option side and repeat flag of AppKit's current event, keeps held keys repeating, and hands Command key-ups to the focused view. `mouse` encodes mouse reports, with no gpui types; `links` finds the link at a cell; `pointer` is the view's mouse listeners: selection, reports, wheel, links, copy and paste. `find` is the find box: Ely's `FindWidget`, and Ely's `find_all` over the grid's shown text.
- `src/term`: the terminal engine. `pty` starts a program through alacritty's event loop; `tap` wraps the pty so its reader scans OSC 0, 2, 7, 9, 99, 133, 777 and 7501, the title stack's CSI 22 t and 23 t, full resets and the mouse modes before the parser; `titles` keeps a program's title as alacritty does, in the output's order; `marks` reads OSC 133's prompt marks; `status` reads OSC 7501 and keeps a terminal's records; `notify` reads OSC 9, 777 and 99, the notifications and OSC 9;4's progress, and gathers OSC 99's chunks; `reports` is the view's side of what programs report: it keeps OSC 7501's records, maps OSC 9;4, answers OSC 99's query and alive, and posts the system notifications its session posts; `prompts` turns each mark into an OSC 8 link the parser keeps in a prompt's cells, and finds the prompt to jump to; `herdr` follows herdr's focused pane and its agents' states through herdr's socket while herdr's client runs in the session: `herdr/unix` finds the client leading the pty's foreground, `herdr/windows` under the session's shell, and asks its named pipe. `unix` starts the program itself, so a failed `chdir` fails the start; Windows uses alacritty's ConPTY. `shell` writes the integration scripts at start and builds each shell's launch. `frame` reads the grid into styled rows, `boxes` turns box drawing, block elements and powerline arrows into rects and polygons (`boxes/outline` strokes curves and diagonals into polygons), `ink` resolves colors, `paint` draws rows, decorations and the cursor, `view` is the entity: pty events, metrics, blink, resize. `zoom` holds the points Bigger and Smaller add to the Size setting, unsaved. `keys` turns key events into bytes, the kitty protocol and, in `keys/legacy`, xterm's encoding, with no gpui types; `input` is the view's key listeners, the bindings that keep Tab and F6 from Ely's focus moves, and its IME handler; `mac` reads the Option side and repeat flag of AppKit's current event, keeps held keys repeating, and hands Command key-ups to the focused view. `mouse` encodes mouse reports, with no gpui types; `links` finds the link at a cell; `pointer` is the view's mouse listeners: selection, reports, wheel, links, copy and paste. `find` is the find box: Ely's `FindWidget`, and Ely's `find_all` over the grid's shown text.
- `assets/shell-integration`: ELYTerminal's own scripts (not Ghostty's, which are GPL): zsh through `ZDOTDIR`, bash through `--rcfile`, fish through `XDG_DATA_DIRS`, PowerShell through `-NoExit -Command`. Each prompt sends OSC 7 and, except in fish, whose `fish_title` stays, a title; each marks prompts and commands with OSC 133 where the shell does not.
- `assets/fonts`: JetBrains Mono 2.304's bold and italic faces (OFL; Ely bundles regular), and Nerd Fonts 3.5.1's JetBrains Mono NF Mono Regular, which draws icons a font lacks.
- `assets/themes`: Ghostty theme files copied verbatim from iTerm2-Color-Schemes (MIT, its `LICENSE` beside them).
@@ -102,4 +102,4 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
- 2026-10-17: programs ask for notifications through OSC 9, 777 and 99 and report progress through OSC 9;4, which the owner asked for. OSC 9 is ConEmu's command where Ghostty reads one, 9;1 to 9;12 each by its own shape, else iTerm2's notification, its text the title; OSC 777 is rxvt's `notify;title;body`, another extension logged and passed by. OSC 99 is kitty's: `k=v` pairs before the payload, a malformed pair passed by; `i`, of `[A-Za-z0-9_-+.]` alone or the sequence drops, gathers chunks while `d=0`, 32 notifications and 16384 bytes of text at most; a payload holds 2048 bytes plain or 4096 encoded at most; plain text is UTF-8 without a control character; `e=1` reads it as base64 group by group, so chunks split before encoding or after join, and it carries any UTF-8, newlines too, checked once the text is whole; a chunk that breaks a rule drops what its notification gathered; metadata holds 1024 bytes at most, and the tap keeps an OSC 99 body to 5124 bytes, room for metadata and a full payload, where other bodies stop at 4096; `p` takes title, body, close, ? and alive, and other payloads pass by; `a`, `c` and `w` are kept for posting, and `o` passes by; a lone body is the title, and one with neither passes by. The query and alive are answered as the notifications below say; an answer names the asker's id, or 0. OSC 9;4's states, remove, set, error, indeterminate and pause, read as Ghostty reads them, the progress clamped to 100, stand as the root record: set and pause working with their progress, indeterminate working without, error an error, remove none; this lasts until a program reports through OSC 7501, and again after a full reset, as the spec asks, so a mapped 9;4 never wipes a real report. The terminal posts the notifications, as below.
- 2026-10-17: inside herdr, which passes no OSC 7501 on, herdr's agents are the session's records. The snapshot the follower asks for lists them; one in a state herdr knows, idle, working, blocked or done, is the record `herdr:PANE`, whose colon no OSC 7501 id holds, so a program's record of a name like it, `herdr/build` among them, stays its own; unknown and any state herdr adds later pass by. herdr names no kind and no progress; the app is the agent, the title its name, else the name herdr shows for it, and the message its terminal title less the spinner herdr strips, else the title its metadata sets; herdr's done is idle and not yet seen. A snapshot that lists other agents replaces them whole, so a full reset or a clear that dropped them sees them back at the next; they go when herdr leaves the foreground, and a snapshot that lands after the shell reported a folder is dropped, its agents with it. A change of their states is logged; a herdr whose snapshot lacks `agents` lists none.
- 2026-10-17: a session's row shows its status, which the owner asked for: one mark before its title, the most pressing state first. Blocked shows its kind, a shield for permission, a circled question mark for a question, a key for a login and a hand for none, in the theme's warning color; error a circled cross; working Ely's `ProgressRing` at its progress with Ely's loader icon inside, since the ring's track takes the theme's border color, which a selected row's ground matches in Ely Dark, where 0% showed nothing, 24 pixels wide, since at an icon's 16 its stroke filled it, and every mark takes that box, so the title stays put as the state changes; else the loader alone, held still, since a turning spinner would draw the window at every frame for as long as a program works; done a circled check; then a failed last command, a circled alert; then idle, a hollow circle. Among the records in that state, the one reported last speaks. Hover over the mark lists each record, ten at most: its state, its app, its own or its nearest ancestor's as the spec asks, its title and its message, each on one line and cut at 120 characters, with C1 controls, direction controls and invisible formatting taken out, as the spec asks; joiners stay, since scripts and emoji need them. A failed last command's status follows. Hover reads the records as they stand at each frame, so an open one follows a change; it stays while the pointer is over it and scrolls within the window's height, so ten long records all show. A key the user types, a paste too, sees the terminal's done and error records, which then go, as the spec suggests; herdr's stay, since herdr marks them seen. The view notes the state its row shows after each change it hears, not once an output, and tells the workspace of each, and a hidden session whose mark turns to blocked sets its title strong until it is shown, though it went on within the same output, so one output marks as two would. A change of herdr's records draws the view again, so an open hover follows them though the state holds. The view logs each change of that state, and keeps its last command's status.
- 2026-10-17: a session posts system notifications through gpui's, which the owner asked for. At start `set_app_identity` names the app: on Windows that sets the process's AppUserModelID, which the taskbar groups its windows by too, so a pin made before may show apart, and the first notification writes the app's name under `HKCU\Software\Classes\AppUserModelId`, as gpui needs for an app without a package; on macOS gpui posts only from the app bundle, asks for leave at the first, and logs once, under the `gpui_macos` target, that it posts nothing outside one; on Linux it posts over D-Bus through notify-rust. Each terminal view posts its own, in the order its output asks, so OSC 99's answers keep their place before a DA1 sent after them: when its row's mark turns to waiting, done or error, at each turn, as the row's waiting mark is set, the hover's line for the record its body; when a command C started ends, `Command finished`, or `Command exited with N`, so a prompt drawn again, as macOS's bash 3.2 draws one with its D at a resize, posts nothing, and bash 3.2, which sends no C, posts no command's end, as Ghostty's integration, which skips it, posts none; and when a program asks through OSC 9, 777 or 99, its title and body the body, each line shown as text. It posts only while its window is not active or it is not the session shown, which the workspace tells it, one every 3 seconds at most, a later one logged and dropped; the title is the program's title, else the app's name, as the row names the session. The session's own share a tag, so a new one takes the old one's place where the system allows, as an OSC 99 id does; OSC 9, 777 and OSC 99 without an id each take their own; a session keeps its last 64. A press goes to the window whose session posted it: it shows the window and the session, unless OSC 99 said `a=-focus`, and tells a program that asked `a=report`, `OSC 99 ; i=ID ;`; a press whose record went, a twin Linux stacked and pressed before or one past the 64 kept, still shows its session and tells no program. macOS brings the app forward at any press, `a=-focus` or not, and on Wayland gpui asks for the window with a serial the compositor likely refuses, so KWin and Mutter mark it as asking for attention instead. gpui hears of no close on any system, so `c=1` hears `p=close;untracked` once posted, and alive lists the ids posted and not yet pressed, closed or expired. On macOS and Windows a close takes the notification back, and `w` over 0 takes it back once it runs out, its session gone or not, unless a later post holds its tag; gpui on Linux closes and replaces none, so there a close and `w` pass by, logged, a closed one stays alive, and an update posts beside the old one. `o` passes by: each notification posts only while unseen, which is kitty's invisible, so the query answers `a=focus,report:o=invisible:p=title,body,close,?,alive:w=1`, on Linux `a=focus,report:o=invisible:p=title,body,?,alive`, never with the `c` that close events would need. Windows' toasts are silent, as macOS's are; on Linux the notification daemon decides, and reads the body as markup, so its `&`, `<` and `>` go as entities.
- 2026-10-17: a session posts system notifications through gpui's, which the owner asked for. At start `set_app_identity` names the app: on Windows that sets the process's AppUserModelID, which the taskbar groups its windows by too, so a pin made before may show apart, and the first notification writes the app's name under `HKCU\Software\Classes\AppUserModelId`, as gpui needs for an app without a package; on macOS gpui posts only from the app bundle, asks for leave at the first, and logs once, under the `gpui_macos` target, that it posts nothing outside one; on Linux it posts over D-Bus through notify-rust. Each terminal view posts its own, in the order its output asks, so OSC 99's answers keep their place before a DA1 sent after them: when its row's mark turns to waiting, done or error, at each turn, as the row's waiting mark is set, the hover's line for the record its body; when a command C started ends, `Command finished`, or `Command exited with N`, so a prompt drawn again, as macOS's bash 3.2 draws one with its D at a resize, posts nothing, and bash 3.2, which sends no C, posts no command's end, as Ghostty's integration, which skips it, posts none; and when a program asks through OSC 9, 777 or 99, its title and body the body, each line shown as text. It posts only while its window is not active or it is not the session shown, which the workspace tells it, one every 3 seconds at most, a later one logged and dropped; the title is the program's title, else the app's name, as the row names the session, read in the output's order: alacritty's title events come after the tap's, so 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 a title set before a notification in the same output names it. The session's own share a tag, so a new one takes the old one's place where the system allows, as an OSC 99 id does; OSC 9, 777 and OSC 99 without an id each take their own; a session keeps its last 64. A press goes to the window whose session posted it: it shows the window and the session, unless OSC 99 said `a=-focus`, and tells a program that asked `a=report`, `OSC 99 ; i=ID ;`; a press whose record went, a twin Linux stacked and pressed before or one past the 64 kept, still shows its session and tells no program. macOS brings the app forward at any press, `a=-focus` or not, and on Wayland gpui asks for the window with a serial the compositor likely refuses, so KWin and Mutter mark it as asking for attention instead. gpui hears of no close on any system, so `c=1` hears `p=close;untracked` once posted, and alive lists the ids posted and not yet pressed, closed or expired. On macOS and Windows a close takes the notification back, and `w` over 0 takes it back once it runs out, its session gone or not, unless a later post holds its tag; gpui on Linux closes and replaces none, so there a close and `w` pass by, logged, a closed one stays alive, and an update posts beside the old one. `o` passes by: each notification posts only while unseen, which is kitty's invisible, so the query answers `a=focus,report:o=invisible:p=title,body,close,?,alive:w=1`, on Linux `a=focus,report:o=invisible:p=title,body,?,alive`, never with the `c` that close events would need. Windows' toasts are silent, as macOS's are; on Linux the notification daemon decides, and reads the body as markup, so its `&`, `<` and `>` go as entities.
+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);
}
}
+1 -1
View File
@@ -58,7 +58,7 @@ What no check reached: no real KWin drew the blur; of Wayland desktops, only Hyp
| Inside herdr, the folder of herdr's focused pane, read from its socket, on macOS, Linux and Windows | T29, T32 | `term::herdr` tests: the socket found as herdr 0.9 finds it, only the terminal client followed and a remote one refused, the snapshot read, a socket round trip, a process's path, arguments and variables; `the_foreground_is_the_program_s_own_group`; on X11 with herdr 0.9.5 run in the session: its start showed the focused pane's folder, a cd in the pane moved the panel, the focus moved to a pane in `/etc` and back moved it each time, and the client's end gave the panel back to bash's report, then `exec herdr` from that bash, with `HERDR_SOCKET_PATH` relative to its folder, was followed through the socket there; on macOS under `shot`, herdr 0.9.5 as the program and again by `exec` from `sh` with a relative socket, its pane's cd moved the panel; on Windows, `term::herdr` tests on the CI runner: the folder under `APPDATA` and `USERPROFILE`, the `herdr.exe` nearest under the shell, this test's name, arguments and variables read through the live process list and `sysinfo`, a round trip over a named pipe; no real herdr has run on Windows yet | Partial |
| Inside herdr, herdr's agent states are the session's records | T38 | `term::herdr` tests: each agent in a state herdr knows is its pane's record, its app, title and message from herdr's fields, unknown and new states passed by, and a snapshot without agents lists none. `term::status`: herdr's agents replace their own records alone, a program's `herdr/build` kept through an empty snapshot, a full one and herdr's leaving, since a program's id ends at a colon; the same agents change nothing, and the next snapshot puts them back after a full reset | Pass |
| A row shows its session's status, hover each record; a hidden session that starts waiting marks its row | T39 | `term::status` tests: blocked, then error, working, done and idle show, the record reported last in its state speaks; an app comes from the nearest ancestor that names one; typing sees done and error, herdr's aside; each change of the state a row shows is noted, a wait after work in one output among them. `sessions` tests: hover lists each record's state, app, title and message, ten at most, then a failed command's status; a program's text shows on one line without direction controls or invisible formatting, joiners kept, cut at 120 characters. In the app on macOS, `shot` showed each mark: the ring at 40%, the still loader, a shield over three records whose hover listed each, the root's app inherited, a check, a cross, a hollow circle, an alert after `133;D;1`, a hidden session's question mark with its title strong, and a done record gone after a typed key; after review, a hidden session that waited, worked and waited again in one output set its title strong, an open hover followed a record replaced under it and work from 20% to 80%, ten long records scrolled to the last within the window, and a selected row at 0% in Ely Dark showed its loader; after round 2, herdr's records replaced in the same state showed at once in an open hover, and a hidden session that waited and worked again in one output set its title strong | Pass |
| System notifications on macOS, Windows and Linux: a session that starts waiting, finishes or fails, and OSC 9, 777 and 99, while the window is inactive or the session hidden, one a session every 3 s; a press shows the window and session | T40 | `term::reports` tests: a session posts once in 3 s, its own under one tag; a program's notifications go by their id and live until pressed, closed or expired, an expiry taking only its own post and none a later post holds; a session keeps its last 64; the text shows as text, and as markup's text on Linux; a session posts unless it is the one shown in an active window; waiting, done and error post, work and rest do not; a tag names the session that posted it, by itself, an id or a count. `term::notify`: the query, on Linux without close and `w`, alive, untracked and activation answers. In the app on macOS, outside a bundle, where gpui posts nothing and the log names each post: an OSC 9 posted and an OSC 777 within 3 s was dropped; an OSC 99 with `c=1` and `w=800` posted, its program heard `p=close;untracked`, alive listed it, and once it expired alive listed none; a closed one left alive empty; the query's answer came back; a command's end without its C posted nothing, and a failed one after its C posted; a notice, alive and DA1 in one write came back as alive naming it, then DA1's answer; a notice set to expire whose session ended expired after it; a hidden session that waited and worked again in one output posted its wait; and a notice sent while the window was active and the session shown posted nothing, the next, once the window lost focus, posted. No system showed one yet: on macOS gpui posts only from the app bundle, which asks the owner's leave first, and Windows and Linux did not run it; a press needs the system's notification center, so the owner posts and presses one on each | Partial |
| System notifications on macOS, Windows and Linux: a session that starts waiting, finishes or fails, and OSC 9, 777 and 99, while the window is inactive or the session hidden, one a session every 3 s; a press shows the window and session | T40 | `term::reports` tests: a session posts once in 3 s, its own under one tag; a program's notifications go by their id and live until pressed, closed or expired, an expiry taking only its own post and none a later post holds; a session keeps its last 64; the text shows as text, and as markup's text on Linux; a session posts unless it is the one shown in an active window; waiting, done and error post, work and rest do not; a tag names the session that posted it, by itself, an id or a count. `term::titles`: OSC 0 and 2 set the title as alacritty joins it, CSI 22 t and 23 t save it and bring it back, a full reset empties both. `term::tap`: the scanner finds CSI 22 t and 23 t as vte reads them. `term::pty`: titles, notifications and full resets reach the session in the output's order, one write and a write a sequence alike. `term::notify`: the query, on Linux without close and `w`, alive, untracked and activation answers. In the app on macOS, outside a bundle, where gpui posts nothing and the log names each post: an OSC 9 posted and an OSC 777 within 3 s was dropped; an OSC 99 with `c=1` and `w=800` posted, its program heard `p=close;untracked`, alive listed it, and once it expired alive listed none; a closed one left alive empty; the query's answer came back; a command's end without its C posted nothing, and a failed one after its C posted; a notice after a title set, or after a full reset, in the same write named the session as split writes do; a notice, alive and DA1 in one write came back as alive naming it, then DA1's answer; a notice set to expire whose session ended expired after it; a hidden session that waited and worked again in one output posted its wait; and a notice sent while the window was active and the session shown posted nothing, the next, once the window lost focus, posted. No system showed one yet: on macOS gpui posts only from the app bundle, which asks the owner's leave first, and Windows and Linux did not run it; a press needs the system's notification center, so the owner posts and presses one on each | Partial |
| Colors, styles, five underlines, wide characters, combining marks | T06 | `term::frame`, `term::ink`, `term::paint` tests | Pass |
| Box drawing, blocks and powerline shapes as geometry | T07 | `term::boxes` tests | Pass |
| JetBrains Mono, any installed font, Nerd Font icons | T06, T17 | `tests/fonts.rs` | Pass |