T36: programs report their status through OSC 7501, revision 0.4, checked as the spec checks them; each terminal keeps their records by id, replaced whole, cleared with those under them, 256 at most; a new prompt drops working, blocked and idle, a full reset all; the support query is answered with every state and kind, ahead of a DA1 sent after it; the pty's tests move to their own file
This commit is contained in:
12 files changed
+755
-250
No files matched your search
@@ -7,7 +7,7 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
|
||||
- Rust 1.95.0, as `rust-toolchain.toml` pins it, edition 2024. One binary crate, `elyterminal`.
|
||||
- Ely (`ely-gpui-component`) from git.zacharyzhang.com, pinned by rev. gpui and gpui_platform from crates.io's `gpui-pre`, the exact version Ely pins, under their crate names through `package`.
|
||||
- `alacritty_terminal` 0.26: grid, parser, pseudo-terminal, event loop.
|
||||
- `serde`, `serde_json`: settings. `dirs`: the config folder. `log`, `env_logger`: logs to stderr. `unicode-normalization`, on macOS: Git's marks in composed form.
|
||||
- `serde`, `serde_json`: settings. `dirs`: the config folder. `base64`, the version alacritty pulls in for OSC 52: OSC 7501's text. `log`, `env_logger`: logs to stderr. `unicode-normalization`, on macOS: Git's marks in composed form.
|
||||
- On Linux, `wayland-client`, `wayland-backend` and `wayland-protocols` (staging) ask the compositor for `ext_background_effect_v1` on gpui's own connection; `wayland-server` hosts a compositor in the tests. `x11rb` sets `_KDE_NET_WM_BLUR_BEHIND_REGION`; its test runs Xvfb.
|
||||
- On Windows, `sysinfo` reads herdr's client's arguments and variables; `windows-sys`, the version the Windows build already holds, lists processes and waits on herdr's pipe.
|
||||
- `resvg`, a dev dependency at the version gpui uses, renders the icon's SVG for the macOS app and the Windows `.ico`. `embed-resource`, a build dependency at the version gpui uses, puts the icon into the Windows executable.
|
||||
@@ -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 and the mouse modes before the parser; `marks` reads OSC 133's prompt marks; `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, 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.
|
||||
- `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).
|
||||
@@ -98,3 +98,4 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
|
||||
- 2026-10-16: the sessions and right columns resize by a drag on their border toward the terminal, which the owner asked for on macOS, Windows and Linux. Ely's `ShellColumn::resizable`, added to Ely for this, puts Ely's `resize_handle` on the border toward the filling column, the sessions column's right and the right column's left: the handle draws that border, a filled hairline, since gpui paints a border no wider than half its box, and the line it drew at first showed half a pixel on macOS and none on X11; it occludes the header under it, so a press there drags the border, not the window, on Windows too, where an unoccluded drag region takes the press as the caption. A drag sets off from the wider of the width drawn and the one kept: a header's parts may hold a column wider, and a window too narrow for both widths squeezes them, where a drag from the squeezed width jumped back against the pointer; a drag that has not moved sideways changes nothing, and one that comes back where it set off gives the width it had. A set width in Ely became a flex basis, so a column holds its header's own parts: at a set width under them, Windows' caption buttons ran out of the window. The widths, 160 to 960 pixels, show at once and are saved in `sessions_width` and `panel_width` through `SettingsStore::update` 0.5 s after a drag rests, as the window's size is; a drag in the half second before the window closes is lost, and a save the store refuses puts the columns back. A window too narrow for both yields the terminal its header's width, as Ely's set widths give way.
|
||||
- 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, the last of a key kept. A sequence over 4096 bytes, 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`, 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.
|
||||
Generated
+1
@@ -1751,6 +1751,7 @@ version = "0.1.2"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"anyhow",
|
||||
"base64",
|
||||
"block",
|
||||
"core-graphics 0.24.0",
|
||||
"dirs 7.0.0",
|
||||
|
||||
@@ -11,6 +11,7 @@ publish = false
|
||||
[dependencies]
|
||||
alacritty_terminal = { version = "=0.26.0", default-features = false }
|
||||
anyhow = "1"
|
||||
base64 = "0.22"
|
||||
dirs = "7"
|
||||
ely-gpui-component = { git = "https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components.git", rev = "da3fdcaf39f2fa85ce1dd9cad07c3c81cf1dd721" }
|
||||
env_logger = "0.11"
|
||||
|
||||
@@ -15,6 +15,7 @@ mod pointer;
|
||||
mod prompts;
|
||||
mod pty;
|
||||
pub mod shell;
|
||||
mod status;
|
||||
mod tap;
|
||||
#[cfg(unix)]
|
||||
mod unix;
|
||||
|
||||
+6
-220
@@ -11,7 +11,7 @@ use alacritty_terminal::{
|
||||
};
|
||||
use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender, unbounded};
|
||||
|
||||
use super::{marks::Mark, mouse::MouseMode, tap::Tapped};
|
||||
use super::{marks::Mark, mouse::MouseMode, status::Status, tap::Tapped};
|
||||
use crate::settings::{CursorStyle, Settings};
|
||||
|
||||
/// What a session hears from its pty.
|
||||
@@ -21,6 +21,10 @@ pub enum PtyEvent {
|
||||
Folder(PathBuf),
|
||||
Mouse(MouseMode),
|
||||
Mark(Mark),
|
||||
/// A program's status, OSC 7501.
|
||||
Status(Status),
|
||||
/// A full reset, `ESC c`.
|
||||
Reset,
|
||||
}
|
||||
|
||||
/// Hands the grid's events to the session; `pid` names the program in logs.
|
||||
@@ -246,225 +250,7 @@ pub fn fit(columns: usize, lines: usize, cell: (u16, u16)) -> WindowSize {
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use alacritty_terminal::{
|
||||
grid::Dimensions,
|
||||
index::{Column, Line},
|
||||
};
|
||||
use futures::StreamExt;
|
||||
|
||||
use super::*;
|
||||
|
||||
struct Ran {
|
||||
folders: Vec<PathBuf>,
|
||||
marks: Vec<Mark>,
|
||||
text: String,
|
||||
/// The first line's cells that carry a prompt's link.
|
||||
prompt: Vec<usize>,
|
||||
success: bool,
|
||||
pid: u32,
|
||||
columns: usize,
|
||||
}
|
||||
|
||||
fn run(script: &str, size: (usize, usize)) -> Ran {
|
||||
crate::testlog::init();
|
||||
let launch = Launch {
|
||||
program: Some(("/bin/sh".into(), vec!["-c".into(), script.into()])),
|
||||
cwd: std::env::temp_dir(),
|
||||
env: vec![("ELY".into(), "ok".into())],
|
||||
reports: false,
|
||||
};
|
||||
let (pty, mut events) =
|
||||
spawn(&launch, size, (8, 16), config(&Settings::default())).expect("sh starts");
|
||||
let (folders, marks, success) = futures::executor::block_on(async {
|
||||
let (mut folders, mut marks, mut success) = (Vec::new(), Vec::new(), false);
|
||||
while let Some(event) = events.next().await {
|
||||
match event {
|
||||
PtyEvent::Folder(folder) => folders.push(folder),
|
||||
PtyEvent::Mark(mark) => marks.push(mark),
|
||||
PtyEvent::Term(Event::ChildExit(status)) => success = status.success(),
|
||||
PtyEvent::Term(Event::Exit) => break,
|
||||
PtyEvent::Term(_) | PtyEvent::Mouse(_) => {}
|
||||
}
|
||||
}
|
||||
(folders, marks, success)
|
||||
});
|
||||
let term = pty.term.lock();
|
||||
let row = &term.grid()[Line(0)];
|
||||
let text: String = (0..24).map(|column| row[Column(column)].c).collect();
|
||||
let prompt = (0..24).filter(|&column| {
|
||||
row[Column(column)]
|
||||
.hyperlink()
|
||||
.is_some_and(|link| link.uri() == crate::term::prompts::LINK)
|
||||
});
|
||||
Ran {
|
||||
folders,
|
||||
marks,
|
||||
text: text.trim_end().to_string(),
|
||||
prompt: prompt.collect(),
|
||||
success,
|
||||
pid: pty.pid,
|
||||
columns: term.columns(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_program_prints_into_the_grid_with_its_variables_and_id() {
|
||||
let ran = run("printf 'ely %s %s' \"$ELY\" $$", (40, 4));
|
||||
assert!(ran.folders.is_empty());
|
||||
assert_eq!(ran.text, format!("ely ok {}", ran.pid));
|
||||
assert!(ran.success);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_7_reaches_the_session_as_a_folder() {
|
||||
let ran = run(
|
||||
"printf '\\033]7;file:///tmp/a%%20b\\007x\\033]7;file://elsewhere.invalid/c\\033\\\\'",
|
||||
(40, 4),
|
||||
);
|
||||
assert_eq!(ran.folders, [PathBuf::from("/tmp/a b")]);
|
||||
assert_eq!(ran.text, "x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_reaches_the_session_as_marks() {
|
||||
let ran = run(
|
||||
"printf '\\033]133;A\\007>\\033]133;B\\007\\033]133;C\\033\\\\x\\033]133;D;3\\007'",
|
||||
(40, 4),
|
||||
);
|
||||
let marks = [Mark::Prompt, Mark::Input, Mark::Output, Mark::Done(Some(3))];
|
||||
assert_eq!(ran.marks, marks);
|
||||
assert_eq!(ran.text, ">x");
|
||||
assert_eq!(ran.prompt, [0], "the prompt's cell, not the output's");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_escape_of_controls_keeps_the_pty_reading() {
|
||||
let ran = run(
|
||||
"printf '\\033'; head -c 3000000 /dev/zero; printf '[0mREADY'",
|
||||
(40, 4),
|
||||
);
|
||||
assert_eq!(ran.text, "READY");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_foreground_is_the_program_s_own_group() {
|
||||
let launch = Launch {
|
||||
program: Some(("/bin/sh".into(), vec!["-c".into(), "exec sleep 30".into()])),
|
||||
cwd: std::env::temp_dir(),
|
||||
env: Vec::new(),
|
||||
reports: false,
|
||||
};
|
||||
let (pty, _events) =
|
||||
spawn(&launch, (20, 4), (8, 16), config(&Settings::default())).expect("sh starts");
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
while pty.foreground().ok() != Some(pty.pid as i32) {
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"{:?}",
|
||||
pty.foreground()
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_size_past_the_kernel_is_one_size_for_grid_and_program() {
|
||||
let ran = run("stty size", (9000, 8));
|
||||
assert_eq!(ran.columns, usize::from(u16::MAX / 8));
|
||||
assert_eq!(ran.text, format!("8 {}", u16::MAX / 8));
|
||||
}
|
||||
|
||||
/// Starts `script`, waits for it to print READY, closes it, and waits for the reap's log.
|
||||
fn close_after_ready(script: &str, reaped: &str) {
|
||||
crate::testlog::init();
|
||||
let launch = Launch {
|
||||
program: Some(("/bin/sh".into(), vec!["-c".into(), script.into()])),
|
||||
cwd: std::env::temp_dir(),
|
||||
env: Vec::new(),
|
||||
reports: false,
|
||||
};
|
||||
let (pty, mut events) =
|
||||
spawn(&launch, (20, 4), (8, 16), config(&Settings::default())).expect("sh starts");
|
||||
futures::executor::block_on(async {
|
||||
while let Some(event) = events.next().await {
|
||||
let term = pty.term.lock();
|
||||
let text: String = (0..5)
|
||||
.map(|column| term.grid()[Line(0)][Column(column)].c)
|
||||
.collect();
|
||||
drop(term);
|
||||
if matches!(event, PtyEvent::Term(Event::Wakeup)) && text == "READY" {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
let pid = pty.pid;
|
||||
drop(pty);
|
||||
let hung_up = format!("pty {pid}: hung up, {reaped}");
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
while !crate::testlog::logged(&hung_up) {
|
||||
assert!(std::time::Instant::now() < deadline, "no `{hung_up}`");
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
assert!(crate::testlog::logged(&format!("pty {pid}: closing")));
|
||||
let alive = unsafe { libc::kill(pid as libc::pid_t, 0) } == 0;
|
||||
assert!(!alive, "the program was reaped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_a_session_hangs_up_its_program() {
|
||||
close_after_ready("printf READY; exec cat", "signal: 1 (SIGHUP)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_program_that_ignores_sighup_ends_when_the_pty_closes() {
|
||||
// cat reads EOF on macOS and EIO on Linux; the shell's own exit holds on both.
|
||||
close_after_ready("trap '' HUP; printf READY; cat; exit 7", "exit status: 7");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_folder_stops_the_start() {
|
||||
let launch = Launch {
|
||||
program: None,
|
||||
cwd: "/nonexistent/ely".into(),
|
||||
env: Vec::new(),
|
||||
reports: false,
|
||||
};
|
||||
let error = spawn(&launch, (20, 4), (8, 16), config(&Settings::default()))
|
||||
.err()
|
||||
.expect("a missing folder is an error");
|
||||
assert!(error.to_string().contains("/nonexistent/ely"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_that_cannot_be_entered_stops_the_start() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let folder = std::env::temp_dir().join(format!("elyterminal-shut-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&folder).expect("a temp folder");
|
||||
std::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o600)).expect("600");
|
||||
let launch = Launch {
|
||||
program: Some(("/bin/sh".into(), vec!["-c".into(), "true".into()])),
|
||||
cwd: folder.clone(),
|
||||
env: Vec::new(),
|
||||
reports: false,
|
||||
};
|
||||
let started = spawn(&launch, (20, 4), (8, 16), config(&Settings::default()));
|
||||
std::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o700)).expect("700");
|
||||
std::fs::remove_dir(&folder).expect("the temp folder goes");
|
||||
let error = started.err().expect("a folder it cannot enter is an error");
|
||||
let error = error.to_string();
|
||||
assert!(error.contains("Permission denied"), "{error}");
|
||||
assert!(error.contains(&folder.display().to_string()), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sizes_hold_at_least_two_by_one_and_cap_at_the_kernel() {
|
||||
let size = fit(70_000, 24, (8, 16));
|
||||
assert_eq!((size.num_cols, size.num_lines), (u16::MAX / 8, 24));
|
||||
let size = fit(0, 0, (8, 16));
|
||||
assert_eq!((size.num_cols, size.num_lines), (2, 1));
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod scrollback {
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
use alacritty_terminal::{
|
||||
grid::Dimensions,
|
||||
index::{Column, Line},
|
||||
};
|
||||
use futures::StreamExt;
|
||||
|
||||
use super::*;
|
||||
use crate::term::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>>,
|
||||
text: String,
|
||||
/// The first line's cells that carry a prompt's link.
|
||||
prompt: Vec<usize>,
|
||||
success: bool,
|
||||
pid: u32,
|
||||
columns: usize,
|
||||
}
|
||||
|
||||
fn run(script: &str, size: (usize, usize)) -> Ran {
|
||||
crate::testlog::init();
|
||||
let launch = Launch {
|
||||
program: Some(("/bin/sh".into(), vec!["-c".into(), script.into()])),
|
||||
cwd: std::env::temp_dir(),
|
||||
env: vec![("ELY".into(), "ok".into())],
|
||||
reports: false,
|
||||
};
|
||||
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);
|
||||
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::Term(Event::ChildExit(status)) => success = status.success(),
|
||||
PtyEvent::Term(Event::Exit) => break,
|
||||
PtyEvent::Term(_) | PtyEvent::Mouse(_) => {}
|
||||
}
|
||||
}
|
||||
(folders, marks, statuses, success)
|
||||
});
|
||||
let term = pty.term.lock();
|
||||
let row = &term.grid()[Line(0)];
|
||||
let text: String = (0..24).map(|column| row[Column(column)].c).collect();
|
||||
let prompt = (0..24).filter(|&column| {
|
||||
row[Column(column)]
|
||||
.hyperlink()
|
||||
.is_some_and(|link| link.uri() == crate::term::prompts::LINK)
|
||||
});
|
||||
Ran {
|
||||
folders,
|
||||
marks,
|
||||
statuses,
|
||||
text: text.trim_end().to_string(),
|
||||
prompt: prompt.collect(),
|
||||
success,
|
||||
pid: pty.pid,
|
||||
columns: term.columns(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_program_prints_into_the_grid_with_its_variables_and_id() {
|
||||
let ran = run("printf 'ely %s %s' \"$ELY\" $$", (40, 4));
|
||||
assert!(ran.folders.is_empty());
|
||||
assert_eq!(ran.text, format!("ely ok {}", ran.pid));
|
||||
assert!(ran.success);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_7_reaches_the_session_as_a_folder() {
|
||||
let ran = run(
|
||||
"printf '\\033]7;file:///tmp/a%%20b\\007x\\033]7;file://elsewhere.invalid/c\\033\\\\'",
|
||||
(40, 4),
|
||||
);
|
||||
assert_eq!(ran.folders, [PathBuf::from("/tmp/a b")]);
|
||||
assert_eq!(ran.text, "x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_reaches_the_session_as_marks() {
|
||||
let ran = run(
|
||||
"printf '\\033]133;A\\007>\\033]133;B\\007\\033]133;C\\033\\\\x\\033]133;D;3\\007'",
|
||||
(40, 4),
|
||||
);
|
||||
let marks = [Mark::Prompt, Mark::Input, Mark::Output, Mark::Done(Some(3))];
|
||||
assert_eq!(ran.marks, marks);
|
||||
assert_eq!(ran.text, ">x");
|
||||
assert_eq!(ran.prompt, [0], "the prompt's cell, not the output's");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_7501_and_a_full_reset_reach_the_session_in_order() {
|
||||
let ran = run(
|
||||
"printf '\\033]7501;state=working:progress=40\\033\\\\\\033]7501;?\\007\\033cx'",
|
||||
(40, 4),
|
||||
);
|
||||
let working = Record {
|
||||
state: State::Working,
|
||||
kind: None,
|
||||
progress: Some(40),
|
||||
app: None,
|
||||
title: None,
|
||||
msg: None,
|
||||
};
|
||||
let said = [
|
||||
Some(Status::Report(String::new(), working)),
|
||||
Some(Status::Query),
|
||||
None,
|
||||
];
|
||||
assert_eq!(ran.statuses, said);
|
||||
assert_eq!(ran.text, "x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_escape_of_controls_keeps_the_pty_reading() {
|
||||
let ran = run(
|
||||
"printf '\\033'; head -c 3000000 /dev/zero; printf '[0mREADY'",
|
||||
(40, 4),
|
||||
);
|
||||
assert_eq!(ran.text, "READY");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_foreground_is_the_program_s_own_group() {
|
||||
let launch = Launch {
|
||||
program: Some(("/bin/sh".into(), vec!["-c".into(), "exec sleep 30".into()])),
|
||||
cwd: std::env::temp_dir(),
|
||||
env: Vec::new(),
|
||||
reports: false,
|
||||
};
|
||||
let (pty, _events) =
|
||||
spawn(&launch, (20, 4), (8, 16), config(&Settings::default())).expect("sh starts");
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
while pty.foreground().ok() != Some(pty.pid as i32) {
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"{:?}",
|
||||
pty.foreground()
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_size_past_the_kernel_is_one_size_for_grid_and_program() {
|
||||
let ran = run("stty size", (9000, 8));
|
||||
assert_eq!(ran.columns, usize::from(u16::MAX / 8));
|
||||
assert_eq!(ran.text, format!("8 {}", u16::MAX / 8));
|
||||
}
|
||||
|
||||
/// Starts `script`, waits for it to print READY, closes it, and waits for the reap's log.
|
||||
fn close_after_ready(script: &str, reaped: &str) {
|
||||
crate::testlog::init();
|
||||
let launch = Launch {
|
||||
program: Some(("/bin/sh".into(), vec!["-c".into(), script.into()])),
|
||||
cwd: std::env::temp_dir(),
|
||||
env: Vec::new(),
|
||||
reports: false,
|
||||
};
|
||||
let (pty, mut events) =
|
||||
spawn(&launch, (20, 4), (8, 16), config(&Settings::default())).expect("sh starts");
|
||||
futures::executor::block_on(async {
|
||||
while let Some(event) = events.next().await {
|
||||
let term = pty.term.lock();
|
||||
let text: String = (0..5)
|
||||
.map(|column| term.grid()[Line(0)][Column(column)].c)
|
||||
.collect();
|
||||
drop(term);
|
||||
if matches!(event, PtyEvent::Term(Event::Wakeup)) && text == "READY" {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
let pid = pty.pid;
|
||||
drop(pty);
|
||||
let hung_up = format!("pty {pid}: hung up, {reaped}");
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
while !crate::testlog::logged(&hung_up) {
|
||||
assert!(std::time::Instant::now() < deadline, "no `{hung_up}`");
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
assert!(crate::testlog::logged(&format!("pty {pid}: closing")));
|
||||
let alive = unsafe { libc::kill(pid as libc::pid_t, 0) } == 0;
|
||||
assert!(!alive, "the program was reaped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_a_session_hangs_up_its_program() {
|
||||
close_after_ready("printf READY; exec cat", "signal: 1 (SIGHUP)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_program_that_ignores_sighup_ends_when_the_pty_closes() {
|
||||
// cat reads EOF on macOS and EIO on Linux; the shell's own exit holds on both.
|
||||
close_after_ready("trap '' HUP; printf READY; cat; exit 7", "exit status: 7");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_folder_stops_the_start() {
|
||||
let launch = Launch {
|
||||
program: None,
|
||||
cwd: "/nonexistent/ely".into(),
|
||||
env: Vec::new(),
|
||||
reports: false,
|
||||
};
|
||||
let error = spawn(&launch, (20, 4), (8, 16), config(&Settings::default()))
|
||||
.err()
|
||||
.expect("a missing folder is an error");
|
||||
assert!(error.to_string().contains("/nonexistent/ely"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_that_cannot_be_entered_stops_the_start() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let folder = std::env::temp_dir().join(format!("elyterminal-shut-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&folder).expect("a temp folder");
|
||||
std::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o600)).expect("600");
|
||||
let launch = Launch {
|
||||
program: Some(("/bin/sh".into(), vec!["-c".into(), "true".into()])),
|
||||
cwd: folder.clone(),
|
||||
env: Vec::new(),
|
||||
reports: false,
|
||||
};
|
||||
let started = spawn(&launch, (20, 4), (8, 16), config(&Settings::default()));
|
||||
std::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o700)).expect("700");
|
||||
std::fs::remove_dir(&folder).expect("the temp folder goes");
|
||||
let error = started.err().expect("a folder it cannot enter is an error");
|
||||
let error = error.to_string();
|
||||
assert!(error.contains("Permission denied"), "{error}");
|
||||
assert!(error.contains(&folder.display().to_string()), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sizes_hold_at_least_two_by_one_and_cap_at_the_kernel() {
|
||||
let size = fit(70_000, 24, (8, 16));
|
||||
assert_eq!((size.num_cols, size.num_lines), (u16::MAX / 8, 24));
|
||||
let size = fit(0, 0, (8, 16));
|
||||
assert_eq!((size.num_cols, size.num_lines), (2, 1));
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
use base64::{
|
||||
Engine,
|
||||
alphabet::STANDARD,
|
||||
engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig},
|
||||
};
|
||||
|
||||
/// What a program says it is doing, OSC 7501.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum State {
|
||||
Idle,
|
||||
Working,
|
||||
Done,
|
||||
Blocked,
|
||||
Error,
|
||||
}
|
||||
|
||||
/// What a blocked program waits for.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Kind {
|
||||
Permission,
|
||||
Question,
|
||||
Auth,
|
||||
}
|
||||
|
||||
/// A record, as the last report for its id left it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Record {
|
||||
pub state: State,
|
||||
pub kind: Option<Kind>,
|
||||
pub progress: Option<u8>,
|
||||
pub app: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub msg: Option<String>,
|
||||
}
|
||||
|
||||
/// An OSC 7501 sequence, checked whole; an id is empty for the root record.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Status {
|
||||
Report(String, Record),
|
||||
/// Drops the record at the id and those under it; with no id, all.
|
||||
Clear(Option<String>),
|
||||
/// Asks which states and kinds the terminal takes.
|
||||
Query,
|
||||
}
|
||||
|
||||
/// The answer to the query: every state and kind this terminal takes.
|
||||
pub const REPLY: &str =
|
||||
"\x1b]7501;?:states=idle,working,done,blocked,error:kinds=permission,question,auth\x1b\\";
|
||||
|
||||
/// The spec's limits: a whole sequence, a key, an app, an id, its segments and levels.
|
||||
const SEQUENCE: usize = 4096;
|
||||
const KEY: usize = 16;
|
||||
const APP: usize = 32;
|
||||
const ID: usize = 128;
|
||||
const SEGMENT: usize = 32;
|
||||
const LEVELS: usize = 8;
|
||||
|
||||
/// Records a terminal keeps; past them, the one updated longest ago goes.
|
||||
pub const RECORDS: usize = 256;
|
||||
|
||||
/// Standard base64, its padding optional.
|
||||
const BASE64: GeneralPurpose = GeneralPurpose::new(
|
||||
&STANDARD,
|
||||
GeneralPurposeConfig::new().with_decode_padding_mode(DecodePaddingMode::Indifferent),
|
||||
);
|
||||
|
||||
/// The sequence in an OSC 7501 body past `7501;`; none for one the spec drops, logged with why.
|
||||
pub fn read(body: &[u8]) -> Option<Status> {
|
||||
parse(body)
|
||||
.inspect_err(|why| log::warn!("pty: OSC 7501 dropped: {why}"))
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// A value's bytes, as the spec allows them.
|
||||
fn valued(byte: u8) -> bool {
|
||||
byte.is_ascii_alphanumeric() || b"_.,+/=-".contains(&byte)
|
||||
}
|
||||
|
||||
/// An id's or an app's bytes.
|
||||
fn named(byte: u8) -> bool {
|
||||
byte.is_ascii_alphanumeric() || b"_.+-".contains(&byte)
|
||||
}
|
||||
|
||||
fn parse(body: &[u8]) -> Result<Status, String> {
|
||||
// ESC ] 7501 ; and an ST of two bytes frame the body.
|
||||
if body.len() + 9 > SEQUENCE {
|
||||
return Err(format!("over {SEQUENCE} bytes"));
|
||||
}
|
||||
if body.trim_ascii() == b"?" {
|
||||
return Ok(Status::Query);
|
||||
}
|
||||
let mut pairs = Vec::new();
|
||||
for pair in body.split(|&byte| byte == b':') {
|
||||
// A malformed pair is skipped: no `=`, no key, or a byte outside the set.
|
||||
let Some(at) = pair.iter().position(|&byte| byte == b'=') else {
|
||||
continue;
|
||||
};
|
||||
let (key, value) = (pair[..at].trim_ascii(), pair[at + 1..].trim_ascii());
|
||||
if key.is_empty()
|
||||
|| !key.iter().all(u8::is_ascii_lowercase)
|
||||
|| !value.iter().all(|&byte| valued(byte))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if key.len() > KEY {
|
||||
return Err(format!("a key over {KEY} bytes"));
|
||||
}
|
||||
let ascii = |bytes| String::from_utf8(Vec::from(bytes)).expect("ASCII");
|
||||
pairs.push((ascii(key), ascii(value)));
|
||||
}
|
||||
// The last of a key wins.
|
||||
let get = |key: &str| {
|
||||
pairs
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|(name, _)| name == key)
|
||||
.map(|(_, value)| value.as_str())
|
||||
};
|
||||
let id = get("id").map(id).transpose()?;
|
||||
let state = match get("state").ok_or("no state")? {
|
||||
"idle" => State::Idle,
|
||||
"working" => State::Working,
|
||||
"done" => State::Done,
|
||||
"blocked" => State::Blocked,
|
||||
"error" => State::Error,
|
||||
"clear" => return Ok(Status::Clear(id)),
|
||||
other => return Err(format!("state {other} is not the spec's")),
|
||||
};
|
||||
let kind = match (state, get("kind")) {
|
||||
(State::Blocked, Some("permission")) => Some(Kind::Permission),
|
||||
(State::Blocked, Some("question")) => Some(Kind::Question),
|
||||
(State::Blocked, Some("auth")) => Some(Kind::Auth),
|
||||
_ => None,
|
||||
};
|
||||
let progress = match state {
|
||||
State::Working | State::Blocked => get("progress")
|
||||
.filter(|value| value.bytes().all(|byte| byte.is_ascii_digit()))
|
||||
.and_then(|value| value.parse::<u8>().ok())
|
||||
.filter(|&value| value <= 100),
|
||||
_ => None,
|
||||
};
|
||||
let app = match get("app") {
|
||||
Some(app) if app.len() > APP => return Err(format!("an app over {APP} bytes")),
|
||||
Some(app) if !app.is_empty() && app.bytes().all(named) => Some(app.to_string()),
|
||||
_ => None,
|
||||
};
|
||||
let title = get("title")
|
||||
.map(|value| text(value, "title", 256, 192))
|
||||
.transpose()?;
|
||||
let msg = get("msg")
|
||||
.map(|value| text(value, "msg", 2732, 2048))
|
||||
.transpose()?;
|
||||
let record = Record {
|
||||
state,
|
||||
kind,
|
||||
progress,
|
||||
app,
|
||||
title,
|
||||
msg,
|
||||
};
|
||||
Ok(Status::Report(id.unwrap_or_default(), record))
|
||||
}
|
||||
|
||||
/// An id that keeps to the spec's grammar and limits.
|
||||
fn id(value: &str) -> Result<String, String> {
|
||||
let segments: Vec<&str> = value.split('/').collect();
|
||||
let fits = value.len() <= ID
|
||||
&& segments.len() <= LEVELS
|
||||
&& segments
|
||||
.iter()
|
||||
.all(|segment| (1..=SEGMENT).contains(&segment.len()) && segment.bytes().all(named));
|
||||
if fits {
|
||||
Ok(value.to_string())
|
||||
} else {
|
||||
Err(format!("id {value} is not the spec's"))
|
||||
}
|
||||
}
|
||||
|
||||
/// The text a base64 value names, one line, within `encoded` and `decoded` bytes.
|
||||
fn text(value: &str, key: &str, encoded: usize, decoded: usize) -> Result<String, String> {
|
||||
if value.len() > encoded {
|
||||
return Err(format!("a {key} over {encoded} bytes"));
|
||||
}
|
||||
let bytes = BASE64
|
||||
.decode(value)
|
||||
.map_err(|error| format!("{key} is no base64: {error}"))?;
|
||||
if bytes.len() > decoded {
|
||||
return Err(format!("a {key} over {decoded} bytes decoded"));
|
||||
}
|
||||
let text = String::from_utf8(bytes).map_err(|_| format!("{key} is not UTF-8"))?;
|
||||
if text.chars().any(char::is_control) {
|
||||
return Err(format!("{key} holds a control character"));
|
||||
}
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
/// The records one terminal keeps, by id, each with when it was last reported.
|
||||
#[derive(Default)]
|
||||
pub struct Records {
|
||||
list: Vec<(String, Record, u64)>,
|
||||
clock: u64,
|
||||
}
|
||||
|
||||
impl Records {
|
||||
/// Replaces the record at `id` whole.
|
||||
pub fn report(&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);
|
||||
return;
|
||||
}
|
||||
if self.list.len() == RECORDS {
|
||||
let oldest = (0..self.list.len())
|
||||
.min_by_key(|&at| self.list[at].2)
|
||||
.expect("records");
|
||||
let (gone, ..) = self.list.remove(oldest);
|
||||
log::debug!("status: record {gone:?} goes, the one updated longest ago");
|
||||
}
|
||||
self.list.push((id, record, self.clock));
|
||||
}
|
||||
|
||||
/// Drops the record at `id` and every one under it; with none, all.
|
||||
pub fn clear(&mut self, id: Option<&str>) {
|
||||
match id {
|
||||
None => self.list.clear(),
|
||||
Some(id) => {
|
||||
let under = format!("{id}/");
|
||||
self.list
|
||||
.retain(|(at, ..)| at != id && !at.starts_with(&under));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A new prompt: working, blocked and idle records go, done and error stay.
|
||||
pub fn prompt(&mut self) {
|
||||
self.list
|
||||
.retain(|(_, record, _)| matches!(record.state, State::Done | State::Error));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,168 @@
|
||||
use super::*;
|
||||
|
||||
fn encoded(text: &[u8]) -> String {
|
||||
BASE64.encode(text)
|
||||
}
|
||||
|
||||
fn record(state: State, app: Option<&str>) -> Record {
|
||||
Record {
|
||||
state,
|
||||
kind: None,
|
||||
progress: None,
|
||||
app: app.map(str::to_string),
|
||||
title: None,
|
||||
msg: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn reported(body: &str) -> Record {
|
||||
match read(body.as_bytes()) {
|
||||
Some(Status::Report(_, record)) => record,
|
||||
other => panic!("{body}: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_spec_s_examples_read_as_it_means_them() {
|
||||
let body = b"state=blocked:kind=permission:app=terraform:msg=QXBwbHkgMyB0byBhZGQsIDEgdG8gY2hhbmdlLCAwIHRvIGRlc3Ryb3k/";
|
||||
let blocked = Record {
|
||||
kind: Some(Kind::Permission),
|
||||
msg: Some("Apply 3 to add, 1 to change, 0 to destroy?".into()),
|
||||
..record(State::Blocked, Some("terraform"))
|
||||
};
|
||||
assert_eq!(read(body), Some(Status::Report(String::new(), blocked)));
|
||||
let body = b"state=working:id=us-east:title=VVMgRWFzdA==:progress=40:msg=UHVzaGluZyBpbWFnZQ==";
|
||||
let working = Record {
|
||||
progress: Some(40),
|
||||
title: Some("US East".into()),
|
||||
msg: Some("Pushing image".into()),
|
||||
..record(State::Working, None)
|
||||
};
|
||||
assert_eq!(read(body), Some(Status::Report("us-east".into(), working)));
|
||||
assert_eq!(
|
||||
read(b"state=clear:id=us-east"),
|
||||
Some(Status::Clear(Some("us-east".into())))
|
||||
);
|
||||
assert_eq!(read(b"state=clear"), Some(Status::Clear(None)));
|
||||
assert_eq!(read(b"?"), Some(Status::Query));
|
||||
assert_eq!(
|
||||
reported("state=done:msg=VXBncmFkZWQgMTIgcGFja2FnZXM")
|
||||
.msg
|
||||
.as_deref(),
|
||||
Some("Upgraded 12 packages"),
|
||||
"padding is optional"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_malformed_pair_is_skipped_the_last_of_a_key_wins_and_unknown_keys_pass() {
|
||||
let record = reported(" junk : =x : state = idle :Key=v:app=a b:color=red:state=done:app=ok ");
|
||||
assert_eq!(record, self::record(State::Done, Some("ok")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sequence_that_breaks_a_rule_drops_whole() {
|
||||
let long = |key: &str, length: usize| format!("state=done:{key}={}", "a".repeat(length));
|
||||
let drops = [
|
||||
"app=x".to_string(),
|
||||
"state=sleeping".into(),
|
||||
"state=done:id=".into(),
|
||||
"state=done:id=a//b".into(),
|
||||
"state=done:id=a/b/c/d/e/f/g/h/i".into(),
|
||||
format!("state=done:id={}", "a".repeat(33)),
|
||||
format!("state=done:{}=1", "k".repeat(17)),
|
||||
long("app", 33),
|
||||
long("title", 257),
|
||||
long("msg", 2733),
|
||||
"state=done:msg=A".into(),
|
||||
format!("state=done:msg={}", encoded(b"a\nb")),
|
||||
format!("state=done:msg={}", encoded("a\u{85}b".as_bytes())),
|
||||
format!("state=done:msg={}", encoded(&[0xff])),
|
||||
format!("state=done:title={}", encoded(&[b'a'; 193])),
|
||||
format!("state=done:x={}", "a".repeat(4096 - 9 - 12)),
|
||||
];
|
||||
for body in drops {
|
||||
assert_eq!(read(body.as_bytes()), None, "{body}");
|
||||
}
|
||||
assert!(read(format!("state=done:x={}", "a".repeat(4096 - 9 - 14)).as_bytes()).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kind_progress_and_app_count_only_where_the_spec_has_them() {
|
||||
let done = reported("state=done:kind=auth:progress=5");
|
||||
assert_eq!((done.kind, done.progress), (None, None));
|
||||
let blocked = reported("state=blocked:kind=nope:progress=101");
|
||||
assert_eq!((blocked.kind, blocked.progress), (None, None));
|
||||
assert_eq!(reported("state=working:progress=007").progress, Some(7));
|
||||
assert_eq!(reported("state=working:progress=+5").progress, None);
|
||||
assert_eq!(reported("state=blocked:kind=auth").kind, Some(Kind::Auth));
|
||||
assert_eq!(
|
||||
reported("state=working:app=a.b+c_d-e").app.as_deref(),
|
||||
Some("a.b+c_d-e")
|
||||
);
|
||||
assert_eq!(reported("state=working:app=a,b").app, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_replace_and_clear_as_the_spec_says() {
|
||||
let mut records = Records::default();
|
||||
records.report(String::new(), record(State::Working, Some("deploy")));
|
||||
records.report("us-east".into(), record(State::Working, None));
|
||||
records.report("us-east/push".into(), record(State::Blocked, None));
|
||||
records.report("us-easter".into(), record(State::Done, Some("other")));
|
||||
records.report("us-east".into(), record(State::Done, None));
|
||||
let ids: Vec<&str> = records.list.iter().map(|(id, ..)| id.as_str()).collect();
|
||||
assert_eq!(ids, ["", "us-east", "us-east/push", "us-easter"]);
|
||||
let east = records
|
||||
.list
|
||||
.iter()
|
||||
.find(|(id, ..)| id == "us-east")
|
||||
.map(|(_, record, _)| record.clone());
|
||||
assert_eq!(
|
||||
east,
|
||||
Some(record(State::Done, None)),
|
||||
"a report replaces its record whole"
|
||||
);
|
||||
records.clear(Some("us-east"));
|
||||
let ids: Vec<&str> = records.list.iter().map(|(id, ..)| id.as_str()).collect();
|
||||
assert_eq!(
|
||||
ids,
|
||||
["", "us-easter"],
|
||||
"clear takes the record and those under it"
|
||||
);
|
||||
records.clear(None);
|
||||
assert!(records.list.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_prompt_leaves_done_and_error() {
|
||||
let mut records = Records::default();
|
||||
for (id, state) in [
|
||||
("a", State::Idle),
|
||||
("b", State::Working),
|
||||
("c", State::Done),
|
||||
("d", State::Blocked),
|
||||
("e", State::Error),
|
||||
] {
|
||||
records.report(id.into(), record(state, None));
|
||||
}
|
||||
records.prompt();
|
||||
let ids: Vec<&str> = records.list.iter().map(|(id, ..)| id.as_str()).collect();
|
||||
assert_eq!(ids, ["c", "e"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn past_the_cap_the_record_updated_longest_ago_goes() {
|
||||
let mut records = Records::default();
|
||||
for at in 0..RECORDS {
|
||||
records.report(format!("r{at}"), record(State::Working, None));
|
||||
}
|
||||
records.report("r0".into(), record(State::Done, None));
|
||||
records.report("new".into(), record(State::Working, None));
|
||||
assert_eq!(records.list.len(), RECORDS);
|
||||
assert!(
|
||||
records.list.iter().all(|(id, ..)| id != "r1"),
|
||||
"r1 was updated longest ago"
|
||||
);
|
||||
assert!(records.list.iter().any(|(id, ..)| id == "r0"));
|
||||
}
|
||||
+49
-25
@@ -12,6 +12,7 @@ use super::{
|
||||
mouse::MouseMode,
|
||||
prompts::{self, Prompts},
|
||||
pty::PtyEvent,
|
||||
status,
|
||||
};
|
||||
|
||||
/// The longest OSC body kept; longer ones are dropped whole.
|
||||
@@ -55,27 +56,8 @@ 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);
|
||||
self.scanner.feed(read, &mut |body| {
|
||||
if let Some(mark) = body.strip_prefix(b"133;").and_then(marks::read) {
|
||||
log::debug!("pty {pid}: {mark:?}");
|
||||
if events.unbounded_send(PtyEvent::Mark(mark)).is_err() {
|
||||
log::debug!("pty {pid}: a mark after its session closed");
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(folder) = body.strip_prefix(b"7;").and_then(folder) else {
|
||||
return;
|
||||
};
|
||||
if last.as_ref() == Some(&folder) {
|
||||
log::debug!("pty {pid}: folder {} again", folder.display());
|
||||
} else {
|
||||
log::info!("pty {pid}: folder {}", folder.display());
|
||||
*last = Some(folder.clone());
|
||||
}
|
||||
if events.unbounded_send(PtyEvent::Folder(folder)).is_err() {
|
||||
log::debug!("pty {pid}: a folder report after its session closed");
|
||||
}
|
||||
});
|
||||
self.scanner
|
||||
.feed(read, &mut |found| report(found, events, pid, last));
|
||||
if let Some(mode) = self.mouse.feed(read) {
|
||||
log::info!("pty {pid}: mouse {mode:?}");
|
||||
if events.unbounded_send(PtyEvent::Mouse(mode)).is_err() {
|
||||
@@ -88,6 +70,38 @@ 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>) {
|
||||
let event = match found {
|
||||
Found::Reset => {
|
||||
log::debug!("pty {pid}: a full reset");
|
||||
PtyEvent::Reset
|
||||
}
|
||||
Found::Osc(body) => {
|
||||
if let Some(mark) = body.strip_prefix(b"133;").and_then(marks::read) {
|
||||
log::debug!("pty {pid}: {mark:?}");
|
||||
PtyEvent::Mark(mark)
|
||||
} else if let Some(status) = body.strip_prefix(b"7501;").and_then(status::read) {
|
||||
log::debug!("pty {pid}: {status:?}");
|
||||
PtyEvent::Status(status)
|
||||
} 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());
|
||||
} else {
|
||||
log::info!("pty {pid}: folder {}", folder.display());
|
||||
*last = Some(folder.clone());
|
||||
}
|
||||
PtyEvent::Folder(folder)
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
if events.unbounded_send(event).is_err() {
|
||||
log::debug!("pty {pid}: an event after its session closed");
|
||||
}
|
||||
}
|
||||
|
||||
impl<P: EventedReadWrite> EventedReadWrite for Tapped<P> {
|
||||
type Reader = Self;
|
||||
type Writer = P::Writer;
|
||||
@@ -138,7 +152,13 @@ enum State {
|
||||
Osc,
|
||||
}
|
||||
|
||||
/// Finds each OSC body across reads, ended as vte ends it: at BEL, ESC, CAN or SUB.
|
||||
/// What the scanner finds: an OSC's body, or a full reset.
|
||||
pub(crate) enum Found<'a> {
|
||||
Osc(&'a [u8]),
|
||||
Reset,
|
||||
}
|
||||
|
||||
/// Finds each OSC body across reads, ended as vte ends it: at BEL, ESC, CAN or SUB, and each full reset.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Osc {
|
||||
state: State,
|
||||
@@ -147,7 +167,7 @@ pub(crate) struct Osc {
|
||||
}
|
||||
|
||||
impl Osc {
|
||||
pub fn feed(&mut self, bytes: &[u8], found: &mut impl FnMut(&[u8])) {
|
||||
pub fn feed(&mut self, bytes: &[u8], found: &mut impl FnMut(Found)) {
|
||||
let mut rest = bytes;
|
||||
while !rest.is_empty() {
|
||||
if self.state == State::Ground {
|
||||
@@ -168,6 +188,10 @@ impl Osc {
|
||||
self.overflow = false;
|
||||
State::Osc
|
||||
}
|
||||
(State::Escape, b'c') => {
|
||||
found(Found::Reset);
|
||||
State::Ground
|
||||
}
|
||||
// vte runs controls in an escape and stays there.
|
||||
(State::Escape, 0x00..=0x17 | 0x19 | 0x1b..=0x1f) => State::Escape,
|
||||
(State::Escape, _) => State::Ground,
|
||||
@@ -194,11 +218,11 @@ impl Osc {
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(&mut self, found: &mut impl FnMut(&[u8])) {
|
||||
fn finish(&mut self, found: &mut impl FnMut(Found)) {
|
||||
if self.overflow {
|
||||
log::warn!("pty: an OSC over {MOST} bytes dropped");
|
||||
} else {
|
||||
found(&self.body);
|
||||
found(Found::Osc(&self.body));
|
||||
}
|
||||
self.body.clear();
|
||||
}
|
||||
|
||||
+19
-3
@@ -1,16 +1,32 @@
|
||||
use super::*;
|
||||
|
||||
/// What the scanner finds in `chunks`, each one read: a body, or `RESET`.
|
||||
fn scan(chunks: &[&[u8]]) -> Vec<String> {
|
||||
let mut scanner = Osc::default();
|
||||
let mut found = Vec::new();
|
||||
for chunk in chunks {
|
||||
scanner.feed(chunk, &mut |body| {
|
||||
found.push(String::from_utf8(body.to_vec()).expect("utf-8"))
|
||||
scanner.feed(chunk, &mut |thing| {
|
||||
found.push(match thing {
|
||||
Found::Osc(body) => String::from_utf8(body.to_vec()).expect("utf-8"),
|
||||
Found::Reset => "RESET".into(),
|
||||
})
|
||||
});
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_a_full_reset_between_bodies() {
|
||||
assert_eq!(
|
||||
scan(&[b"\x1b]0;a\x07\x1bc\x1b", b"c\x1b]0;b\x1b\\"]),
|
||||
["0;a", "RESET", "RESET", "0;b"]
|
||||
);
|
||||
assert!(
|
||||
scan(&[b"\x1b[c\x1b]0;c"]).is_empty(),
|
||||
"DA1 and an unfinished body"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_each_osc_body_across_reads_ended_as_vte_ends_it() {
|
||||
assert_eq!(scan(&[b"ab\x1b]7;file:///a\x07cd"]), ["7;file:///a"]);
|
||||
@@ -30,7 +46,7 @@ fn finds_each_osc_body_across_reads_ended_as_vte_ends_it() {
|
||||
#[test]
|
||||
fn controls_vte_skips_stay_out_of_a_body() {
|
||||
assert_eq!(scan(&[b"\x1b\x00]1\x0033;\x1fA\x07"]), ["133;A"]);
|
||||
assert!(scan(&[b"\x1b[31m\x1bc"]).is_empty());
|
||||
assert_eq!(scan(&[b"\x1b[31m\x1bc"]), ["RESET"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -27,6 +27,7 @@ use super::{
|
||||
paint::{self, Look},
|
||||
pointer::Pointer,
|
||||
pty::{Events, Pty, PtyEvent, config, fit},
|
||||
status::{self, Records, Status},
|
||||
zoom,
|
||||
};
|
||||
use crate::{
|
||||
@@ -71,6 +72,8 @@ pub struct TerminalView {
|
||||
pub(super) size: (usize, usize),
|
||||
/// How many folders the program has reported.
|
||||
pub(super) reports: u64,
|
||||
/// What programs say they are doing, OSC 7501.
|
||||
pub(super) records: Records,
|
||||
cell: (u16, u16),
|
||||
/// Focused in an active window, as the program last heard.
|
||||
focused: bool,
|
||||
@@ -118,6 +121,7 @@ impl TerminalView {
|
||||
finder: None,
|
||||
size,
|
||||
reports: 0,
|
||||
records: Records::default(),
|
||||
cell: (8, 16),
|
||||
focused: false,
|
||||
blinking: false,
|
||||
@@ -222,7 +226,11 @@ impl TerminalView {
|
||||
PtyEvent::Term(event) => self.term_event(event, cx),
|
||||
PtyEvent::Mouse(mode) => self.mouse = mode,
|
||||
PtyEvent::Mark(Mark::Done(status)) => cx.emit(TerminalEvent::Finished(status)),
|
||||
// A new prompt ends what was working, waiting or idle; done and error stay.
|
||||
PtyEvent::Mark(Mark::Prompt) => self.records.prompt(),
|
||||
PtyEvent::Mark(_) => {}
|
||||
PtyEvent::Status(status) => self.status(status),
|
||||
PtyEvent::Reset => self.records.clear(None),
|
||||
}
|
||||
}
|
||||
// ponytail: a full search per output batch while find is open; search only new lines if it lags.
|
||||
@@ -232,6 +240,15 @@ impl TerminalView {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Keeps a program's report, drops what it clears, and answers its query.
|
||||
fn status(&mut self, status: Status) {
|
||||
match status {
|
||||
Status::Report(id, record) => self.records.report(id, record),
|
||||
Status::Clear(id) => self.records.clear(id.as_deref()),
|
||||
Status::Query => self.pty.write(status::REPLY.as_bytes().to_vec()),
|
||||
}
|
||||
}
|
||||
|
||||
fn term_event(&mut self, event: Event, cx: &mut Context<Self>) {
|
||||
match event {
|
||||
Event::Wakeup => self.shown = true,
|
||||
|
||||
@@ -53,6 +53,7 @@ What no check reached: no real KWin drew the blur; of Wayland desktops, only Hyp
|
||||
| Shell integration for zsh, bash, fish, PowerShell; a shell without it reports nothing and the panels say so | T05 | `term::shell` tests on macOS and Linux | Pass |
|
||||
| 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; 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. `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 |
|
||||
| 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