T59: OSC 133 B stamps the line typed after a prompt with a link of its own, numbered by the commands ended, so a shell whose prompt prints nothing, as fish's empty prompt, takes clicks from its first typed cell, and fish's right prompt drawn again at each key keeps the line

This commit is contained in:
ZacharyZhang-NY committed 2026-10-19 16:09:11 -04:00
1 parent 9fa68b24a7
commit 84364ce03e
6 files changed
+247 -80

No files matched your search

+44 -24
View File
@@ -13,7 +13,7 @@ use gpui::Context;
use super::{
marks::Click,
mouse::Tracking,
prompts::{prompted, start},
prompts::{Part, part, prompted, start},
view::TerminalView,
};
@@ -92,37 +92,57 @@ pub fn arrows<T>(term: &Term<T>, click: Point) -> Option<i32> {
})
}
/// The left press, SGR, a shell that takes clicks hears for `click`, its row the screen's or counted from its prompt's first, as kitty and Ghostty send it; none on the prompt or before its end.
/// The left press, SGR, a shell that takes clicks hears for `click`, its row the screen's or counted from its prompt's first, as kitty and Ghostty send it; none on a prompt or before the input.
pub fn press<T>(term: &Term<T>, click: Point, relative: bool) -> Option<Vec<u8>> {
if term.mode().contains(TermMode::ALT_SCREEN) {
return None;
}
let grid = term.grid();
let cursor = grid.cursor.point;
let first = start(term, cursor.line.0)?;
let cell = |line: i32, column: usize| &grid[Line(line)][Column(column)];
// The prompt ends at its last cell before the cursor; an empty one, at its first row's start.
let end = (first..=cursor.line.0)
.flat_map(|line| {
let width = if line == cursor.line.0 {
cursor.column.0
} else {
term.columns()
};
(0..width).map(move |column| (line, column))
})
.filter(|&(line, column)| prompted(cell(line, column)))
.last();
let cell = |(line, column): (i32, usize)| &grid[Line(line)][Column(column)];
let cursor = (grid.cursor.point.line.0, grid.cursor.point.column.0);
// ponytail: scans of the rows on the way; index the terminal's own links if long histories lag.
let back = (grid.topmost_line().0..=grid.bottommost_line().0)
.rev()
.flat_map(|line| (0..term.columns()).rev().map(move |column| (line, column)));
let end = (back.clone())
.filter(|&at| at < cursor)
.find(|&at| prompted(cell(at)));
// The newest line typed, its first cell; a shell drawing its prompts again keeps its number.
let mut typed = back.filter_map(|at| match part(cell(at)) {
Some(Part::Input(line)) => Some((at, line)),
_ => None,
});
let input = typed.next().map(|(last, line)| {
let first =
(typed.take_while(|&(_, other)| other == line).last()).map_or(last, |(first, _)| first);
(first, line)
});
// An older line typed between a prompt and the input leaves that prompt behind.
let older = |end: (i32, usize), (input, line): ((i32, usize), u32)| {
(end.0..=input.0)
.flat_map(|row| {
let from = if row == end.0 { end.1 + 1 } else { 0 };
let to = if row == input.0 {
input.1
} else {
term.columns()
};
(from..to).map(move |column| (row, column))
})
.any(|at| matches!(part(cell(at)), Some(Part::Input(other)) if other != line))
};
let at = (click.line.0, click.column.0);
let before = end.map_or(at.0 < first, |end| at <= end);
if before || prompted(cell(at.0, at.1)) {
// Input starts past the prompt's last cell before the cursor, or at its first typed cell where no prompt of its own came before it.
let (before, first) = match (end, input) {
(Some(end), Some(typed)) if typed.0 > end && older(end, typed) => (at < typed.0, typed.0.0),
(Some(end), _) => (at <= end, start(term, end.0)?),
(None, Some((input, _))) => (at < input, input.0),
(None, None) => return None,
};
if before || prompted(cell(at)) {
return None;
}
let row = if relative {
click.line.0 - first
} else {
click.line.0
} + 1;
let row = if relative { at.0 - first } else { at.0 } + 1;
if row < 1 {
return None;
}
+69 -2
View File
@@ -128,7 +128,7 @@ fn a_shell_that_takes_clicks_hears_the_press_where_it_landed() {
);
let right = term(
20,
&format!("{marked}ls\x1b7\x1b[1;17H\x1b]133;A\x07~/a\x1b]133;B\x07\x1b8"),
&format!("{marked}\x1b[1;17H\x1b]133;A\x07~/a\x1b]133;B\x07\x1b[1;3Hls"),
);
assert_eq!(
sent(&right, click(0, 18), false),
@@ -139,9 +139,10 @@ fn a_shell_that_takes_clicks_hears_the_press_where_it_landed() {
sent(&right, click(0, 3), false).as_deref(),
Some("\x1b[<0;4;1M")
);
assert_eq!(sent(&right, click(0, 1), false), None, "on the left prompt");
let empty = term(
20,
"\x1b7\x1b[1;17H\x1b]133;A;click_events=1\x07~/a\x1b]133;B\x07\x1b8ls",
"\x1b[1;17H\x1b]133;A;click_events=1\x07~/a\x1b]133;B\x07\x1b[1;1Hls",
);
let left = sent(&empty, click(0, 1), false);
assert_eq!(left.as_deref(), Some("\x1b[<0;2;1M"), "only a right prompt");
@@ -186,3 +187,69 @@ fn input_reads_the_line_its_prompt_started() {
Shell::Reading(Click::Relative)
);
}
#[test]
fn a_prompt_that_prints_nothing_starts_the_input_at_its_first_typed_cell() {
let empty = "\x1b]133;A;click_events=1\x07\x1b]133;B\x07";
let typed = term(20, &format!("echo hi\r\nhi\r\n{empty}abcdef"));
let at = sent(&typed, click(2, 3), false);
assert_eq!(at.as_deref(), Some("\x1b[<0;4;3M"), "on the d");
let relative = sent(&typed, click(2, 3), true);
assert_eq!(
relative.as_deref(),
Some("\x1b[<0;4;1M"),
"from the input's row"
);
assert_eq!(sent(&typed, click(1, 1), false), None, "output above");
assert_eq!(
sent(&typed, click(0, 2), false),
None,
"the line run before"
);
let moved = term(20, &format!("{empty}abcdef\x1b[6D"));
let back = sent(&moved, click(0, 4), false);
assert_eq!(
back.as_deref(),
Some("\x1b[<0;5;1M"),
"the cursor at the start"
);
let blank = term(20, &format!("echo hi\r\n{empty}"));
assert_eq!(
sent(&blank, click(0, 2), false),
None,
"nothing typed, nothing after"
);
}
#[test]
fn a_prompt_drawn_again_after_the_typed_text_keeps_its_line() {
let marked = "\x1b]133;A;click_events=1\x07";
let right = format!("\x1b[1;17H{marked}RIGHT\x1b]133;B\x07\x1b[1;7H");
let repainted = term(24, &format!("{marked}\x1b]133;B\x07abcdef{right}"));
let at = sent(&repainted, click(0, 3), false);
assert_eq!(
at.as_deref(),
Some("\x1b[<0;4;1M"),
"fish's right prompt again"
);
assert_eq!(
sent(&repainted, click(0, 17), false),
None,
"on that prompt"
);
let ran = "\x1b]133;C\x07\x1b]133;D;0\x07";
let after = format!("$ \x1b]133;B\x07echo hi{ran}\r\nhi\r\n{marked}\x1b]133;B\x07abcdef");
let below = term(20, &format!("{marked}{after}"));
assert_eq!(sent(&below, click(1, 1), false), None, "the output between");
assert_eq!(
sent(&below, click(0, 4), false),
None,
"the line run before"
);
let typed = sent(&below, click(2, 2), true);
assert_eq!(
typed.as_deref(),
Some("\x1b[<0;3;1M"),
"from the input's own row"
);
}
+73 -35
View File
@@ -13,6 +13,24 @@ pub(crate) const LINK: &str = "\u{7f}";
/// What a link's id starts with when it carries a prompt's number: the prompt's own link, or a program's inside it.
const NUMBERED: char = '\u{7f}';
/// What ends the id of a link on the line typed after a prompt.
const TYPED: char = 'i';
/// What the terminal's own link stamps: a prompt's cells, by its number, or the line typed after it, OSC 133 B to C, by the commands ended before it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum Part {
Prompt(u32),
Input(u32),
}
/// How a mark moves the link: a prompt starts, its input starts, or the command ends it.
#[derive(Debug, PartialEq)]
enum Edge {
Prompt,
Input,
End,
}
/// What ends a prompt's link.
const CLOSE: &[u8] = b"\x1b]8;;\x07";
@@ -53,12 +71,14 @@ pub(crate) struct Prompts {
overflow: bool,
/// The held OSC's ESC has gone on: it ended the OSC before.
sent: bool,
/// The prompt open now, from its start mark to its end.
prompt: Option<u32>,
/// The prompt or input open now, from its mark to the next.
open: Option<Part>,
/// The target of a program's link open now, to open it again with a prompt's number and without.
target: Option<Vec<u8>>,
/// The last prompt's number.
count: u32,
/// Commands ended, C or D, so every prompt a shell draws again on one line shares its input's number.
ended: u32,
/// What goes to the parser from the last read.
out: Vec<u8>,
}
@@ -116,7 +136,7 @@ impl Prompts {
(State::Escape { .. }, _) => {
// A full reset clears the link the parser writes with.
if byte == b'c' {
(self.prompt, self.target) = (None, None);
(self.open, self.target) = (None, None);
}
self.held.push(byte);
self.release();
@@ -175,7 +195,7 @@ impl Prompts {
let length = self.body.len().min(prefix.len());
self.body[..length] == prefix[..length]
};
!self.overflow && (fits(b"133;") || (self.prompt.is_some() && fits(b"8;")))
!self.overflow && (fits(b"133;") || (self.open.is_some() && fits(b"8;")))
}
fn release(&mut self) {
@@ -186,27 +206,31 @@ impl Prompts {
fn end(&mut self, end: Option<u8>) -> bool {
let rewrite = if self.overflow {
None
} else if let Some(opens) = self.body.strip_prefix(b"133;").and_then(mark) {
if opens {
self.count = self.count.wrapping_add(1);
self.prompt = Some(self.count);
// A prompt inside a program's link carries its number on that link.
Some(match &self.target {
Some(target) => link(self.count, target),
None => link(self.count, LINK.as_bytes()),
})
} else {
match (self.prompt.take(), &self.target) {
(Some(_), Some(target)) => Some(plain(target)),
(Some(_), None) => Some(CLOSE.to_vec()),
(None, _) => None,
} else if let Some(edge) = self.body.strip_prefix(b"133;").and_then(mark) {
// A prompt or input inside a program's link carries its number on that link.
let target = self.target.as_deref().unwrap_or(LINK.as_bytes());
match (edge, self.open) {
(Edge::Prompt, _) => {
self.count = self.count.wrapping_add(1);
self.open = Some(Part::Prompt(self.count));
Some(link(Part::Prompt(self.count), target))
}
(Edge::Input, Some(Part::Prompt(_))) => {
self.open = Some(Part::Input(self.ended));
Some(link(Part::Input(self.ended), target))
}
(Edge::Input, _) => None,
(Edge::End, open) => {
self.ended = self.ended.wrapping_add(1);
self.open = None;
open.map(|_| self.target.as_deref().map_or_else(|| CLOSE.to_vec(), plain))
}
}
} else if let Some((opens, target)) = user(&self.body) {
self.target = opens.then(|| target.to_vec());
// Inside a prompt a program's link carries the prompt's number; its end takes the prompt's link up again.
// Inside a prompt or input a program's link carries its number; its end takes the terminal's link up again.
let target = if opens { target } else { LINK.as_bytes() };
self.prompt.map(|number| link(number, target))
self.open.map(|part| link(part, target))
} else {
None
};
@@ -232,15 +256,16 @@ impl Prompts {
}
}
/// Whether a mark's body opens a prompt or ends one, as `marks::read` reads its letter; none for neither.
fn mark(body: &[u8]) -> Option<bool> {
/// How a mark's body moves the link, as `marks::read` reads its letter; none for no mark.
fn mark(body: &[u8]) -> Option<Edge> {
let (&letter, rest) = body.split_first()?;
if !rest.is_empty() && rest[0] != b';' {
return None;
}
match letter {
b'A' | b'N' => Some(true),
b'B' | b'I' | b'C' | b'D' => Some(false),
b'A' | b'N' => Some(Edge::Prompt),
b'B' | b'I' => Some(Edge::Input),
b'C' | b'D' => Some(Edge::End),
_ => None,
}
}
@@ -255,9 +280,13 @@ fn user(body: &[u8]) -> Option<(bool, &[u8])> {
Some((opens, target))
}
/// A link to `target` whose id carries prompt `number`.
fn link(number: u32, target: &[u8]) -> Vec<u8> {
let mut link = format!("\x1b]8;id={NUMBERED}{number};").into_bytes();
/// A link to `target` whose id carries `part`'s prompt number.
fn link(part: Part, target: &[u8]) -> Vec<u8> {
let id = match part {
Part::Prompt(number) => format!("{NUMBERED}{number}"),
Part::Input(number) => format!("{NUMBERED}{number}{TYPED}"),
};
let mut link = format!("\x1b]8;id={id};").into_bytes();
link.extend_from_slice(target);
link.push(0x07);
link
@@ -271,17 +300,26 @@ fn plain(target: &[u8]) -> Vec<u8> {
link
}
/// Whether `cell` belongs to a prompt, its link's id carrying a prompt's number.
pub(super) fn prompted(cell: &Cell) -> bool {
cell.hyperlink()
.is_some_and(|link| link.id().starts_with(NUMBERED))
/// The prompt or input `cell` belongs to, as its link's id says.
pub(super) fn part(cell: &Cell) -> Option<Part> {
let link = cell.hyperlink()?;
let number = link.id().strip_prefix(NUMBERED)?;
match number.strip_suffix(TYPED) {
Some(typed) => typed.parse().ok().map(Part::Input),
None => number.parse().ok().map(Part::Prompt),
}
}
/// The number of the prompt `row` shows, as a link's id carries it.
/// Whether `cell` belongs to a prompt.
pub(super) fn prompted(cell: &Cell) -> bool {
matches!(part(cell), Some(Part::Prompt(_)))
}
/// The number of the prompt `row` shows.
fn prompt(row: &Row<Cell>) -> Option<u32> {
row.into_iter().find_map(|cell| {
let link = cell.hyperlink()?;
link.id().strip_prefix(NUMBERED)?.parse().ok()
row.into_iter().find_map(|cell| match part(cell) {
Some(Part::Prompt(number)) => Some(number),
_ => None,
})
}
+58 -16
View File
@@ -40,7 +40,12 @@ fn target_of(term: &Term<VoidListener>, line: i32, column: usize) -> Option<Stri
fn a_mark_becomes_its_prompt_s_numbered_link() {
assert_eq!(
passed(&[b"\x1b]133;A\x07$ \x1b]133;B\x1b\\x"]),
b"\x1b]8;id=\x7f1;\x7f\x07$ \x1b]8;;\x07\x1b\\x"
b"\x1b]8;id=\x7f1;\x7f\x07$ \x1b]8;id=\x7f0i;\x7f\x07\x1b\\x"
);
assert_eq!(
passed(&[b"\x1b]133;A\x07$ \x1b]133;B\x07ls\x1b]133;C\x07out"]),
b"\x1b]8;id=\x7f1;\x7f\x07$ \x1b]8;id=\x7f0i;\x7f\x07ls\x1b]8;;\x07out",
"the input runs from B to C"
);
assert_eq!(
passed(&[b"\x1b]133;A;k=i\x07\x1b]133;D;1\x18"]),
@@ -63,7 +68,11 @@ fn the_rewrite_reads_a_mark_as_the_session_hears_it() {
"A", "A;k=i", "N", "B", "I", "C;x", "D;1", "D;abc", "D", "", "AB", "L", "P;k=i", "Q", "a",
];
for body in bodies {
let heard = marks::read(body.as_bytes()).map(|mark| matches!(mark, Mark::Prompt(_)));
let heard = marks::read(body.as_bytes()).map(|mark| match mark {
Mark::Prompt(_) => Edge::Prompt,
Mark::Input => Edge::Input,
Mark::Output | Mark::Done(_) => Edge::End,
});
assert_eq!(mark(body.as_bytes()), heard, "{body}");
}
}
@@ -154,14 +163,11 @@ fn a_program_s_link_inside_a_prompt_carries_its_number() {
&open.grid()[Line(0)][Column(1)],
&open.grid()[Line(0)][Column(2)],
);
assert!(
z.hyperlink()
.is_some_and(|link| !link.id().starts_with(NUMBERED))
);
assert_eq!(part(z), Some(Part::Input(0)), "the input inside it");
assert_eq!(
target_of(&open, 0, 2).as_deref(),
Some("file:///x"),
"its own again past the prompt"
"its own target past the prompt"
);
assert!(
y.hyperlink()
@@ -178,16 +184,12 @@ fn a_long_target_numbers_a_prompt_in_either_order() {
for bytes in [before, inside] {
let term = parsed(bytes.as_bytes(), 8, 2);
let row = &term.grid()[Line(0)];
let numbered = |column: usize| {
row[Column(column)]
.hyperlink()
.is_some_and(|link| link.id().starts_with(NUMBERED))
};
assert_eq!(prompt(row), Some(1), "{bytes:?}");
assert!(
numbered(1) && !numbered(2),
prompted(&row[Column(1)]) && !prompted(&row[Column(2)]),
"the prompt's cell alone: {bytes:?}"
);
assert_eq!(part(&row[Column(2)]), Some(Part::Input(0)), "{bytes:?}");
assert_eq!(target_of(&term, 0, 2).as_deref(), Some(target.as_str()));
}
}
@@ -215,7 +217,7 @@ fn controls_after_an_osc_s_closing_escape_run_where_vte_runs_them() {
fn del_and_high_bytes_in_an_escape_go_on_after_it() {
assert_eq!(
passed(&[b"\x1b\x7f]133;A\x07$\x1b\xff", b"]133;B\x07"]),
b"\x1b\x7f]8;id=\x7f1;\x7f\x07$\x1b\xff]8;;\x07"
b"\x1b\x7f]8;id=\x7f1;\x7f\x07$\x1b\xff]8;id=\x7f0i;\x7f\x07"
);
}
@@ -228,7 +230,7 @@ fn controls_vte_runs_inside_an_escape_go_on_before_the_link() {
}
#[test]
fn a_prompt_s_cells_carry_its_link_through_sgr_0_and_its_input_none() {
fn a_prompt_s_cells_carry_its_link_through_sgr_0_and_its_input_its_own() {
let term = parsed(
b"x\r\n\x1b]133;A\x07\x1b[31m$\x1b[0m \x1b]133;B\x07ls",
8,
@@ -237,7 +239,17 @@ fn a_prompt_s_cells_carry_its_link_through_sgr_0_and_its_input_none() {
assert_eq!(prompt(&term.grid()[Line(0)]), None);
assert_eq!(prompt(&term.grid()[Line(1)]), Some(1));
assert_eq!(target_of(&term, 1, 1).as_deref(), Some(LINK));
assert_eq!(target_of(&term, 1, 2), None, "input");
assert_eq!(
part(&term.grid()[Line(1)][Column(2)]),
Some(Part::Input(0)),
"input"
);
let ran = parsed(
b"\x1b]133;A\x07$\x1b]133;B\x07ls\x1b]133;C\x07\r\nout",
8,
2,
);
assert_eq!(target_of(&ran, 1, 0), None, "output");
}
#[test]
@@ -329,3 +341,33 @@ fn a_prompt_keeps_its_start_through_a_resize() {
let top = term.topmost_line().0 + first.expect("prompt 1") as i32;
assert_eq!(offset as i32, -top);
}
#[test]
fn input_carries_the_commands_ended_before_it() {
let line = b"\x1b]133;A\x07$\x1b]133;B\x07ab\x1b]133;A\x07R\x1b]133;B\x07c";
let term = parsed(line, 8, 2);
let at = |column: usize| part(&term.grid()[Line(0)][Column(column)]);
assert_eq!(
(at(0), at(1), at(3)),
(
Some(Part::Prompt(1)),
Some(Part::Input(0)),
Some(Part::Prompt(2))
)
);
assert_eq!(
at(4),
Some(Part::Input(0)),
"a prompt drawn again keeps the line"
);
let next = parsed(
b"\x1b]133;A\x07$\x1b]133;B\x07a\x1b]133;C\x07\x1b]133;D\x07\x1b]133;A\x07$\x1b]133;B\x07b",
8,
2,
);
assert_eq!(
part(&next.grid()[Line(0)][Column(3)]),
Some(Part::Input(2)),
"C and D each end it"
);
}