T40: a press whose record went, a twin Linux stacked or one past the 64 kept, still shows its session; on Linux the body goes as markup's text; only a command C started posts its end, so a prompt bash 3.2 draws again posts nothing; the posting conditions are pure and tested; the decision says what macOS, Wayland and gpui's log do at a press and outside the bundle
CI / platforms (ubuntu-24.04) (push) Successful in 1m29s
CI / platforms (windows-latest) (push) Successful in 1m34s
CI / macos (push) Successful in 14m7s

This commit is contained in:
ZacharyZhang-NY committed 2026-10-17 15:51:46 -04:00
1 parent efda50f142
commit 52576bb8ee
5 files changed
+98 -11

No files matched your search

+1 -1
View File
@@ -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 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 ends, `Command finished`, or `Command exited with N`; 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 ;`. 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.
- 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.
+52 -9
View File
@@ -143,11 +143,7 @@ impl TerminalView {
/// Posts the state its row shows when it turned to waiting, done or error.
fn tell_state(&mut self, cx: &mut Context<Self>) {
let body = match self.records.shown() {
Some((id, record))
if matches!(record.state, State::Blocked | State::Done | State::Error) =>
{
line(&self.records, id, record)
}
Some((id, record)) if tells(record.state) => line(&self.records, id, record),
_ => return,
};
self.post(body, None, cx);
@@ -160,13 +156,21 @@ impl TerminalView {
log::info!("terminal {}: its last command ended {ended}", self.pty.pid);
}
self.ended = status;
self.post(finished(status), None, cx);
// Only a command C started posts its end, so a prompt bash 3.2 draws again posts nothing.
if std::mem::take(&mut self.ran) {
self.post(finished(status), None, cx);
} else {
log::debug!(
"terminal {}: an end without its start posts nothing",
self.pty.pid
);
}
}
/// Posts `body` while the window is not active or the session hidden, one every 3 seconds at most.
fn post(&mut self, body: String, asked: Option<&Notice>, cx: &mut Context<Self>) {
let pid = self.pty.pid;
if self.front && self.active {
if !unseen(self.front, self.active) {
log::debug!("terminal {pid}: in view, so it posts no {body:?}");
return;
}
@@ -181,6 +185,12 @@ impl TerminalView {
&self.title
});
log::info!("terminal {pid}: posts {}: {title:?}, {body:?}", posted.tag);
// Linux's notification daemons read the body as markup.
let body = if cfg!(target_os = "linux") {
escape(&body)
} else {
body
};
cx.show_system_notification(SystemNotification {
tag: posted.tag.clone(),
title: title.into(),
@@ -246,8 +256,18 @@ impl TerminalView {
/// A press on notification `tag`, if this session posted it: a program that asked hears of it; whether the session shows.
pub fn pressed(&mut self, tag: &str) -> Option<bool> {
let posted = self.notices.take(tag, None)?;
log::info!("terminal {}: notification {tag} pressed", self.pty.pid);
let pid = self.pty.pid;
let Some(posted) = self.notices.take(tag, None) else {
// A twin Linux stacked, pressed before, or one past the 64 kept, still shows its session.
let own = owns(pid, tag);
if own {
log::info!(
"terminal {pid}: notification {tag} pressed, its record gone, so it tells no program"
);
}
return own.then_some(true);
};
log::info!("terminal {pid}: notification {tag} pressed");
if posted.report {
let id = posted.id.as_deref().unwrap_or("0");
self.pty.write(notify::activated(id).into_bytes());
@@ -256,6 +276,29 @@ impl TerminalView {
}
}
/// Whether a session posts: not while it is the session shown in an active window.
fn unseen(front: bool, active: bool) -> bool {
!(front && active)
}
/// Whether the row's turn to `state` posts: waiting, done and error do.
fn tells(state: State) -> bool {
matches!(state, State::Blocked | State::Done | State::Error)
}
/// Whether program `pid`'s session posted notification `tag`: its own, `PID`, else `PID:ID` or `PID#N`.
fn owns(pid: u32, tag: &str) -> bool {
tag.strip_prefix(&pid.to_string())
.is_some_and(|rest| rest.is_empty() || rest.starts_with([':', '#']))
}
/// `text` as markup reads it as text.
fn escape(text: &str) -> String {
text.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
/// What a command's end says: it finished, or the status it failed with.
fn finished(status: Option<i32>) -> String {
match status {
+40
View File
@@ -81,3 +81,43 @@ fn a_notification_says_its_text_as_text() {
assert_eq!(finished(None), "Command finished");
assert_eq!(finished(Some(2)), "Command exited with 2");
}
#[test]
fn a_session_posts_unless_it_is_the_one_shown_in_an_active_window() {
assert!(!unseen(true, true));
assert!(unseen(true, false), "the window inactive");
assert!(unseen(false, true), "the session hidden");
assert!(unseen(false, false));
}
#[test]
fn waiting_done_and_error_post_and_work_and_rest_do_not() {
for (state, posts) in [
(State::Blocked, true),
(State::Done, true),
(State::Error, true),
(State::Working, false),
(State::Idle, false),
] {
assert_eq!(tells(state), posts, "{state:?}");
}
}
#[test]
fn a_tag_names_the_session_that_posted_it() {
for tag in ["12", "12:build", "12#3"] {
assert!(owns(12, tag), "{tag}");
}
for tag in ["123", "1", "123:build", "13#3", ""] {
assert!(!owns(12, tag), "{tag}");
}
}
#[test]
fn a_body_goes_as_markup_s_text() {
assert_eq!(
escape("<b>a</b> & <a href=x>"),
"&lt;b&gt;a&lt;/b&gt; &amp; &lt;a href=x&gt;"
);
assert_eq!(escape("plain"), "plain");
}
+4
View File
@@ -77,6 +77,8 @@ pub struct TerminalView {
pub(super) records: Records,
/// The status its shell's last command ended with; none before one, or when it gave none.
pub(super) ended: Option<i32>,
/// A command started, as OSC 133 C says, since the last one ended.
pub(super) ran: bool,
/// The system notifications it posted.
pub(super) notices: Notices,
/// The program's title; empty while it names none.
@@ -134,6 +136,7 @@ impl TerminalView {
reports: 0,
records: Records::default(),
ended: None,
ran: false,
notices: Notices::default(),
title: String::new(),
front: true,
@@ -263,6 +266,7 @@ impl TerminalView {
self.records.prompt();
self.restate(cx);
}
PtyEvent::Mark(Mark::Output) => self.ran = true,
PtyEvent::Mark(_) => {}
PtyEvent::Status(status) => {
self.status(status);
+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. `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 that failed 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::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 |
| 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 |