diff --git a/AGENTS.md b/AGENTS.md index 96779b0..42332a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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; `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 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. - `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). @@ -97,3 +97,4 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does; - 2026-10-16: the main window opens at the size it was left at, which the owner asked for on macOS, Windows and Linux: `window_width` and `window_height` in the settings hold the size gpui's `window_bounds` gives 0.5 s after the bounds settle, saved through `SettingsStore::update`, which writes only a change, so a drag saves once and a move saves nothing. A maximized, full-screen or hidden window keeps the size from before; its state and size are read once they settle, since X11 marks a window maximized after its new size, gpui's X11 window then gives a maximized window's own size, and it calls a maximized window unmaximized while minimized. A window that shows again, uncovered, restored or back on its screen, is read again, so a size it took just before it hid is kept; a size it takes in the half second before it closes, or before it hides until the app quits, is lost. The window is no smaller than 100 by 100, the least the settings keep, or a smaller size the platform allows could not be kept. The window opens centered at whole points, since macOS grows a window by a point on a side whose edge falls on a half point; its place is not kept, and a kept side past the primary display's visible area shrinks to it, logged. A size outside 100 to 16384 stops the start, as any value out of range 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 each mark in place, at its own length, into an OSC 8 link of the terminal's own, which the parser handles in step with the text: `ESC ] 133;A`, or N, opens a link to `\x7f`, a target no program names, B, I, C and D close it, and the options after the letter become NULs, which vte skips inside an OSC. A mark's first bytes wait for the next read until its letter shows, 64 at most, and show nothing meanwhile, since an OSC shows nothing. The parser stamps each cell a prompt prints with the link, SGR 0 keeps it, and each open gets an id of alacritty's own, so the link rides the cells through scroll, reflow and history, and a prompt starts on the first row that shows its link. Links, Cmd-press among them, pass over it. 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. A prompt that opens an OSC 8 link of its own first loses the mark on those cells. diff --git a/README.md b/README.md index c095da5..228b3e0 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ The app writes its log to stderr. `RUST_LOG=elyterminal=debug` writes more; `tra - The terminal runs on `alacritty_terminal`: 256 colors and truecolor, bold, dim, italic, five underline styles, wide characters and combining marks. Box drawing, block elements and powerline shapes are drawn as geometry, so lines join across cells. Keys follow xterm and the kitty keyboard protocol; input methods compose at the cursor; programs can track the mouse; pastes are bracketed when a program asks; OSC 8 and plain links open with the link press below; find searches the scrollback. - Shell integration for zsh, bash, fish and PowerShell: each prompt reports its folder and title, and OSC 133 marks each prompt, each command and its exit status. - Sessions: new, close and switch. A bell marks a hidden session's row and makes no sound. +- Cmd-Up and Cmd-Down, Ctrl-Shift-Up and Ctrl-Shift-Down off macOS, jump to the prompt before or after the view's top. - A button at each end of the title bar hides or shows its side column, and a drag on the border between a side column and the terminal resizes it; the window opens as it was left, at the size and widths it was left at. On Windows and Linux a gear beside the right one opens Settings. - Files shows the session's folder as a tree with Git's marks. Inside herdr the folder is that of herdr's focused pane. - Git shows the branch, stages, unstages, discards, commits, lists recent commits and opens diffs, all through the `git` command line. diff --git a/src/settings/window/terminal.rs b/src/settings/window/terminal.rs index 65929f1..39bf143 100644 --- a/src/settings/window/terminal.rs +++ b/src/settings/window/terminal.rs @@ -14,7 +14,7 @@ use super::{OpenSettings, SettingsWindow, choice, set, switch}; use crate::{ menus::{Bigger, Minimize, Quit, ResetSize, Smaller}, settings::{OptionAsAlt, SCROLLBACK, SettingsStore, StartIn}, - term::input::{self, Find}, + term::input::{self, Find, NextPrompt, PreviousPrompt}, workspace::{ self, CloseSession, LastSession, NewSession, NextSession, PreviousSession, SelectSession, TogglePanel, ToggleSessions, @@ -141,6 +141,8 @@ fn listed() -> Vec<(&'static str, &'static str, Box)> { ("Edit", "Paste", Paste.boxed_clone()), ("Edit", "Select all", SelectAll.boxed_clone()), ("Edit", "Find", Find.boxed_clone()), + ("View", "Previous prompt", PreviousPrompt.boxed_clone()), + ("View", "Next prompt", NextPrompt.boxed_clone()), ("View", "Toggle sessions", ToggleSessions.boxed_clone()), ("View", "Toggle right panel", TogglePanel.boxed_clone()), ("View", "Bigger", Bigger.boxed_clone()), diff --git a/src/term/input.rs b/src/term/input.rs index 8604406..a48e19f 100644 --- a/src/term/input.rs +++ b/src/term/input.rs @@ -12,7 +12,7 @@ use super::{ view::TerminalView, }; -actions!(elyterminal, [Find, FindNewer]); +actions!(elyterminal, [Find, FindNewer, PreviousPrompt, NextPrompt]); /// The key context a terminal's root sets. pub const CONTEXT: &str = "Terminal"; @@ -32,6 +32,20 @@ pub fn bind_keys(cx: &mut App) { "ctrl-shift-f", ) }; + // Ghostty's jumps: Cmd with or without Shift on macOS, Ctrl-Shift elsewhere. + let (up, down): (&[&str], &[&str]) = if cfg!(target_os = "macos") { + (&["cmd-up", "cmd-shift-up"], &["cmd-down", "cmd-shift-down"]) + } else { + (&["ctrl-shift-up"], &["ctrl-shift-down"]) + }; + cx.bind_keys( + up.iter() + .map(|key| KeyBinding::new(key, PreviousPrompt, Some(CONTEXT))), + ); + cx.bind_keys( + down.iter() + .map(|key| KeyBinding::new(key, NextPrompt, Some(CONTEXT))), + ); cx.bind_keys([ KeyBinding::new(copy, Copy, Some(CONTEXT)), KeyBinding::new(paste, Paste, Some(CONTEXT)), diff --git a/src/term/links.rs b/src/term/links.rs index 8e5df75..266297d 100644 --- a/src/term/links.rs +++ b/src/term/links.rs @@ -4,13 +4,18 @@ use alacritty_terminal::{ }; use ely_gpui_component::terminal::{Target, links}; -/// The OSC 8 target at `point`, else a URL in its wrapped line. +use super::prompts; + +/// The OSC 8 target at `point`, else a URL in its wrapped line; a prompt's own link is no target. pub fn link_at(term: &Term, mut point: Point) -> Option { let grid = term.grid(); if grid[point].flags.contains(Flags::WIDE_CHAR_SPACER) { point.column -= 1; } - if let Some(link) = grid[point].hyperlink() { + if let Some(link) = grid[point] + .hyperlink() + .filter(|link| link.uri() != prompts::LINK) + { return Some(link.uri().to_string()); } let end = term.line_search_right(point); @@ -63,6 +68,13 @@ mod tests { assert_eq!(link(text, 0, 0), None); } + #[test] + fn a_prompt_s_own_link_is_no_target() { + let text = "\x1b]8;;\x7f\x1b\\$ https://ely.dev\x1b]8;;\x1b\\"; + assert_eq!(link(text, 0, 0), None); + assert_eq!(link(text, 0, 4).as_deref(), Some("https://ely.dev")); + } + #[test] fn a_url_ends_before_its_sentence_does() { assert_eq!(link("(a.io/x).", 0, 2), None, "no scheme, no link"); diff --git a/src/term/mod.rs b/src/term/mod.rs index 42cfee5..ebdf8cc 100644 --- a/src/term/mod.rs +++ b/src/term/mod.rs @@ -12,6 +12,7 @@ mod marks; pub mod mouse; mod paint; mod pointer; +mod prompts; mod pty; pub mod shell; mod tap; diff --git a/src/term/prompts.rs b/src/term/prompts.rs new file mode 100644 index 0000000..2fee022 --- /dev/null +++ b/src/term/prompts.rs @@ -0,0 +1,180 @@ +use alacritty_terminal::{ + grid::{Dimensions, Scroll}, + index::Line, + term::{ + Term, + cell::{Cell, Hyperlink}, + }, +}; +use gpui::Context; + +use super::view::TerminalView; + +/// The target of the OSC 8 link each prompt mark becomes; no program names a link so. +pub(crate) const LINK: &str = "\u{7f}"; + +/// The most bytes a mark's start may span, ignored controls in it too. +const MOST: usize = 64; + +const ESC: u8 = 0x1b; + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +enum State { + #[default] + Ground, + /// An ESC at this index, which may start a mark. + Escape(usize), + /// Inside `ESC ]` from this index, with this much of `133;` matched. + Body(usize, usize), + /// Past a rewritten mark's letter, its options blanked to the end. + Options, +} + +/// Turns each OSC 133 mark into an OSC 8 link as it passes to the parser, so the parser marks the cells each prompt prints. +#[derive(Default)] +pub(crate) struct Prompts { + state: State, + /// A mark's start, held from the parser until its letter shows. + held: Vec, +} + +/// Controls vte skips inside a sequence. +fn ignored(byte: u8) -> bool { + matches!(byte, 0x00..=0x06 | 0x08..=0x17 | 0x19 | 0x1c..=0x1f) +} + +impl Prompts { + /// The bytes held from the last read, which go before the next. + pub fn held(&self) -> usize { + self.held.len() + } + + /// Rewrites the marks in `buf[..held + read]`, the held bytes put first; returns how many bytes go on. + pub fn pass(&mut self, buf: &mut [u8], read: usize) -> usize { + let from = self.held.len(); + buf[..from].copy_from_slice(&self.held); + self.held.clear(); + let end = from + read; + for at in from..end { + let byte = buf[at]; + self.state = match self.state { + State::Ground if byte == ESC => State::Escape(at), + State::Ground => State::Ground, + State::Escape(start) => match byte { + b']' => State::Body(start, 0), + ESC => State::Escape(at), + // vte runs these and stays in the escape. + byte if byte == 0x07 || ignored(byte) => State::Escape(start), + _ => State::Ground, + }, + State::Body(start, seen) => match byte { + ESC => State::Escape(at), + 0x07 | 0x18 | 0x1a => State::Ground, + byte if ignored(byte) => State::Body(start, seen), + byte if seen < 4 && byte == b"133;"[seen] => State::Body(start, seen + 1), + byte if seen == 4 => match byte { + b'A' | b'N' => link(&mut buf[start..=at], true), + b'B' | b'I' | b'C' | b'D' => link(&mut buf[start..=at], false), + _ => State::Ground, + }, + _ => State::Ground, + }, + State::Options => match byte { + ESC => State::Escape(at), + 0x07 | 0x18 | 0x1a => State::Ground, + byte if ignored(byte) => State::Options, + _ => { + buf[at] = 0; + State::Options + } + }, + }; + } + match self.state { + State::Escape(start) | State::Body(start, _) if end - start <= MOST => { + self.held.extend_from_slice(&buf[start..end]); + self.state = match self.state { + State::Body(_, seen) => State::Body(0, seen), + _ => State::Escape(0), + }; + start + } + State::Escape(_) | State::Body(..) => { + log::warn!("pty: a mark's start over {MOST} bytes passes as it came"); + self.state = State::Ground; + end + } + State::Ground | State::Options => end, + } + } +} + +/// Makes `ESC ] 133;X` an OSC 8 link of the terminal's own: opened at a prompt's start, else closed. +fn link(span: &mut [u8], open: bool) -> State { + let bytes = [b'8', b';', b';', if open { 0x7f } else { 0 }, 0]; + let body = span + .iter() + .position(|&byte| byte == b']') + .expect("a mark holds its ]") + + 1; + let mut next = bytes.into_iter(); + for byte in span[body..].iter_mut().filter(|byte| !ignored(**byte)) { + *byte = next.next().expect("a mark's five bytes"); + } + State::Options +} + +/// The prompt link a cell carries. +fn prompt_link(cell: &Cell) -> Option { + cell.hyperlink().filter(|link| link.uri() == LINK) +} + +/// The prompt `line` shows. +fn prompt(term: &Term, line: Line) -> Option { + term.grid()[line].into_iter().find_map(prompt_link) +} + +/// Whether `line` starts a prompt: it shows one the line above does not. +fn starts(term: &Term, line: Line) -> bool { + prompt(term, line).is_some_and(|link| { + line == term.topmost_line() || prompt(term, line - 1).as_ref() != Some(&link) + }) +} + +/// The display offset that puts the prompt before or after the view's top at the top, as Ghostty jumps. +pub fn jump(term: &Term, up: bool) -> Option { + let top = Line(-(term.grid().display_offset() as i32)); + // ponytail: a scan of every row on the way; index prompt rows if long histories lag. + let found = if up { + let mut lines = (term.topmost_line().0..top.0).rev().map(Line); + lines.find(|&line| starts(term, line)) + } else { + let mut lines = (top.0 + 1..=term.bottommost_line().0).map(Line); + lines.find(|&line| starts(term, line)) + }?; + // A prompt on the screen means the bottom, which the view cannot pass. + Some((-found.0).max(0) as usize) +} + +impl TerminalView { + /// Scrolls to the prompt before or after the view's top; with none there, nothing moves. + pub(super) fn scroll_to_prompt(&mut self, up: bool, cx: &mut Context) { + let mut term = self.pty.term.lock(); + let Some(offset) = jump(&term, up) else { + log::debug!( + "terminal {}: no prompt {}", + self.pty.pid, + if up { "above" } else { "below" } + ); + return; + }; + let delta = offset as i32 - term.grid().display_offset() as i32; + term.scroll_display(Scroll::Delta(delta)); + drop(term); + log::debug!("terminal {}: to the prompt {offset} lines up", self.pty.pid); + cx.notify(); + } +} + +#[cfg(test)] +mod tests; diff --git a/src/term/prompts/tests.rs b/src/term/prompts/tests.rs new file mode 100644 index 0000000..5d05d96 --- /dev/null +++ b/src/term/prompts/tests.rs @@ -0,0 +1,160 @@ +use alacritty_terminal::{ + event::VoidListener, + index::Column, + term::{Config, test::TermSize}, + vte::ansi::Processor, +}; + +use super::*; + +/// What the parser gets from `chunks`, each one read. +fn passed(chunks: &[&[u8]]) -> Vec { + let mut prompts = Prompts::default(); + let mut out = Vec::new(); + for chunk in chunks { + let held = prompts.held(); + let mut buf = vec![0; held + chunk.len()]; + buf[held..].copy_from_slice(chunk); + let count = prompts.pass(&mut buf, chunk.len()); + out.extend_from_slice(&buf[..count]); + } + out +} + +#[test] +fn a_mark_becomes_a_link_of_the_same_length() { + assert_eq!(passed(&[b"\x1b]133;A\x07"]), b"\x1b]8;;\x7f\0\x07"); + assert_eq!(passed(&[b"\x1b]133;N\x07"]), b"\x1b]8;;\x7f\0\x07"); + assert_eq!(passed(&[b"\x1b]133;B\x1b\\"]), b"\x1b]8;;\0\0\x1b\\"); + assert_eq!(passed(&[b"\x1b]133;D;1\x07"]), b"\x1b]8;;\0\0\0\0\x07"); + assert_eq!( + passed(&[b"\x1b]133;A;k=i\x07x"]), + b"\x1b]8;;\x7f\0\0\0\0\0\x07x" + ); + assert_eq!(passed(&[b"\x1b]1\x0033;C\x07"]), b"\x1b]8\x00;;\0\0\x07"); +} + +#[test] +fn a_mark_split_across_reads_waits_for_its_letter() { + let whole: &[u8] = b"a\x1b]133;A;k=i\x07$ \x1b]133;B\x1b\\b"; + let once = passed(&[whole]); + for cut in 0..=whole.len() { + let (first, second) = whole.split_at(cut); + assert_eq!(passed(&[first, second]), once, "cut at {cut}"); + } + let bytes: Vec<&[u8]> = whole.chunks(1).collect(); + assert_eq!(passed(&bytes), once); +} + +#[test] +fn other_sequences_pass_as_they_came() { + for bytes in [ + &b"\x1b]1337;A\x07"[..], + b"\x1b]13;A\x07", + b"\x1b]133;P;k=i\x07", + b"\x1b]133;L\x07", + b"\x1b]133;\x07", + b"\x1b]133\x18;A\x07", + b"\x1b]0;133;A\x07", + b"\x1b[31m\x1bc", + b"text", + ] { + assert_eq!( + passed(&[bytes]), + bytes, + "{}", + String::from_utf8_lossy(bytes) + ); + } +} + +#[test] +fn a_mark_s_start_waits_no_longer_than_its_cap() { + let mut long = b"\x1b]".to_vec(); + long.extend([0u8; MOST]); + assert_eq!(passed(&[&long]), long); +} + +/// A terminal `columns` wide and two lines high after `bytes` passed through the rewrite. +fn parsed(bytes: &[u8], columns: usize) -> Term { + let mut term = Term::new(Config::default(), &TermSize::new(columns, 2), VoidListener); + let mut parser: Processor = Processor::new(); + parser.advance(&mut term, &passed(&[bytes])); + term +} + +#[test] +fn a_prompt_s_cells_carry_its_link_and_its_input_none() { + let term = parsed(b"x\r\n\x1b]133;A\x07\x1b[31m$\x1b[0m \x1b]133;B\x07ls", 8); + let cell = |line, column| prompt_link(&term.grid()[Line(line)][Column(column)]); + assert!(cell(0, 0).is_none()); + assert!( + cell(1, 0).is_some(), + "a prompt keeps its link through SGR 0" + ); + assert_eq!(cell(1, 0), cell(1, 1)); + assert!(cell(1, 2).is_none(), "input"); + assert!(starts(&term, Line(1))); + assert!(!starts(&term, Line(0))); +} + +#[test] +fn a_prompt_over_two_rows_starts_at_its_first() { + let term = parsed(b"\x1b]133;A\x07~/dir\r\n> \x1b]133;B\x07", 8); + assert!(starts(&term, Line(0))); + assert!(!starts(&term, Line(1)), "the same prompt goes on"); + let next = parsed(b"\x1b]133;A\x07$ \x1b]133;B\x07\r\n\x1b]133;A\x07$ ", 8); + assert!( + starts(&next, Line(1)), + "an empty line's next prompt is its own" + ); +} + +/// Prompts on lines 0, 3 and 6 of a history four lines high, the last on the screen. +fn history() -> Term { + let mut bytes = Vec::new(); + for _ in 0..3 { + bytes.extend_from_slice( + b"\x1b]133;A\x07$ \x1b]133;B\x07c\r\n\x1b]133;C\x07o\r\no\r\n\x1b]133;D;0\x07", + ); + } + bytes.extend_from_slice(b"\x1b]133;A\x07$ \x1b]133;B\x07"); + let mut term = Term::new(Config::default(), &TermSize::new(8, 4), VoidListener); + let mut parser: Processor = Processor::new(); + parser.advance(&mut term, &passed(&[&bytes])); + term +} + +#[test] +fn up_and_down_go_to_the_prompts_beside_the_view_s_top() { + let mut term = history(); + assert_eq!(term.grid().history_size(), 6); + // From the bottom: the prompt at the view's top (line 0) is passed, the one before it found. + assert_eq!(jump(&term, true), Some(3)); + term.scroll_display(Scroll::Delta(3)); + assert_eq!(jump(&term, true), Some(6)); + term.scroll_display(Scroll::Delta(3)); + assert_eq!(jump(&term, true), None, "no prompt above the first"); + assert_eq!(jump(&term, false), Some(3)); + term.scroll_display(Scroll::Delta(-3)); + assert_eq!( + jump(&term, false), + Some(0), + "a prompt on the screen means the bottom" + ); +} + +#[test] +fn a_prompt_keeps_its_start_through_a_resize() { + let bytes = b"\x1b]133;A\x07~/folder $ \x1b]133;B\x07ls\r\nout\r\n\x1b]133;A\x07$ "; + let mut term = Term::new(Config::default(), &TermSize::new(12, 4), VoidListener); + let mut parser: Processor = Processor::new(); + parser.advance(&mut term, &passed(&[bytes])); + term.resize(TermSize::new(6, 4)); + let lines = term.topmost_line().0..=term.bottommost_line().0; + let starts: Vec = lines.filter(|&line| starts(&term, Line(line))).collect(); + // The first prompt wraps over two rows and starts at its first; the last starts two below. + let first = starts.first().copied().expect("a prompt"); + assert_eq!(starts.len(), 2, "{starts:?}"); + assert!(!starts.contains(&(first + 1)), "{starts:?}"); +} diff --git a/src/term/pty.rs b/src/term/pty.rs index 96797d4..c772c89 100644 --- a/src/term/pty.rs +++ b/src/term/pty.rs @@ -259,6 +259,8 @@ mod tests { folders: Vec, marks: Vec, text: String, + /// The first line's cells that carry a prompt's link. + prompt: Vec, success: bool, pid: u32, columns: usize, @@ -290,10 +292,16 @@ mod tests { 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(), @@ -327,6 +335,7 @@ mod tests { 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] diff --git a/src/term/tap.rs b/src/term/tap.rs index 2729f30..150a3c2 100644 --- a/src/term/tap.rs +++ b/src/term/tap.rs @@ -7,7 +7,7 @@ use alacritty_terminal::{ use futures::channel::mpsc::UnboundedSender; use polling::{Event, PollMode, Poller}; -use super::{marks, mouse::MouseMode, pty::PtyEvent}; +use super::{marks, mouse::MouseMode, prompts::Prompts, pty::PtyEvent}; /// The longest OSC body kept; longer ones are dropped whole. const MOST: usize = 4096; @@ -17,6 +17,7 @@ pub(crate) struct Tapped

{ pub inner: P, scanner: Osc, mouse: MouseModes, + prompts: Prompts, events: UnboundedSender, pid: u32, last: Option, @@ -28,6 +29,7 @@ impl

Tapped

{ inner, scanner: Osc::default(), mouse: MouseModes::default(), + prompts: Prompts::default(), events, pid, last: None, @@ -37,9 +39,15 @@ impl

Tapped

{ impl io::Read for Tapped

{ fn read(&mut self, buf: &mut [u8]) -> io::Result { - let count = self.inner.reader().read(buf)?; + let held = self.prompts.held(); + // A buffer with no room past the held bytes waits for the parser to empty it. + if buf.len() <= held { + return Ok(0); + } + let count = self.inner.reader().read(&mut buf[held..])?; + let read = &buf[held..held + count]; let (events, pid, last) = (&self.events, self.pid, &mut self.last); - self.scanner.feed(&buf[..count], &mut |body| { + 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() { @@ -60,13 +68,13 @@ impl io::Read for Tapped

{ log::debug!("pty {pid}: a folder report after its session closed"); } }); - if let Some(mode) = self.mouse.feed(&buf[..count]) { + if let Some(mode) = self.mouse.feed(read) { log::info!("pty {pid}: mouse {mode:?}"); if events.unbounded_send(PtyEvent::Mouse(mode)).is_err() { log::debug!("pty {pid}: a mouse mode after its session closed"); } } - Ok(count) + Ok(self.prompts.pass(buf, count)) } } @@ -118,10 +126,9 @@ enum State { Ground, Escape, Osc, - OscEscape, } -/// Finds each OSC body, `ESC ] body` ended by BEL or `ESC \`, across reads. +/// Finds each OSC body across reads, ended as vte ends it: at BEL, ESC, CAN or SUB. #[derive(Default)] pub(crate) struct Osc { state: State, @@ -151,21 +158,19 @@ impl Osc { self.overflow = false; State::Osc } - (State::Escape, 0x1b) => State::Escape, + // vte runs controls in an escape and stays there. + (State::Escape, 0x00..=0x17 | 0x19 | 0x1b..=0x1f) => State::Escape, (State::Escape, _) => State::Ground, - (State::Osc, 0x07) | (State::OscEscape, b'\\') => { + (State::Osc, 0x07 | 0x18 | 0x1a) => { self.finish(found); State::Ground } - (State::Osc | State::OscEscape, 0x18 | 0x1a) => State::Ground, - (State::Osc, 0x1b) => State::OscEscape, - (State::OscEscape, b']') => { - self.body.clear(); - self.overflow = false; - State::Osc + // ESC ends the body, then reads as an escape: `\` makes ST. + (State::Osc, 0x1b) => { + self.finish(found); + State::Escape } - (State::OscEscape, 0x1b) => State::Escape, - (State::OscEscape, _) => State::Ground, + (State::Osc, 0x00..=0x06 | 0x08..=0x17 | 0x19 | 0x1c..=0x1f) => State::Osc, (State::Osc, byte) => { if self.body.len() < MOST { self.body.push(byte); diff --git a/src/term/tap/tests.rs b/src/term/tap/tests.rs index 28921b0..5468763 100644 --- a/src/term/tap/tests.rs +++ b/src/term/tap/tests.rs @@ -12,7 +12,7 @@ fn scan(chunks: &[&[u8]]) -> Vec { } #[test] -fn finds_each_osc_body_ended_by_bel_or_st_across_reads() { +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"]); assert_eq!( scan(&[b"\x1b]7;file:", b"///b\x1b", b"\\"]), @@ -22,13 +22,15 @@ fn finds_each_osc_body_ended_by_bel_or_st_across_reads() { scan(&[b"\x1b", b"]133;A\x07\x1b]0;t\x1b\\"]), ["133;A", "0;t"] ); + assert_eq!(scan(&[b"\x1b]7;x\x18\x1b]7;y\x1a"]), ["7;x", "7;y"]); + assert_eq!(scan(&[b"\x1b]7;cut\x1b]133;D;1\x07"]), ["7;cut", "133;D;1"]); + assert_eq!(scan(&[b"\x1b]0;t\x1b[31m"]), ["0;t"]); } #[test] -fn other_sequences_and_cancels_pass() { +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!(scan(&[b"\x1b]7;file:///a\x18\x07"]).is_empty()); - assert_eq!(scan(&[b"\x1b]7;lost\x1b]133;D;1\x07"]), ["133;D;1"]); } #[test] diff --git a/src/term/view.rs b/src/term/view.rs index c5dd13f..b34f0dc 100644 --- a/src/term/view.rs +++ b/src/term/view.rs @@ -21,7 +21,7 @@ use super::{ find::Finder, frame::frame, ink::Ink, - input::{self, Find}, + input::{self, Find, NextPrompt, PreviousPrompt}, marks::Mark, mouse::MouseMode, paint::{self, Look}, @@ -370,6 +370,10 @@ impl Render for TerminalView { .on_action(cx.listener(|view, _: &Paste, _, cx| view.paste(cx))) .on_action(cx.listener(|view, _: &SelectAll, _, cx| view.select_all(cx))) .on_action(cx.listener(|view, _: &Find, window, cx| view.find(window, cx))) + .on_action( + cx.listener(|view, _: &PreviousPrompt, _, cx| view.scroll_to_prompt(true, cx)), + ) + .on_action(cx.listener(|view, _: &NextPrompt, _, cx| view.scroll_to_prompt(false, cx))) .on_any_mouse_down(cx.listener(Self::mouse_down)) .on_mouse_move(cx.listener(Self::mouse_move)) .on_scroll_wheel(cx.listener(Self::wheel)) diff --git a/tasks/acceptance.md b/tasks/acceptance.md index 8ae355e..59e1a8e 100644 --- a/tasks/acceptance.md +++ b/tasks/acceptance.md @@ -52,6 +52,7 @@ What no check reached: no real KWin drew the blur; of Wayland desktops, only Hyp | PTY through alacritty's event loop; OSC 7 and OSC 133 scanned on the reader | T04, T34 | `term::pty`, `term::tap` tests; round 3 failed at the cap, its fix stands on a red-green test | Pass | | 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 a link of its own length, split across reads at every byte, and other sequences pass as they came; a prompt's cells carry its link through SGR 0 and a resize, its input none; a prompt over two rows starts at its first; 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 keys off macOS are bound, not run | 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 |