T35: 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, as Ghostty jumps: the tap rewrites each OSC 133 mark in place into an OSC 8 link of the terminal's own, which the parser keeps in the prompt's cells through scroll, reflow and history, and links pass over; the tap's OSC scanner ends a body as vte does
CI / platforms (ubuntu-24.04) (push) Successful in 1m4s
CI / platforms (windows-latest) (push) Successful in 1m36s
CI / macos (push) Successful in 11m52s

This commit is contained in:
ZacharyZhang-NY committed 2026-10-16 23:46:58 -04:00
1 parent d3700f5278
commit 15e83f5b7e
13 files changed
+419 -27

No files matched your search

+15 -1
View File
@@ -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)),
+14 -2
View File
@@ -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<T>(term: &Term<T>, mut point: Point) -> Option<String> {
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");
+1
View File
@@ -12,6 +12,7 @@ mod marks;
pub mod mouse;
mod paint;
mod pointer;
mod prompts;
mod pty;
pub mod shell;
mod tap;
+180
View File
@@ -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<u8>,
}
/// 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<Hyperlink> {
cell.hyperlink().filter(|link| link.uri() == LINK)
}
/// The prompt `line` shows.
fn prompt<T>(term: &Term<T>, line: Line) -> Option<Hyperlink> {
term.grid()[line].into_iter().find_map(prompt_link)
}
/// Whether `line` starts a prompt: it shows one the line above does not.
fn starts<T>(term: &Term<T>, 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<T>(term: &Term<T>, up: bool) -> Option<usize> {
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<Self>) {
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;
+160
View File
@@ -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<u8> {
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<VoidListener> {
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<VoidListener> {
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<i32> = 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:?}");
}
+9
View File
@@ -259,6 +259,8 @@ mod tests {
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,
@@ -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]
+22 -17
View File
@@ -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<P> {
pub inner: P,
scanner: Osc,
mouse: MouseModes,
prompts: Prompts,
events: UnboundedSender<PtyEvent>,
pid: u32,
last: Option<PathBuf>,
@@ -28,6 +29,7 @@ impl<P> Tapped<P> {
inner,
scanner: Osc::default(),
mouse: MouseModes::default(),
prompts: Prompts::default(),
events,
pid,
last: None,
@@ -37,9 +39,15 @@ impl<P> Tapped<P> {
impl<P: EventedReadWrite> io::Read for Tapped<P> {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
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<P: EventedReadWrite> io::Read for Tapped<P> {
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);
+6 -4
View File
@@ -12,7 +12,7 @@ fn scan(chunks: &[&[u8]]) -> Vec<String> {
}
#[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]
+5 -1
View File
@@ -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))