mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 00:02:23 +00:00
fix(terminal): keep pasted text out of the shell's binding table (#660)
Review follow-up on the previous commit, which sends a plain single line raw so fish's `expand-abbr` (and every other input-time expansion) runs. Raw delivery is not selective: it hands the line to the shell's binding table, and printable keys live there too. Three consequences, each reproduced on a pty. `TerminalView::paste` inserts the clipboard straight into the editor's buffer, so a pasted line was going out raw as well -- and bracketed paste's contract is that what was pasted is what runs. A fish user with `abbr -a l 'ls -la'` who pastes `l /tmp` out of their notes would have run `ls -la /tmp`. `CmdEditor` now carries a paste mark: `insert_pasted` sets it, `clear` -- which runs on every submit and every handoff -- resets it, and every edit in between keeps it, including the wholesale `set` a completion or ghost suggestion does over the pasted text. Dropping the mark there would reopen the hole; keeping it only ever costs that one line its shell-side expansion. `char::is_control` is the right predicate for what it rules out (a Tab completes, a `^U` kills, a `^C` abandons) but it cannot rule out a rebound printable key, and it should not: that is the mechanism. Typed lines therefore do pick up whatever the user has bound. `bindkey . rationalise-dot` turns a typed `echo a...b` into `echo a../..b`, and zsh-autopair -- which deliberately disables itself inside a bracketed paste -- now sees an unbalanced quote. That is the same delivery those users already get with `prompt_editor: false`, so this makes tty7 agree with the shell they configured instead of diverging from it, but it is a real change and the PR names the configs. The 512-byte bound was justified as a knee in a measurement whose timer included the plugin sourcing. Measured properly -- prelude settled before the timer, completion marker printed by a prelude-defined function so it never appears in the echoed line, 11 iterations, medians -- a paste is flat in length and raw is linear from the first byte, with no knee anywhere. The bound is a ceiling on added submit latency, not a discontinuity, and the comment now says so.
This commit is contained in:
@@ -6,6 +6,7 @@ pub struct CmdEditor {
|
||||
undo: Vec<(Vec<char>, usize)>,
|
||||
redo: Vec<(Vec<char>, usize)>,
|
||||
kill: String,
|
||||
pasted: bool,
|
||||
}
|
||||
|
||||
const UNDO_LIMIT: usize = 200;
|
||||
@@ -80,6 +81,31 @@ impl CmdEditor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert clipboard (or dropped-file) text, and remember that this line has
|
||||
/// carried some.
|
||||
///
|
||||
/// The mark is what lets the submit path keep bracketed paste's contract —
|
||||
/// what was pasted is what runs, with no shell-side rewriting — for a line
|
||||
/// that came from outside, while a line the user typed goes to the shell as
|
||||
/// typed. See `submit_bytes` in the terminal view.
|
||||
pub fn insert_pasted(&mut self, s: &str) {
|
||||
self.pasted = true;
|
||||
self.insert_str(s);
|
||||
}
|
||||
|
||||
/// Whether clipboard text has been inserted into the line being edited.
|
||||
///
|
||||
/// Deliberately sticky for the life of the buffer rather than tracked per
|
||||
/// character: `clear` runs on every submit and every handoff, so the mark
|
||||
/// is already scoped to exactly one line, and every edit that survives
|
||||
/// inside that line — a completion accepted over the pasted text, a ghost
|
||||
/// suggestion, a kill and yank — keeps it. Erring toward "pasted" only ever
|
||||
/// costs the shell-side expansion this line might have had; erring the
|
||||
/// other way would hand the shell's binding table something the user pasted.
|
||||
pub fn pasted(&self) -> bool {
|
||||
self.pasted
|
||||
}
|
||||
|
||||
pub fn prepend_str(&mut self, s: &str) {
|
||||
if s.is_empty() {
|
||||
return;
|
||||
@@ -391,6 +417,7 @@ impl CmdEditor {
|
||||
self.anchor = None;
|
||||
self.undo.clear();
|
||||
self.redo.clear();
|
||||
self.pasted = false;
|
||||
}
|
||||
|
||||
pub fn set(&mut self, text: &str) {
|
||||
@@ -833,4 +860,34 @@ mod tests {
|
||||
e.set_with_cursor("hi", 99);
|
||||
assert_eq!((e.text().as_str(), e.cursor()), ("hi", 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_paste_mark_lasts_exactly_one_line() {
|
||||
let mut e = ed("git ", 4);
|
||||
assert!(!e.pasted(), "a typed line carries no mark");
|
||||
e.insert_str("status");
|
||||
assert!(!e.pasted());
|
||||
|
||||
e.insert_pasted(" --short");
|
||||
assert!(e.pasted());
|
||||
|
||||
// Every edit that leaves the pasted text inside this line keeps the
|
||||
// mark, including the ones that rewrite the buffer wholesale on top of
|
||||
// it -- a completion accepted over the paste, a ghost suggestion.
|
||||
e.backspace();
|
||||
e.delete_word_left();
|
||||
e.set("git status --shor");
|
||||
e.set_with_cursor("git status --short", 18);
|
||||
assert!(
|
||||
e.pasted(),
|
||||
"losing the mark mid-line would hand the paste to the shell's bindings"
|
||||
);
|
||||
|
||||
// `clear` runs on every submit and every handoff, so the next line
|
||||
// starts clean and gets the typed delivery again.
|
||||
e.clear();
|
||||
assert!(!e.pasted());
|
||||
e.insert_str("j build");
|
||||
assert!(!e.pasted());
|
||||
}
|
||||
}
|
||||
|
||||
+95
-49
@@ -697,8 +697,8 @@ fn paste_bytes(text: &str, bracketed: bool) -> Vec<u8> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A line the shell can be handed byte for byte, exactly as if it had been
|
||||
/// typed at its own prompt.
|
||||
/// A line the shell can be handed byte for byte, as if it had been typed at its
|
||||
/// own prompt.
|
||||
///
|
||||
/// Control characters are what rule a line out. Under bracketed paste the shell
|
||||
/// inserts every byte literally; delivered raw, each one runs through the line
|
||||
@@ -707,33 +707,47 @@ fn paste_bytes(text: &str, bracketed: bool) -> Vec<u8> {
|
||||
/// rest, embedded newlines included — a multi-line command still goes as one
|
||||
/// paste, which is what [`submit_bytes`] was built for.
|
||||
///
|
||||
/// The length bound is a cost ceiling, not a correctness one. Raw bytes make
|
||||
/// the shell redraw as it consumes them; measured against zsh 5.9 +
|
||||
/// zsh-syntax-highlighting + zsh-autosuggestions, submit-to-output stays at the
|
||||
/// bracketed 85ms up to ~300 bytes and then climbs — 233ms at 600, 390ms at
|
||||
/// 1200, 735ms at 2400. 512 sits at the knee: every command anyone types by
|
||||
/// hand takes the typed path, and a pasted-in wall of text keeps the flat cost
|
||||
/// the paste framing exists to give it.
|
||||
/// Printable keys are deliberately *not* excluded, and cannot be: a line editor
|
||||
/// binding one is exactly the mechanism this exists to reach. fish binds space
|
||||
/// to `expand-abbr`; zsh users bind `.` to `rationalise-dot` and quotes to
|
||||
/// zsh-autopair. Reaching the first means reaching the others, which is why the
|
||||
/// caller keeps pasted text away from this path entirely.
|
||||
///
|
||||
/// The length bound is a cost ceiling, not a correctness one, and it is a
|
||||
/// policy choice rather than a discontinuity in the data. A paste lands in one
|
||||
/// go; raw bytes make the shell's line editor redraw as it consumes them, so
|
||||
/// the cost is linear in length from the very first byte — measured on a pty it
|
||||
/// rises smoothly, with no knee to put a bound at. What 512 buys is a ceiling
|
||||
/// on how much submit latency that can add: the slowest configuration measured
|
||||
/// (zsh with zsh-syntax-highlighting and zsh-autosuggestions, both of which
|
||||
/// re-run per keystroke) costs about a quarter of a millisecond per byte, so a
|
||||
/// hand-typed command pays single-digit-to-tens of milliseconds and a pasted-in
|
||||
/// wall of text keeps the flat cost the paste framing exists to give it. See
|
||||
/// the PR for #660 for the measured curve.
|
||||
fn types_cleanly(line: &str) -> bool {
|
||||
line.len() <= 512 && !line.chars().any(char::is_control)
|
||||
}
|
||||
|
||||
/// Build the byte sequence that submits the local editor's buffer to the shell.
|
||||
///
|
||||
/// A plain single-line command goes raw, no paste markers: the shell's own
|
||||
/// reader then sees the same bytes a human typing would produce, so its
|
||||
/// input-time expansions run — fish abbreviations (#660), zsh `magic-space`,
|
||||
/// any readline macro bound to a printable key. Inside a bracketed paste none
|
||||
/// of that fires; fish's expand-on-execute only reaches the token under the
|
||||
/// cursor, so `j` expanded but `j build` ran literally.
|
||||
/// A plain single-line command the user *typed* goes raw, no paste markers: the
|
||||
/// shell's own reader then sees the same bytes typing at its prompt would
|
||||
/// produce, so its input-time expansions run — fish abbreviations (#660), zsh
|
||||
/// `magic-space`, a readline macro on a printable key. Inside a bracketed paste
|
||||
/// none of that fires; fish's expand-on-execute only reaches the token under
|
||||
/// the cursor, so `j` expanded but `j build` ran literally.
|
||||
///
|
||||
/// Everything else keeps the paste framing. A multi-line command goes in as one
|
||||
/// paste and one CR, so it costs one prompt cycle instead of one per line —
|
||||
/// preexec, the user's precmd chain, a syntax-highlight pass over the whole
|
||||
/// buffer — and zle keeps the embedded newlines in its buffer, so backslash /
|
||||
/// open-quote continuation and heredocs still parse as one unit. A line
|
||||
/// carrying control characters goes as a paste because raw delivery would let
|
||||
/// the shell's binding table act on them.
|
||||
/// `pasted` is what keeps that from rewriting text the user did not type. A
|
||||
/// paste's contract is that what went in is what runs, and a fish user with
|
||||
/// `abbr -a l 'ls -la'` pasting `l /tmp` from their notes must not get
|
||||
/// `ls -la /tmp`. So a line that has carried clipboard content keeps the paste
|
||||
/// framing whatever else is true of it — see [`CmdEditor::pasted`].
|
||||
///
|
||||
/// Multi-line and control characters keep it too. A multi-line command goes in
|
||||
/// as one paste and one CR, so it costs one prompt cycle instead of one per
|
||||
/// line — preexec, the user's precmd chain, a syntax-highlight pass over the
|
||||
/// whole buffer — and zle keeps the embedded newlines in its buffer, so
|
||||
/// backslash / open-quote continuation and heredocs still parse as one unit.
|
||||
///
|
||||
/// ESC is stripped either way (unlike the paste path): clipboard text carrying
|
||||
/// its own `ESC[201~` could otherwise close the paste early and have the rest
|
||||
@@ -741,14 +755,14 @@ fn types_cleanly(line: &str) -> bool {
|
||||
///
|
||||
/// An empty buffer skips the markers: zsh's `bracketed-paste-magic` (which
|
||||
/// oh-my-zsh turns on) errors on a paste with nothing between them.
|
||||
fn submit_bytes(line: &str, bracketed: bool) -> Vec<u8> {
|
||||
fn submit_bytes(line: &str, bracketed: bool, pasted: bool) -> Vec<u8> {
|
||||
let clean: String = line
|
||||
.replace("\r\n", "\n")
|
||||
.chars()
|
||||
.filter(|&c| c != '\x1b')
|
||||
.map(|c| if c == '\r' { '\n' } else { c })
|
||||
.collect();
|
||||
let framed = bracketed && !clean.is_empty() && !types_cleanly(&clean);
|
||||
let framed = bracketed && !clean.is_empty() && (pasted || !types_cleanly(&clean));
|
||||
let mut bytes = paste_bytes(&clean, framed);
|
||||
bytes.push(b'\r');
|
||||
bytes
|
||||
@@ -2804,7 +2818,7 @@ impl TerminalView {
|
||||
self.jump_to_prompt();
|
||||
if self.input_active() {
|
||||
let trimmed = text.strip_suffix('\n').unwrap_or(&text);
|
||||
self.cmd.insert_str(trimmed);
|
||||
self.cmd.insert_pasted(trimmed);
|
||||
self.history_nav = None;
|
||||
self.editor_goal_col = None;
|
||||
self.close_completion();
|
||||
@@ -4140,7 +4154,8 @@ impl TerminalView {
|
||||
.lock()
|
||||
.mode()
|
||||
.contains(TermMode::BRACKETED_PASTE);
|
||||
self.terminal.write(submit_bytes(&line, bracketed));
|
||||
let pasted = self.cmd.pasted();
|
||||
self.terminal.write(submit_bytes(&line, bracketed, pasted));
|
||||
self.cmd.clear();
|
||||
self.cursor_visible = true;
|
||||
self.jump_to_prompt();
|
||||
@@ -8393,10 +8408,10 @@ mod tests {
|
||||
#[test]
|
||||
fn submit_bytes_sends_a_multi_line_command_as_one_bracketed_paste() {
|
||||
assert_eq!(
|
||||
submit_bytes("echo a\necho b\necho c", true),
|
||||
submit_bytes("echo a\necho b\necho c", true, false),
|
||||
b"\x1b[200~echo a\necho b\necho c\x1b[201~\r".to_vec()
|
||||
);
|
||||
let out = submit_bytes("a\nb\nc\nd", true);
|
||||
let out = submit_bytes("a\nb\nc\nd", true, false);
|
||||
assert_eq!(out.iter().filter(|&&b| b == b'\r').count(), 1);
|
||||
}
|
||||
|
||||
@@ -8404,80 +8419,111 @@ mod tests {
|
||||
fn submit_bytes_types_a_plain_single_line_instead_of_pasting_it() {
|
||||
// #660: inside a bracketed paste fish never runs `expand-abbr`, so an
|
||||
// abbreviation with arguments reached the shell verbatim and `j build`
|
||||
// died as "command not found". Raw bytes are what a human typing
|
||||
// produces, and that is the delivery every input-time expansion --
|
||||
// fish abbreviations, zsh magic-space -- is bound to.
|
||||
assert_eq!(submit_bytes("j build", true), b"j build\r".to_vec());
|
||||
// died as "command not found". Raw bytes are what typing produces, and
|
||||
// that is the delivery every input-time expansion -- fish
|
||||
// abbreviations, zsh magic-space -- is bound to.
|
||||
assert_eq!(submit_bytes("j build", true, false), b"j build\r".to_vec());
|
||||
assert_eq!(
|
||||
submit_bytes("echo 'a b' | cat", true),
|
||||
submit_bytes("echo 'a b' | cat", true, false),
|
||||
b"echo 'a b' | cat\r".to_vec()
|
||||
);
|
||||
// Non-ASCII is text, not a control character.
|
||||
assert_eq!(submit_bytes("echo 中文", true), "echo 中文\r".as_bytes());
|
||||
assert_eq!(
|
||||
submit_bytes("echo 中文", true, false),
|
||||
"echo 中文\r".as_bytes()
|
||||
);
|
||||
|
||||
// A control character would be acted on by the shell's binding table
|
||||
// rather than inserted -- a Tab completes, a ^U kills the line -- so
|
||||
// those keep the paste framing.
|
||||
assert_eq!(
|
||||
submit_bytes("echo a\tb", true),
|
||||
submit_bytes("echo a\tb", true, false),
|
||||
b"\x1b[200~echo a\tb\x1b[201~\r".to_vec()
|
||||
);
|
||||
assert_eq!(
|
||||
submit_bytes("echo a\x15b", true),
|
||||
submit_bytes("echo a\x15b", true, false),
|
||||
b"\x1b[200~echo a\x15b\x1b[201~\r".to_vec()
|
||||
);
|
||||
|
||||
// Past the length bound the flat cost of a paste wins over replaying
|
||||
// the shell's redraw per byte.
|
||||
// Past the length bound the flat cost of a paste wins over the shell's
|
||||
// per-byte redraw. The bound is a latency ceiling, not a knee in the
|
||||
// curve -- see `types_cleanly`.
|
||||
let at_bound = format!("echo {}", "y".repeat(507));
|
||||
assert_eq!(at_bound.len(), 512);
|
||||
assert_eq!(
|
||||
submit_bytes(&at_bound, true),
|
||||
submit_bytes(&at_bound, true, false),
|
||||
[at_bound.as_bytes(), b"\r"].concat()
|
||||
);
|
||||
let over_bound = format!("echo {}", "y".repeat(508));
|
||||
assert_eq!(over_bound.len(), 513);
|
||||
assert_eq!(
|
||||
submit_bytes(&over_bound, true),
|
||||
submit_bytes(&over_bound, true, false),
|
||||
[b"\x1b[200~", over_bound.as_bytes(), b"\x1b[201~\r"].concat()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submit_bytes_keeps_pasted_text_inside_a_bracketed_paste() {
|
||||
// A paste's contract is that what went in is what runs. The typed path
|
||||
// hands the line to the shell's binding table, and a printable key can
|
||||
// be bound there: a fish user with `abbr -a l 'ls -la'` who pastes
|
||||
// `l /tmp` out of their notes must not run `ls -la /tmp`, and a zsh
|
||||
// user with `bindkey . rationalise-dot` must not have a pasted
|
||||
// `echo a...b` become `echo a../..b`. Both were reproduced on a pty.
|
||||
assert_eq!(
|
||||
submit_bytes("l /tmp", true, true),
|
||||
b"\x1b[200~l /tmp\x1b[201~\r".to_vec()
|
||||
);
|
||||
assert_eq!(
|
||||
submit_bytes("echo a...b", true, true),
|
||||
b"\x1b[200~echo a...b\x1b[201~\r".to_vec()
|
||||
);
|
||||
// Same text typed rather than pasted takes the typed path -- that is
|
||||
// the whole point, and it is the same delivery the user's own shell
|
||||
// prompt would have given it.
|
||||
assert_eq!(submit_bytes("l /tmp", true, false), b"l /tmp\r".to_vec());
|
||||
// A shell with no bracketed paste has no framing to fall back on, so
|
||||
// the mark changes nothing there.
|
||||
assert_eq!(submit_bytes("l /tmp", false, true), b"l /tmp\r".to_vec());
|
||||
// An empty buffer still skips the markers, pasted or not.
|
||||
assert_eq!(submit_bytes("", true, true), b"\r".to_vec());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submit_bytes_falls_back_to_per_line_cr_without_bracketed_paste() {
|
||||
assert_eq!(submit_bytes("a\nb", false), b"a\rb\r".to_vec());
|
||||
assert_eq!(submit_bytes("a\r\nb", false), b"a\rb\r".to_vec());
|
||||
assert_eq!(submit_bytes("a\nb", false, false), b"a\rb\r".to_vec());
|
||||
assert_eq!(submit_bytes("a\r\nb", false, false), b"a\rb\r".to_vec());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submit_bytes_normalizes_line_breaks_inside_the_paste() {
|
||||
assert_eq!(
|
||||
submit_bytes("a\r\nb", true),
|
||||
submit_bytes("a\r\nb", true, false),
|
||||
b"\x1b[200~a\nb\x1b[201~\r".to_vec()
|
||||
);
|
||||
assert_eq!(
|
||||
submit_bytes("a\rb", true),
|
||||
submit_bytes("a\rb", true, false),
|
||||
b"\x1b[200~a\nb\x1b[201~\r".to_vec()
|
||||
);
|
||||
assert_eq!(submit_bytes("a\rb", false), b"a\rb\r".to_vec());
|
||||
assert_eq!(submit_bytes("a\rb", false, false), b"a\rb\r".to_vec());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submit_bytes_strips_esc_and_skips_markers_on_an_empty_line() {
|
||||
let out = submit_bytes("foo\x1b[201~\nrm -rf ~", true);
|
||||
let out = submit_bytes("foo\x1b[201~\nrm -rf ~", true, false);
|
||||
let end = b"\x1b[201~";
|
||||
assert_eq!(out.windows(end.len()).filter(|w| *w == end).count(), 1);
|
||||
assert_eq!(out, b"\x1b[200~foo[201~\nrm -rf ~\x1b[201~\r".to_vec());
|
||||
assert_eq!(submit_bytes("a\x1bb", false), b"ab\r".to_vec());
|
||||
assert_eq!(submit_bytes("a\x1bb", false, false), b"ab\r".to_vec());
|
||||
// The same smuggling attempt on the typed path is just literal text at
|
||||
// the prompt: there is no paste to break out of, and no ESC survives to
|
||||
// reach the line editor as a command.
|
||||
assert_eq!(
|
||||
submit_bytes("foo\x1b[201~; rm -rf ~", true),
|
||||
submit_bytes("foo\x1b[201~; rm -rf ~", true, false),
|
||||
b"foo[201~; rm -rf ~\r".to_vec()
|
||||
);
|
||||
|
||||
assert_eq!(submit_bytes("", true), b"\r".to_vec());
|
||||
assert_eq!(submit_bytes("", true, false), b"\r".to_vec());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user