T37: programs ask for notifications through OSC 9, 777 and 99, read as Ghostty and kitty read them, OSC 99's chunks gathered by id with base64, options, close, query and alive, and the workspace hears each; OSC 9;4's progress stands as the root record until a program reports through OSC 7501, and again after a full reset
This commit is contained in:
12 files changed
+680
-15
No files matched your search
@@ -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, a command's end, the program's exit. `src/sessions.rs`: a session and its row. `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, OSC 133, OSC 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; `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 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 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; `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 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).
|
||||
@@ -99,3 +99,4 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
|
||||
- 2026-10-16: prompts and commands carry OSC 133 marks, which the owner asked for, read as Ghostty reads them: A or N starts a prompt, B or I ends it, C starts a command's output, and D ends the command with its status, D's first option, none when that is no number. The tap reads any program's marks before the parser, so it never sees the cursor: A's fresh line and L, which need it, are not done, and P adds nothing to A. Each session keeps the status its last command ended with. The integration marks what the shell leaves unmarked, and the user's prompt still sees the command's status. zsh: precmd reads `$?` and sends D after a command ran, preexec sends C, and a last precmd hook wraps PS1 in A and B under `prompt_percent`, which the first prompt says while it is off; it moves to the end of `precmd_functions` at each prompt, since a theme that sets PS1 in a hook of its own loads after it, and at the first prompt `sched +0` wraps it once more, since zsh runs due events after every precmd hook; that call returns the command's status, which the prompt shows, but it would reset `$pipestatus`, so it serves the first prompt alone. bash: PROMPT_COMMAND's first entry reads `$?`, which each entry resets, and its last wraps PS1. From bash 4.4 PS0 sends C and its subscript notes that a command ran, since bash prints no PS0 for an empty line, and the first entry sends D for it; both need `promptvars`, on by default. macOS's bash 3.2 has no PS0 and nothing else between reading a line and running it: its DEBUG trap runs for a key binding's command (`bind -x`) as for a typed one, with nothing to tell the two apart, runs for no line that is only a subshell, and under `extdebug` its status decides whether a command runs. So bash 3.2 sends no C, and Ghostty's automatic integration skips bash 3.2. Its D comes from PS1 once `\#`, the command number, has moved, which neither a key binding nor an empty line does; readline sends a prompt's escapes again at each redraw, so that D goes again with it, the same status. Every mark of a command rides an expansion, which `promptvars` turns on, so while it is off at start the script says so rather than mark nothing without a word. fish 4.3 marks its own prompts and commands, fish 4.0 all but B, older ones none: the script adds what the running fish lacks, around the prompt shown, and wraps it again when the user's changes. PowerShell: the prompt reads `$?` first and sets it back for the user's prompt through `Write-Error '' -ErrorAction Ignore`, which adds nothing to `$Error`. A failure ends with `$LASTEXITCODE` when a program set it during the line, since each run stores a new object, equal or not, which `[object]::ReferenceEquals` tells apart, unless the line surely ends in PowerShell: its syntax tree, read as PSReadLine hands the line over, ends in a cmdlet, as `Get-Command` resolves the name, aliases through, in a name not found, or in no command; such a line ends with 1, a cmdlet's failure under `-ErrorAction Ignore` too. A line that ends in a block, a function, a script or a call through a variable takes the code of a program it ran: only a hook on every command lookup would name the last command run, and it slowed a loop of commands by half. `$LASTEXITCODE` is read through `Get-Variable`, since strict mode refuses an unset variable and a prompt that fails falls back to `PS>`. C goes out when PSReadLine hands over a line that is not blank; without PSReadLine the script says it marks no command. herdr passes no OSC 133 on, so a session running herdr keeps the status from before it.
|
||||
- 2026-10-16: the view jumps between prompts, which the owner asked for, as Ghostty jumps: Cmd-Up and Cmd-Down, with Shift or without, on macOS, Ctrl-Shift-Up and Ctrl-Shift-Down elsewhere, scroll to the prompt that starts before or after the view's top; a prompt on the screen's last page means the bottom, which the view cannot pass, and with none there nothing moves. alacritty's rows hold no marks, and the tap, which reads before the parser, never sees the cursor, so the tap rewrites what goes to the parser: an OSC that may be a mark is held whole until it ends and read as `marks` reads it, and a prompt's start becomes an OSC 8 link of the terminal's own, its target `\x7f`, which no program names, its id `\x7f` and the prompt's number; the prompt's end closes it. The parser stamps each cell the prompt prints with the link, SGR 0 keeps it, and it rides the cells through scroll, reflow and history; a prompt starts on the first row that shows its number, blank rows within it passed. A program's own link keeps its target and takes the prompt's number in its id: one opened inside a prompt carries it, and a prompt that starts inside one opens it again with the number and gives back its own id at the prompt's end, its target kept whole as the scanner keeps it; its end inside a prompt takes the prompt's link up again. A cursor restore that brings back a link passes the rewrite by. An OSC held, a body of 4096 bytes at most as the scanner keeps it and twice that with the controls vte skips, shows nothing until it ends; controls vte runs inside an escape go on before an ESC that waits, and only an ESC from ground waits: one that ends an OSC goes on with it, so what follows runs where the parser stands. A rewrite turns a mark of 8 bytes inside a program's link into that link again, its target up to 4096 bytes, so the tap reads about 2 KB of the buffer's room at a time, which moved 3 MB through a pty as fast as larger reads. Links, Cmd-press among them, pass over the prompt's link. The tap's OSC scanner ends a body as vte does, at BEL, ESC, CAN or SUB, and skips the controls vte skips, so a mark and its link agree.
|
||||
- 2026-10-17: programs say what they are doing through OSC 7501, revision 0.4 of the Program Status Protocol, which the owner asked for, read as the spec reads it: pairs `key=value` joined by `:`, a malformed pair skipped, an unknown key passed by, and every pair of a known key checked, a clear's too, before the last of each key is kept. A sequence over 4096 bytes, counted from OSC through ST with the controls its body skips, a key over 16, an app over 32, an id outside its grammar or limits (128 bytes, 32 a segment, 8 levels), a title or msg over its limits, not base64, not UTF-8 or holding a control character, and a missing or unknown state each drop it whole; kind counts only with blocked, and progress, 0 to 100, only with working and blocked, each absent when unknown. Each terminal keeps its records by id, the root's empty: a report replaces its record whole, clear drops the record and those under it, or all without an id, and past 256 the record updated longest ago goes. A new prompt drops working, blocked and idle records, as kitty does, and keeps done and error; a full reset, which the tap's scanner sees as `ESC c`, through the controls, DEL and high bytes vte passes by in an escape, drops all, and DECSTR nothing. The query `OSC 7501 ; ?` is answered with every state and kind; the view writes the answer when the session hears the query, ahead of the parser's answers to what came later in the same read, so a DA1 sent after the query, as the spec suggests, comes back second, and one sent before it in the same read comes back after it.
|
||||
- 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; `e=1` reads the payload as base64; a payload holds 2048 bytes plain or 4096 encoded at most, UTF-8 without a control character; `p` takes title, body, close, ? and alive, and other payloads pass by; `o`, `a`, `c` and `w` are kept for showing; a lone body is the title, and one with neither passes by. The query is answered with what is read so far, `p=title,body,?,alive:o=always`, and alive with no id, since none shows yet; 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. Notifications go to the workspace, which logs them.
|
||||
@@ -10,6 +10,7 @@ mod links;
|
||||
pub mod mac;
|
||||
mod marks;
|
||||
pub mod mouse;
|
||||
mod notify;
|
||||
mod paint;
|
||||
mod pointer;
|
||||
mod prompts;
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
use base64::Engine;
|
||||
|
||||
use super::status::BASE64;
|
||||
|
||||
/// When a notification may show: OSC 99's `o`.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Occasion {
|
||||
#[default]
|
||||
Always,
|
||||
Unfocused,
|
||||
Invisible,
|
||||
}
|
||||
|
||||
/// A notification a program asks for: OSC 9, 777 or 99.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Notice {
|
||||
/// OSC 99's identifier, which updates, closes and answers name; none for OSC 9, 777 and OSC 99 without one.
|
||||
pub id: Option<String>,
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
pub occasion: Occasion,
|
||||
/// A press shows the window and session (`a=focus`, the default), and tells the program (`a=report`).
|
||||
pub focus: bool,
|
||||
pub report: bool,
|
||||
/// The program hears of its close: `c=1`.
|
||||
pub close: bool,
|
||||
/// Milliseconds it lasts: `w` over 0.
|
||||
pub expire: Option<u32>,
|
||||
}
|
||||
|
||||
/// Where a program's work stands, OSC 9;4.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Progress {
|
||||
Remove,
|
||||
Set(Option<u8>),
|
||||
Error(Option<u8>),
|
||||
Indeterminate,
|
||||
Pause(Option<u8>),
|
||||
}
|
||||
|
||||
/// What a program asks of notifications and progress.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Ask {
|
||||
Show(Notice),
|
||||
/// Closes the notification this id names: OSC 99's `p=close`.
|
||||
Close(String),
|
||||
/// Which notifications are alive; the answer names this id: `p=alive`.
|
||||
Alive(String),
|
||||
/// What the terminal does with OSC 99; the answer names this id: `p=?`.
|
||||
Query(String),
|
||||
Progress(Progress),
|
||||
}
|
||||
|
||||
/// The ask in an OSC 9 body past `9;`: ConEmu's commands, 9;4 among them, else iTerm2's notification, as Ghostty reads them.
|
||||
pub fn nine(body: &[u8]) -> Option<Ask> {
|
||||
let semi = |at: usize| body.get(at) == Some(&b';');
|
||||
let conemu = match body {
|
||||
[] => false,
|
||||
[b'1', b';', ..] | [b'1', b'0'] => true,
|
||||
[b'1', b'0', b';', b'0'..=b'3', ..] | [b'1', b'1', b';', ..] | [b'1', b'2', ..] => true,
|
||||
[b'4', b';', state @ b'0'..=b'4', ..] => {
|
||||
return Some(Ask::Progress(progress(*state, &body[3..])));
|
||||
}
|
||||
[b'5', ..] => true,
|
||||
[b'2' | b'3' | b'6' | b'7' | b'8' | b'9', ..] => semi(1),
|
||||
_ => false,
|
||||
};
|
||||
if conemu {
|
||||
log::debug!(
|
||||
"pty: OSC 9;{} is ConEmu's, no notification",
|
||||
String::from_utf8_lossy(body)
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let text = safe(body, "OSC 9")?;
|
||||
(!text.is_empty()).then(|| Ask::Show(plain(text, String::new())))
|
||||
}
|
||||
|
||||
/// OSC 9;4's state and the progress after it, clamped to 100.
|
||||
fn progress(state: u8, rest: &[u8]) -> Progress {
|
||||
let value = rest
|
||||
.strip_prefix(b";")
|
||||
.and_then(|digits| std::str::from_utf8(digits).ok())
|
||||
.filter(|digits| !digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit()))
|
||||
.and_then(|digits| digits.parse::<u64>().ok())
|
||||
.map(|value| value.min(100) as u8);
|
||||
match state {
|
||||
b'0' => Progress::Remove,
|
||||
// Set alone means 0, as in Ghostty; a value that does not read means none.
|
||||
b'1' if rest.is_empty() => Progress::Set(Some(0)),
|
||||
b'1' => Progress::Set(value),
|
||||
b'2' => Progress::Error(value),
|
||||
b'3' => Progress::Indeterminate,
|
||||
_ => Progress::Pause(value),
|
||||
}
|
||||
}
|
||||
|
||||
/// The notification in an OSC 777 body past `777;`: rxvt's `notify;title;body`.
|
||||
pub fn rxvt(body: &[u8]) -> Option<Ask> {
|
||||
let Some(rest) = body.strip_prefix(b"notify;") else {
|
||||
log::warn!(
|
||||
"pty: OSC 777;{} is no rxvt notify",
|
||||
String::from_utf8_lossy(body)
|
||||
);
|
||||
return None;
|
||||
};
|
||||
let Some(at) = rest.iter().position(|&byte| byte == b';') else {
|
||||
log::warn!("pty: OSC 777 notify without a title");
|
||||
return None;
|
||||
};
|
||||
let (title, body) = (
|
||||
safe(&rest[..at], "OSC 777")?,
|
||||
safe(&rest[at + 1..], "OSC 777")?,
|
||||
);
|
||||
(!title.is_empty() || !body.is_empty()).then(|| Ask::Show(plain(title, body)))
|
||||
}
|
||||
|
||||
/// A notification with no options, the body standing in for a missing title.
|
||||
fn plain(title: String, body: String) -> Notice {
|
||||
Notice {
|
||||
id: None,
|
||||
title,
|
||||
body,
|
||||
occasion: Occasion::Always,
|
||||
focus: true,
|
||||
report: false,
|
||||
close: false,
|
||||
expire: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Text with no control character, else none, logged with `what` sent it.
|
||||
fn safe(bytes: &[u8], what: &str) -> Option<String> {
|
||||
let text = String::from_utf8(bytes.to_vec())
|
||||
.inspect_err(|_| log::warn!("pty: {what} text is not UTF-8"))
|
||||
.ok()?;
|
||||
if text.chars().any(char::is_control) {
|
||||
log::warn!("pty: {what} text holds a control character");
|
||||
return None;
|
||||
}
|
||||
Some(text)
|
||||
}
|
||||
|
||||
/// The answer to OSC 99's query, naming `id`: what this terminal reads of notifications.
|
||||
pub fn answer(id: &str) -> String {
|
||||
format!("\x1b]99;i={id}:p=?;p=title,body,?,alive:o=always\x1b\\")
|
||||
}
|
||||
|
||||
/// The answer to OSC 99's `p=alive`, naming `id`: none, as no notification shows yet.
|
||||
pub fn alive(id: &str) -> String {
|
||||
format!("\x1b]99;i={id}:p=alive;\x1b\\")
|
||||
}
|
||||
|
||||
/// An OSC 99 payload's most bytes: plain, and base64.
|
||||
const PLAIN: usize = 2048;
|
||||
const ENCODED: usize = 4096;
|
||||
|
||||
/// The most text one notification gathers from its chunks, and how many gather at once.
|
||||
const GATHERED: usize = 16384;
|
||||
const PENDING: usize = 32;
|
||||
|
||||
/// OSC 99 notifications gathered from their chunks, by id.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Kitty {
|
||||
pending: Vec<(String, Notice)>,
|
||||
}
|
||||
|
||||
impl Kitty {
|
||||
/// The ask an OSC 99 body past `99;` completes, logged with why when there is none.
|
||||
pub fn read(&mut self, body: &[u8]) -> Option<Ask> {
|
||||
self.parse(body)
|
||||
.inspect_err(|why| log::warn!("pty: OSC 99 dropped: {why}"))
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
fn parse(&mut self, body: &[u8]) -> Result<Option<Ask>, String> {
|
||||
let at = body
|
||||
.iter()
|
||||
.position(|&byte| byte == b';')
|
||||
.ok_or("no payload")?;
|
||||
let (metadata, payload) = (&body[..at], &body[at + 1..]);
|
||||
let mut options: Vec<(u8, &[u8])> = Vec::new();
|
||||
for pair in metadata
|
||||
.split(|&byte| byte == b':')
|
||||
.filter(|pair| !pair.is_empty())
|
||||
{
|
||||
match pair {
|
||||
[key, b'=', value @ ..] if key.is_ascii_alphabetic() => options.push((*key, value)),
|
||||
_ => log::debug!("pty: OSC 99 passes {} by", String::from_utf8_lossy(pair)),
|
||||
}
|
||||
}
|
||||
let get = |key: u8| {
|
||||
options
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|(name, _)| *name == key)
|
||||
.map(|(_, value)| *value)
|
||||
};
|
||||
let id = match get(b'i') {
|
||||
Some(id)
|
||||
if id
|
||||
.iter()
|
||||
.all(|&byte| byte.is_ascii_alphanumeric() || b"_-+.".contains(&byte)) =>
|
||||
{
|
||||
Some(String::from_utf8(id.to_vec()).expect("ASCII"))
|
||||
}
|
||||
Some(id) => {
|
||||
return Err(format!(
|
||||
"id {} holds a byte ids may not",
|
||||
String::from_utf8_lossy(id)
|
||||
));
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let encoded = get(b'e') == Some(b"1");
|
||||
let limit = if encoded { ENCODED } else { PLAIN };
|
||||
if payload.len() > limit {
|
||||
return Err(format!("a payload over {limit} bytes"));
|
||||
}
|
||||
let text = if encoded {
|
||||
let bytes = BASE64
|
||||
.decode(payload)
|
||||
.map_err(|error| format!("payload is no base64: {error}"))?;
|
||||
String::from_utf8(bytes).map_err(|_| "payload is not UTF-8")?
|
||||
} else {
|
||||
String::from_utf8(payload.to_vec()).map_err(|_| "payload is not UTF-8")?
|
||||
};
|
||||
if text.chars().any(char::is_control) {
|
||||
return Err("payload holds a control character".into());
|
||||
}
|
||||
// An answer names the asker's id, or 0 without one, as kitty's spec says.
|
||||
let named = || id.clone().unwrap_or_else(|| "0".into());
|
||||
match get(b'p').unwrap_or(b"title") {
|
||||
b"?" => return Ok(Some(Ask::Query(named()))),
|
||||
b"alive" => return Ok(Some(Ask::Alive(named()))),
|
||||
b"close" => return Ok(id.map(Ask::Close)),
|
||||
b"title" | b"body" => {}
|
||||
other => {
|
||||
log::debug!(
|
||||
"pty: OSC 99 payload {} passed by",
|
||||
String::from_utf8_lossy(other)
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
let key = id.clone().unwrap_or_default();
|
||||
let at = match self.pending.iter().position(|(name, _)| *name == key) {
|
||||
Some(at) => at,
|
||||
None if self.pending.len() == PENDING => {
|
||||
return Err(format!("{PENDING} notifications gathering already"));
|
||||
}
|
||||
None => {
|
||||
self.pending.push((
|
||||
key.clone(),
|
||||
Notice {
|
||||
id: id.clone(),
|
||||
..plain(String::new(), String::new())
|
||||
},
|
||||
));
|
||||
self.pending.len() - 1
|
||||
}
|
||||
};
|
||||
let notice = &mut self.pending[at].1;
|
||||
if get(b'p') == Some(b"body") {
|
||||
notice.body.push_str(&text);
|
||||
} else {
|
||||
notice.title.push_str(&text);
|
||||
}
|
||||
if notice.title.len() + notice.body.len() > GATHERED {
|
||||
self.pending.remove(at);
|
||||
return Err(format!("a notification over {GATHERED} bytes"));
|
||||
}
|
||||
if let Some(occasion) = get(b'o') {
|
||||
notice.occasion = match occasion {
|
||||
b"unfocused" => Occasion::Unfocused,
|
||||
b"invisible" => Occasion::Invisible,
|
||||
_ => Occasion::Always,
|
||||
};
|
||||
}
|
||||
if let Some(actions) = get(b'a') {
|
||||
for action in actions.split(|&byte| byte == b',') {
|
||||
let (on, name) = match action.strip_prefix(b"-") {
|
||||
Some(name) => (false, name),
|
||||
None => (true, action),
|
||||
};
|
||||
match name {
|
||||
b"focus" => notice.focus = on,
|
||||
b"report" => notice.report = on,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(close) = get(b'c') {
|
||||
notice.close = close == b"1";
|
||||
}
|
||||
if let Some(expire) = get(b'w') {
|
||||
notice.expire = std::str::from_utf8(expire)
|
||||
.ok()
|
||||
.and_then(|ms| ms.parse::<u32>().ok())
|
||||
.filter(|&ms| ms > 0);
|
||||
}
|
||||
if get(b'd') == Some(b"0") {
|
||||
return Ok(None);
|
||||
}
|
||||
let (_, mut notice) = self.pending.remove(at);
|
||||
if notice.title.is_empty() && notice.body.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
if notice.title.is_empty() {
|
||||
notice.title = std::mem::take(&mut notice.body);
|
||||
}
|
||||
Ok(Some(Ask::Show(notice)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,198 @@
|
||||
use super::*;
|
||||
|
||||
fn shown(ask: Option<Ask>) -> Notice {
|
||||
match ask {
|
||||
Some(Ask::Show(notice)) => notice,
|
||||
other => panic!("no notification: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_is_iterm2_s_notification_unless_it_is_conemu_s_command() {
|
||||
assert_eq!(shown(nine(b"Hello world")).title, "Hello world");
|
||||
for command in [
|
||||
&b"1;100"[..],
|
||||
b"10",
|
||||
b"10;2",
|
||||
b"11;note",
|
||||
b"12",
|
||||
b"2;box",
|
||||
b"3;",
|
||||
b"3;title",
|
||||
b"5",
|
||||
b"5x",
|
||||
b"6;macro",
|
||||
b"7;cmd",
|
||||
b"8;VAR",
|
||||
b"9;/path",
|
||||
] {
|
||||
assert_eq!(nine(command), None, "{}", String::from_utf8_lossy(command));
|
||||
}
|
||||
for text in [
|
||||
"1", "1a", "10x", "10;5", "13", "2", "2a", "4", "4;", "4;9", "x;y",
|
||||
] {
|
||||
assert_eq!(shown(nine(text.as_bytes())).title, text);
|
||||
}
|
||||
assert_eq!(nine(b""), None);
|
||||
assert_eq!(nine(b"a\xffb"), None, "not UTF-8");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_4_reads_progress_as_ghostty_does() {
|
||||
let progress = |body: &[u8]| match nine(body) {
|
||||
Some(Ask::Progress(progress)) => progress,
|
||||
other => panic!("no progress: {other:?}"),
|
||||
};
|
||||
assert_eq!(progress(b"4;0"), Progress::Remove);
|
||||
assert_eq!(progress(b"4;1"), Progress::Set(Some(0)));
|
||||
assert_eq!(progress(b"4;1;40"), Progress::Set(Some(40)));
|
||||
assert_eq!(progress(b"4;1;900"), Progress::Set(Some(100)));
|
||||
assert_eq!(progress(b"4;1;x"), Progress::Set(None));
|
||||
assert_eq!(
|
||||
progress(b"4;1;99999999999999999999999"),
|
||||
Progress::Set(None)
|
||||
);
|
||||
assert_eq!(progress(b"4;1;1_0"), Progress::Set(None));
|
||||
assert_eq!(progress(b"4;2"), Progress::Error(None));
|
||||
assert_eq!(progress(b"4;2;50"), Progress::Error(Some(50)));
|
||||
assert_eq!(progress(b"4;3;7"), Progress::Indeterminate);
|
||||
assert_eq!(progress(b"4;4;10"), Progress::Pause(Some(10)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_777_is_rxvt_s_notify() {
|
||||
let notice = shown(rxvt(b"notify;Title;Body;more"));
|
||||
assert_eq!(
|
||||
(notice.title.as_str(), notice.body.as_str()),
|
||||
("Title", "Body;more")
|
||||
);
|
||||
assert_eq!(rxvt(b"other;x;y"), None);
|
||||
assert_eq!(rxvt(b"notify;Title"), None, "no body");
|
||||
assert_eq!(rxvt(b"notify;;"), None, "nothing to say");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_99_gathers_chunks_by_id_and_reads_its_options() {
|
||||
let mut kitty = Kitty::default();
|
||||
assert_eq!(kitty.read(b"i=a:d=0;Hel"), None);
|
||||
assert_eq!(kitty.read(b"i=b:d=0:p=body;other"), None);
|
||||
assert_eq!(kitty.read(b"i=a:d=0:p=body;Wor"), None);
|
||||
let notice = shown(kitty.read(b"i=a:p=body:o=unfocused:a=report,-focus:c=1:w=5000;ld"));
|
||||
assert_eq!(
|
||||
(
|
||||
notice.id.as_deref(),
|
||||
notice.title.as_str(),
|
||||
notice.body.as_str()
|
||||
),
|
||||
(Some("a"), "Hel", "World")
|
||||
);
|
||||
assert_eq!(
|
||||
(
|
||||
notice.occasion,
|
||||
notice.focus,
|
||||
notice.report,
|
||||
notice.close,
|
||||
notice.expire
|
||||
),
|
||||
(Occasion::Unfocused, false, true, true, Some(5000))
|
||||
);
|
||||
let other = shown(kitty.read(b"i=b:p=body;!"));
|
||||
assert_eq!(
|
||||
(other.title.as_str(), other.body.as_str()),
|
||||
("other!", ""),
|
||||
"a lone body is the title"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_99_decodes_base64_and_takes_plain_text() {
|
||||
let mut kitty = Kitty::default();
|
||||
let notice = shown(kitty.read(b"e=1:p=body;SGVsbG8gd29ybGQ="));
|
||||
assert_eq!((notice.title.as_str(), notice.id), ("Hello world", None));
|
||||
let plain = shown(kitty.read(b";Hello world"));
|
||||
assert_eq!(
|
||||
(plain.title.as_str(), plain.focus, plain.report),
|
||||
("Hello world", true, false)
|
||||
);
|
||||
assert_eq!(
|
||||
kitty.read(b"bad:i=c;x").map(|_| ()),
|
||||
Some(()),
|
||||
"a malformed pair passes by"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_99_asks_to_close_and_what_is_alive_and_supported() {
|
||||
let mut kitty = Kitty::default();
|
||||
assert_eq!(kitty.read(b"i=x:p=close;"), Some(Ask::Close("x".into())));
|
||||
assert_eq!(kitty.read(b"p=close;"), None, "no id, no close");
|
||||
assert_eq!(kitty.read(b"i=q:p=?;"), Some(Ask::Query("q".into())));
|
||||
assert_eq!(kitty.read(b"p=?;"), Some(Ask::Query("0".into())));
|
||||
assert_eq!(kitty.read(b"i=y:p=alive;"), Some(Ask::Alive("y".into())));
|
||||
assert_eq!(
|
||||
kitty.read(b"p=icon;x"),
|
||||
None,
|
||||
"an unknown payload passes by"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_99_drops_what_breaks_its_rules() {
|
||||
let mut kitty = Kitty::default();
|
||||
let long = |length: usize, metadata: &str| {
|
||||
let mut body = format!("{metadata};").into_bytes();
|
||||
body.extend(std::iter::repeat_n(b'a', length));
|
||||
body
|
||||
};
|
||||
for body in [
|
||||
b"no payload".to_vec(),
|
||||
b"i=a b;x".to_vec(),
|
||||
b"i=a\"b;x".to_vec(),
|
||||
b"e=1;!!!".to_vec(),
|
||||
b"e=1;/w==".to_vec(),
|
||||
"x\u{85}y"
|
||||
.as_bytes()
|
||||
.iter()
|
||||
.copied()
|
||||
.fold(b";".to_vec(), |mut body, byte| {
|
||||
body.push(byte);
|
||||
body
|
||||
}),
|
||||
long(PLAIN + 1, ""),
|
||||
long(ENCODED + 4, "e=1"),
|
||||
] {
|
||||
assert_eq!(
|
||||
kitty.read(&body),
|
||||
None,
|
||||
"{}",
|
||||
String::from_utf8_lossy(&body)
|
||||
);
|
||||
}
|
||||
for at in 0..PENDING {
|
||||
assert_eq!(kitty.read(format!("i=p{at}:d=0;x").as_bytes()), None);
|
||||
}
|
||||
assert_eq!(kitty.read(b"i=over:d=0;x"), None);
|
||||
assert!(
|
||||
kitty.pending.iter().all(|(id, _)| id != "over"),
|
||||
"past the cap nothing gathers"
|
||||
);
|
||||
let mut big = Kitty::default();
|
||||
for _ in 0..GATHERED / PLAIN {
|
||||
assert_eq!(big.read(&long(PLAIN, "i=g:d=0")), None);
|
||||
}
|
||||
assert_eq!(
|
||||
big.read(&long(PLAIN, "i=g")),
|
||||
None,
|
||||
"over the gathered bound"
|
||||
);
|
||||
assert!(big.pending.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_answers_name_the_asker_s_id() {
|
||||
assert_eq!(
|
||||
answer("q"),
|
||||
"\x1b]99;i=q:p=?;p=title,body,?,alive:o=always\x1b\\"
|
||||
);
|
||||
assert_eq!(alive("0"), "\x1b]99;i=0:p=alive;\x1b\\");
|
||||
}
|
||||
+3
-1
@@ -11,7 +11,7 @@ use alacritty_terminal::{
|
||||
};
|
||||
use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender, unbounded};
|
||||
|
||||
use super::{marks::Mark, mouse::MouseMode, status::Status, tap::Tapped};
|
||||
use super::{marks::Mark, mouse::MouseMode, notify::Ask, status::Status, tap::Tapped};
|
||||
use crate::settings::{CursorStyle, Settings};
|
||||
|
||||
/// What a session hears from its pty.
|
||||
@@ -23,6 +23,8 @@ pub enum PtyEvent {
|
||||
Mark(Mark),
|
||||
/// A program's status, OSC 7501.
|
||||
Status(Status),
|
||||
/// A notification or progress, OSC 9, 777 or 99.
|
||||
Ask(Ask),
|
||||
/// A full reset, `ESC c`.
|
||||
Reset,
|
||||
}
|
||||
|
||||
+33
-5
@@ -5,13 +5,18 @@ use alacritty_terminal::{
|
||||
use futures::StreamExt;
|
||||
|
||||
use super::*;
|
||||
use crate::term::status::{Record, State};
|
||||
use crate::term::{
|
||||
notify::Ask,
|
||||
status::{Record, State},
|
||||
};
|
||||
|
||||
struct Ran {
|
||||
folders: Vec<PathBuf>,
|
||||
marks: Vec<Mark>,
|
||||
/// What programs said of their status, a full reset as none.
|
||||
statuses: Vec<Option<Status>>,
|
||||
/// Notifications and progress programs asked for.
|
||||
asks: Vec<Ask>,
|
||||
text: String,
|
||||
/// The first line's cells that carry a prompt's link.
|
||||
prompt: Vec<usize>,
|
||||
@@ -30,21 +35,22 @@ 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, success) = futures::executor::block_on(async {
|
||||
let (mut folders, mut marks, mut statuses, mut success) =
|
||||
(Vec::new(), Vec::new(), Vec::new(), false);
|
||||
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);
|
||||
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::Term(Event::ChildExit(status)) => success = status.success(),
|
||||
PtyEvent::Term(Event::Exit) => break,
|
||||
PtyEvent::Term(_) | PtyEvent::Mouse(_) => {}
|
||||
}
|
||||
}
|
||||
(folders, marks, statuses, success)
|
||||
(folders, marks, statuses, asks, success)
|
||||
});
|
||||
let term = pty.term.lock();
|
||||
let row = &term.grid()[Line(0)];
|
||||
@@ -58,6 +64,7 @@ fn run(script: &str, size: (usize, usize)) -> Ran {
|
||||
folders,
|
||||
marks,
|
||||
statuses,
|
||||
asks,
|
||||
text: text.trim_end().to_string(),
|
||||
prompt: prompt.collect(),
|
||||
success,
|
||||
@@ -119,6 +126,27 @@ fn osc_7501_and_a_full_reset_reach_the_session_in_order() {
|
||||
assert_eq!(ran.text, "x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notifications_and_progress_reach_the_session_in_order() {
|
||||
let ran = run(
|
||||
"printf '\\033]9;Hello\\007\\033]777;notify;T;B\\007\\033]99;i=a:d=0;Hel\\033\\\\\\033]99;i=a;lo\\033\\\\\\033]9;4;1;40\\007x'",
|
||||
(40, 4),
|
||||
);
|
||||
let titles: Vec<String> = ran
|
||||
.asks
|
||||
.iter()
|
||||
.map(|ask| match ask {
|
||||
Ask::Show(notice) => format!("{}/{}", notice.title, notice.body),
|
||||
other => format!("{other:?}"),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
titles,
|
||||
["Hello/", "T/B", "Hello/", "Progress(Set(Some(40)))"]
|
||||
);
|
||||
assert_eq!(ran.text, "x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_escape_of_controls_keeps_the_pty_reading() {
|
||||
let ran = run(
|
||||
|
||||
+44
-3
@@ -4,6 +4,8 @@ use base64::{
|
||||
engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig},
|
||||
};
|
||||
|
||||
use super::notify::Progress;
|
||||
|
||||
/// What a program says it is doing, OSC 7501.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum State {
|
||||
@@ -59,7 +61,7 @@ const LEVELS: usize = 8;
|
||||
pub const RECORDS: usize = 256;
|
||||
|
||||
/// Standard base64, its padding optional.
|
||||
const BASE64: GeneralPurpose = GeneralPurpose::new(
|
||||
pub(super) const BASE64: GeneralPurpose = GeneralPurpose::new(
|
||||
&STANDARD,
|
||||
GeneralPurposeConfig::new().with_decode_padding_mode(DecodePaddingMode::Indifferent),
|
||||
);
|
||||
@@ -191,11 +193,49 @@ fn text(value: &str, key: &str, encoded: usize, decoded: usize) -> Result<String
|
||||
pub struct Records {
|
||||
list: Vec<(String, Record, u64)>,
|
||||
clock: u64,
|
||||
/// A program has reported through OSC 7501 since the last full reset, so OSC 9;4 maps no more.
|
||||
reported: bool,
|
||||
}
|
||||
|
||||
impl Records {
|
||||
/// Replaces the record at `id` whole.
|
||||
/// A program's report: replaces the record at `id` whole.
|
||||
pub fn report(&mut self, id: String, record: Record) {
|
||||
self.reported = true;
|
||||
self.put(id, record);
|
||||
}
|
||||
|
||||
/// OSC 9;4 as the root record, until a program reports through OSC 7501.
|
||||
pub fn progress(&mut self, progress: Progress) {
|
||||
if self.reported {
|
||||
log::debug!("status: OSC 9;4 passed by, a program reports through OSC 7501");
|
||||
return;
|
||||
}
|
||||
let root = |state, progress| Record {
|
||||
state,
|
||||
kind: None,
|
||||
progress,
|
||||
app: None,
|
||||
title: None,
|
||||
msg: None,
|
||||
};
|
||||
match progress {
|
||||
Progress::Remove => self.list.retain(|(id, ..)| !id.is_empty()),
|
||||
// Paused work is work still, its progress kept.
|
||||
Progress::Set(value) | Progress::Pause(value) => {
|
||||
self.put(String::new(), root(State::Working, value))
|
||||
}
|
||||
Progress::Indeterminate => self.put(String::new(), root(State::Working, None)),
|
||||
Progress::Error(_) => self.put(String::new(), root(State::Error, None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A full reset: every record goes, and OSC 9;4 maps again.
|
||||
pub fn reset(&mut self) {
|
||||
self.list.clear();
|
||||
self.reported = false;
|
||||
}
|
||||
|
||||
fn put(&mut self, id: String, record: Record) {
|
||||
self.clock += 1;
|
||||
if let Some(entry) = self.list.iter_mut().find(|(at, ..)| *at == id) {
|
||||
*entry = (id, record, self.clock);
|
||||
@@ -211,8 +251,9 @@ impl Records {
|
||||
self.list.push((id, record, self.clock));
|
||||
}
|
||||
|
||||
/// Drops the record at `id` and every one under it; with none, all.
|
||||
/// A program's clear: drops the record at `id` and every one under it; with none, all.
|
||||
pub fn clear(&mut self, id: Option<&str>) {
|
||||
self.reported = true;
|
||||
match id {
|
||||
None => self.list.clear(),
|
||||
Some(id) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use crate::term::notify::Progress;
|
||||
|
||||
fn encoded(text: &[u8]) -> String {
|
||||
BASE64.encode(text)
|
||||
@@ -209,3 +210,36 @@ fn the_sequence_counts_from_osc_through_st() {
|
||||
assert_eq!(read(b"state=done", 4097), None);
|
||||
assert!(read(b"state=done", 4096).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_4_maps_to_the_root_record_until_a_program_reports() {
|
||||
let root = |records: &Records| {
|
||||
records
|
||||
.list
|
||||
.iter()
|
||||
.find(|(id, ..)| id.is_empty())
|
||||
.map(|(_, record, _)| (record.state, record.progress))
|
||||
};
|
||||
let mut records = Records::default();
|
||||
records.progress(Progress::Set(Some(40)));
|
||||
assert_eq!(root(&records), Some((State::Working, Some(40))));
|
||||
records.progress(Progress::Pause(Some(5)));
|
||||
assert_eq!(root(&records), Some((State::Working, Some(5))));
|
||||
records.progress(Progress::Indeterminate);
|
||||
assert_eq!(root(&records), Some((State::Working, None)));
|
||||
records.progress(Progress::Error(Some(9)));
|
||||
assert_eq!(root(&records), Some((State::Error, None)));
|
||||
records.progress(Progress::Remove);
|
||||
assert_eq!(root(&records), None);
|
||||
records.report("a".into(), record(State::Idle, None));
|
||||
records.progress(Progress::Set(Some(1)));
|
||||
assert_eq!(root(&records), None, "a program reports through OSC 7501");
|
||||
records.reset();
|
||||
assert!(records.list.is_empty());
|
||||
records.progress(Progress::Set(Some(2)));
|
||||
assert_eq!(
|
||||
root(&records),
|
||||
Some((State::Working, Some(2))),
|
||||
"a full reset maps again"
|
||||
);
|
||||
}
|
||||
+27
-4
@@ -10,6 +10,7 @@ use polling::{Event, PollMode, Poller};
|
||||
use super::{
|
||||
marks,
|
||||
mouse::MouseMode,
|
||||
notify::{self, Kitty},
|
||||
prompts::{self, Prompts},
|
||||
pty::PtyEvent,
|
||||
status,
|
||||
@@ -24,6 +25,7 @@ pub(crate) struct Tapped<P> {
|
||||
scanner: Osc,
|
||||
mouse: MouseModes,
|
||||
prompts: Prompts,
|
||||
kitty: Kitty,
|
||||
events: UnboundedSender<PtyEvent>,
|
||||
pid: u32,
|
||||
last: Option<PathBuf>,
|
||||
@@ -36,6 +38,7 @@ impl<P> Tapped<P> {
|
||||
scanner: Osc::default(),
|
||||
mouse: MouseModes::default(),
|
||||
prompts: Prompts::default(),
|
||||
kitty: Kitty::default(),
|
||||
events,
|
||||
pid,
|
||||
last: None,
|
||||
@@ -55,9 +58,9 @@ 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) = (&self.events, self.pid, &mut self.last);
|
||||
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));
|
||||
.feed(read, &mut |found| report(found, events, pid, last, kitty));
|
||||
if let Some(mode) = self.mouse.feed(read) {
|
||||
log::info!("pty {pid}: mouse {mode:?}");
|
||||
if events.unbounded_send(PtyEvent::Mouse(mode)).is_err() {
|
||||
@@ -70,8 +73,14 @@ 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 a folder.
|
||||
fn report(found: Found, events: &UnboundedSender<PtyEvent>, pid: u32, last: &mut Option<PathBuf>) {
|
||||
/// Sends the session what the scanner found: a full reset, 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,
|
||||
) {
|
||||
let event = match found {
|
||||
Found::Reset => {
|
||||
log::debug!("pty {pid}: a full reset");
|
||||
@@ -87,6 +96,9 @@ fn report(found: Found, events: &UnboundedSender<PtyEvent>, pid: u32, last: &mut
|
||||
{
|
||||
log::debug!("pty {pid}: {status:?}");
|
||||
PtyEvent::Status(status)
|
||||
} else if let Some(ask) = ask(body, kitty) {
|
||||
log::debug!("pty {pid}: {ask:?}");
|
||||
PtyEvent::Ask(ask)
|
||||
} else if let Some(folder) = body.strip_prefix(b"7;").and_then(folder) {
|
||||
if last.as_ref() == Some(&folder) {
|
||||
log::debug!("pty {pid}: folder {} again", folder.display());
|
||||
@@ -105,6 +117,17 @@ fn report(found: Found, events: &UnboundedSender<PtyEvent>, pid: u32, last: &mut
|
||||
}
|
||||
}
|
||||
|
||||
/// A notification or progress an OSC 99, 777 or 9 body asks for.
|
||||
fn ask(body: &[u8], kitty: &mut Kitty) -> Option<notify::Ask> {
|
||||
if let Some(rest) = body.strip_prefix(b"99;") {
|
||||
kitty.read(rest)
|
||||
} else if let Some(rest) = body.strip_prefix(b"777;") {
|
||||
notify::rxvt(rest)
|
||||
} else {
|
||||
body.strip_prefix(b"9;").and_then(notify::nine)
|
||||
}
|
||||
}
|
||||
|
||||
impl<P: EventedReadWrite> EventedReadWrite for Tapped<P> {
|
||||
type Reader = Self;
|
||||
type Writer = P::Writer;
|
||||
|
||||
+15
-1
@@ -24,6 +24,7 @@ use super::{
|
||||
input::{self, Find, NextPrompt, PreviousPrompt},
|
||||
marks::Mark,
|
||||
mouse::MouseMode,
|
||||
notify::{self, Ask},
|
||||
paint::{self, Look},
|
||||
pointer::Pointer,
|
||||
pty::{Events, Pty, PtyEvent, config, fit},
|
||||
@@ -52,6 +53,8 @@ pub enum TerminalEvent {
|
||||
Bell,
|
||||
/// A command the shell ran ended, with the status it gave.
|
||||
Finished(Option<i32>),
|
||||
/// A notification to show or close, OSC 9, 777 or 99.
|
||||
Notice(Ask),
|
||||
Exited(ExitStatus),
|
||||
}
|
||||
|
||||
@@ -230,7 +233,8 @@ impl TerminalView {
|
||||
PtyEvent::Mark(Mark::Prompt) => self.records.prompt(),
|
||||
PtyEvent::Mark(_) => {}
|
||||
PtyEvent::Status(status) => self.status(status),
|
||||
PtyEvent::Reset => self.records.clear(None),
|
||||
PtyEvent::Ask(ask) => self.ask(ask, cx),
|
||||
PtyEvent::Reset => self.records.reset(),
|
||||
}
|
||||
}
|
||||
// ponytail: a full search per output batch while find is open; search only new lines if it lags.
|
||||
@@ -249,6 +253,16 @@ impl TerminalView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps progress, answers queries, and hands notifications on.
|
||||
fn ask(&mut self, ask: Ask, cx: &mut Context<Self>) {
|
||||
match ask {
|
||||
Ask::Progress(progress) => self.records.progress(progress),
|
||||
Ask::Query(id) => self.pty.write(notify::answer(&id).into_bytes()),
|
||||
Ask::Alive(id) => self.pty.write(notify::alive(&id).into_bytes()),
|
||||
ask @ (Ask::Show(_) | Ask::Close(_)) => cx.emit(TerminalEvent::Notice(ask)),
|
||||
}
|
||||
}
|
||||
|
||||
fn term_event(&mut self, event: Event, cx: &mut Context<Self>) {
|
||||
match event {
|
||||
Event::Wakeup => self.shown = true,
|
||||
|
||||
@@ -46,6 +46,10 @@ impl Workspace {
|
||||
}
|
||||
session.status = *status;
|
||||
}
|
||||
TerminalEvent::Notice(ask) => {
|
||||
let pid = session.terminal.read(cx).pid();
|
||||
log::info!("app: session {pid} asks {ask:?}");
|
||||
}
|
||||
TerminalEvent::Exited(status) => {
|
||||
let pid = session.terminal.read(cx).pid();
|
||||
log::info!("app: session {pid}'s program ended, {status}");
|
||||
|
||||
@@ -54,6 +54,7 @@ What no check reached: no real KWin drew the blur; of Wayland desktops, only Hyp
|
||||
| Prompt marks, OSC 133, from the shell integration and any program; each session keeps its last command's status | T34 | `term::marks` tests: each letter and option as Ghostty reads them; `term::tap` and `term::pty` tests: marks split across reads reach the session in order; `term::shell` tests: zsh, bash, fish and PowerShell send C, D;1, A and B around a failing command, and the user's prompt still shows the failure, a zsh theme's that sets PS1 in its own hook too, whose first prompt is marked before any command, and `$pipestatus` stays; bash's lone subshell ends with D;7, `$_` stays, a command runs under `extdebug` after a failure, a key binding marks nothing, the user's DEBUG trap runs, bash 3.2 sends no C, and bash says at start why it marks nothing while `promptvars` is off; PowerShell reads 1 for a cmdlet's failure under `-ErrorAction Ignore` after a success, a native code twice, a native failure's code under `$PSNativeCommandUseErrorActionPreference`, on a line that mixes a program and a cmdlet the cmdlet's 1, and through a call by variable the program's code; on macOS fish 4.9 marks itself and bash 3.2 marks no C, in the Linux container fish 4.0 gets B and bash 5.2 marks C through PS0, CI's Ubuntu runs fish 3.7, which the script marks whole; by hand in a pty, PowerShell under strict mode, and a cmdlet's failure after a native one's reads 1 | Pass |
|
||||
| Jump to the prompt before or after the view's top, as Ghostty jumps | T35 | `term::prompts` tests: a mark becomes its prompt's numbered link, read as the session reads marks, whole when split across reads at every byte, and other sequences, `133;AB` among them, pass as they came; a prompt's cells carry its link through SGR 0 and a resize, its input none; a program's own link and the prompt's keep each other, a program's link inside a prompt carries the prompt's number, a prompt inside a link left open too, a target of 264 bytes in either order; controls after an OSC's closing ESC keep their line; a long escape of controls keeps the pty reading; a prompt over rows starts at its first, blank rows within it too; up and down land as Ghostty's `scrollPrompt`; `term::links`: a prompt's link is no target; `term::pty`: through the event loop the prompt's cell alone carries it; in the app on macOS, Cmd-Up once and twice put PROMPT-3 and PROMPT-2 at the top, and Cmd-Shift-Down and Cmd-Down came back to the screen's bottom; the review's fixtures of a prompt that starts with a link and of a prompt with a blank row jumped to PROMPT-3-FIRST, and its fixture of `133;AB` marks found no prompt; the second review's fixtures of a prompt all in a link and of a linked first row jumped to PROMPT-3-FIRST. The keys off macOS are bound, not run | Pass |
|
||||
| Program status, OSC 7501 revision 0.4: reports checked as the spec checks them, records per terminal, a new prompt and a full reset, the support query | T36 | `term::status` tests: the spec's examples read as it means them; a malformed pair is skipped, the last of a key wins, unknown keys pass; a sequence over any limit, with bad base64, UTF-8 or a control character, or without a known state drops whole, a clear too, and an earlier pair of a key counts though the last wins; the sequence's length counts the controls its body skips; kind and progress count only with their states; records replace whole, clear with those under them, and past 256 the one updated longest ago goes; a new prompt keeps done and error alone. `term::tap`: a full reset found between bodies, and through DEL and high bytes in its escape. `term::pty`: a report, a query and a full reset reach the session in order through the event loop. In the app on macOS, a program that sent the query and then DA1 read the answer with every state and kind, then DA1's | Pass |
|
||||
| Progress, OSC 9;4, as the root record until an OSC 7501 report; notifications, OSC 9, 777 and 99, read | T37 | `term::notify` tests: OSC 9 reads as Ghostty reads it, ConEmu's commands no notifications, 9;4's progress clamped; OSC 777's notify; OSC 99 gathers chunks by id and reads base64, options, close, query and alive, and drops what breaks its rules; its answers name the asker's id. `term::status`: 9;4 maps to the root record until a report, and again after a full reset. `term::pty`: OSC 9, 777, chunked 99 and 9;4 reach the session in order. In the app on macOS, a program read OSC 99's query and alive answers before DA1's, and four notifications reached the workspace's log | Pass |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
Reference in new issue
Block a user