feat(editor): readline parity at the prompt — ctrl-p/n, ctrl-y, meta-. and shell fallthrough

The local command editor swallowed every Ctrl chord at the prompt, so
engaging shell integration silently removed keys that worked before it:
Ctrl-P/N history motions, Ctrl-T transpose, and any zle widget the user
had bound. Kills (Ctrl-W/U/K) had no yank to put them back.

- Ctrl-P / Ctrl-N are rewritten into the arrow keys up front, so the two
  spellings can't drift apart across history recall, multi-line steps,
  the completion picker and reverse search.
- Ctrl-Y yanks the last kill back; the kill chords now stash what they
  cut in a one-slot ring. Answered locally — zle's kill ring is a
  different buffer and would yank text this editor never cut.
- Alt-. is readline's yank-last-arg, walking back through history on
  repeat and swapping the word the previous press inserted.
- Unrecognized Ctrl and Meta chords hand the line to the shell instead
  of dying at the prompt, the same escape hatch Ctrl-R already used.
- Keys handled locally now snap the viewport back to the live prompt
  (#208): the editor returns early, so it never reached the raw key
  path's housekeeping and could edit a line off screen.
This commit is contained in:
l0ng-ai
2026-07-27 16:59:46 +08:00
parent 0030b98faa
commit e68fcdefb6
2 changed files with 490 additions and 50 deletions
+81 -8
View File
@@ -24,6 +24,11 @@ pub struct CmdEditor {
/// shuttle states between the two.
undo: Vec<(Vec<char>, usize)>,
redo: Vec<(Vec<char>, usize)>,
/// What the last *kill* removed, for [`Self::yank`] to put back — readline's
/// kill ring, one slot deep. Only the word/line kills (⌃W, ⌃U, ⌃K, ⌥D and
/// the arrow-key spellings of them) write here; a plain character delete is
/// not a kill and leaves it untouched.
kill: String,
}
/// Cap on undo history, so a long editing session can't grow it without bound.
@@ -409,6 +414,15 @@ impl CmdEditor {
}
}
/// Remove `s..e` and stash it as the kill ring's contents. The four chords
/// below are readline *kills*, not deletes: what they take is meant to come
/// back out under [`Self::yank`].
fn kill_range(&mut self, s: usize, e: usize) {
self.kill = self.chars[s..e].iter().collect();
self.chars.drain(s..e);
self.shift_anchor_for_removal(s, e);
}
/// Delete the word after the cursor (Alt+Delete): skip following whitespace,
/// then the word.
pub fn delete_word_right(&mut self) {
@@ -421,8 +435,7 @@ impl CmdEditor {
while e < n && !self.chars[e].is_whitespace() {
e += 1;
}
self.chars.drain(self.cursor..e);
self.shift_anchor_for_removal(self.cursor, e);
self.kill_range(self.cursor, e);
}
/// Delete the word before the cursor (Ctrl+W / Alt+Backspace).
@@ -430,25 +443,33 @@ impl CmdEditor {
self.checkpoint();
let end = self.cursor;
self.move_word_left();
self.chars.drain(self.cursor..end);
self.shift_anchor_for_removal(self.cursor, end);
self.kill_range(self.cursor, end);
}
/// Delete from the cursor to the start of the line (Ctrl+U / Cmd+Backspace).
pub fn delete_to_start(&mut self) {
self.checkpoint();
self.chars.drain(0..self.cursor);
let end = self.cursor;
self.cursor = 0;
self.shift_anchor_for_removal(0, end);
self.kill_range(0, end);
}
/// Delete from the cursor to the end of the line (Ctrl+K).
pub fn delete_to_end(&mut self) {
self.checkpoint();
let end = self.chars.len();
self.chars.drain(self.cursor..);
self.shift_anchor_for_removal(self.cursor, end);
self.kill_range(self.cursor, end);
}
/// Reinsert the most recent kill at the cursor (Ctrl+Y). A no-op — undo
/// checkpoint included — when nothing has been killed yet.
pub fn yank(&mut self) {
if self.kill.is_empty() {
return;
}
let kill = std::mem::take(&mut self.kill);
self.insert_str(&kill);
self.kill = kill;
}
/// Clear the line and reset the cursor and undo history (after submit).
@@ -572,6 +593,58 @@ mod tests {
assert_eq!(d.cursor(), 9);
}
/// The four readline *kill* chords stash what they removed so ⌃Y can put it
/// back; the ring holds the most recent kill only.
#[test]
fn kills_fill_the_kill_buffer_and_yank_puts_it_back() {
let mut e = ed("git push origin", 15);
e.delete_word_left();
assert_eq!(e.text(), "git push ");
e.yank();
assert_eq!((e.text().as_str(), e.cursor()), ("git push origin", 15));
let mut k = ed("hello world", 5);
k.delete_to_end();
assert_eq!(k.text(), "hello");
k.yank();
assert_eq!(k.text(), "hello world");
let mut u = ed("hello world", 6);
u.delete_to_start();
assert_eq!(u.text(), "world");
u.move_end();
u.yank();
assert_eq!(u.text(), "worldhello ");
let mut d = ed("git push origin", 4);
d.delete_word_right();
assert_eq!(d.text(), "git origin");
d.yank();
assert_eq!(d.text(), "git push origin");
}
/// A plain character delete is not a kill — readline keeps the two apart,
/// so backspacing must not clobber the word ⌃W stashed a moment ago.
#[test]
fn character_deletes_leave_the_kill_buffer_alone() {
let mut e = ed("git push origin", 15);
e.delete_word_left();
e.backspace();
e.delete();
assert_eq!(e.text(), "git push");
e.yank();
assert_eq!(e.text(), "git pushorigin");
}
/// Nothing killed yet: ⌃Y leaves the line and the caret exactly as they
/// were rather than inserting an empty string.
#[test]
fn yank_without_a_kill_does_nothing() {
let mut e = ed("hello", 3);
e.yank();
assert_eq!((e.text().as_str(), e.cursor()), ("hello", 3));
}
#[test]
fn delete_to_start_and_end() {
let mut s = ed("hello world", 6);
+409 -42
View File
@@ -340,6 +340,12 @@ pub struct TerminalView {
/// The in-progress line saved when history navigation starts, so pressing ↓
/// past the newest entry restores what the user was typing.
history_stash: String,
/// Position of a run of ⌥. presses (readline's `yank-last-arg`): which
/// `history` entry the last press took its word from, and the char span it
/// left in the line — the next press replaces that span with the word from
/// the entry before it. Any other key clears this, so the following ⌥.
/// starts a fresh walk at the newest entry.
last_word_nav: Option<LastWordWalk>,
/// A submitted command whose history-file record is deferred until the
/// shell reports back at its prompt, so the record can carry the command's
/// exit code (see [`PendingHistory`]).
@@ -448,6 +454,17 @@ struct PendingHistory {
seq: u64,
}
/// Where a run of ⌥. presses has walked to (see
/// [`TerminalView::last_word_nav`]).
struct LastWordWalk {
/// Index into `history` the last press took its word from.
entry: usize,
/// Char offset of the word it inserted, and how many chars it spans — the
/// next press swaps that range for the word from an older entry.
at: usize,
len: usize,
}
/// Seconds since the unix epoch — the timestamp history records carry.
fn unix_now() -> u64 {
std::time::SystemTime::now()
@@ -1172,6 +1189,7 @@ impl TerminalView {
ranked_cwd: None,
history_nav: None,
history_stash: String::new(),
last_word_nav: None,
pending_history: None,
completion: None,
completion_generation: 0,
@@ -1655,11 +1673,7 @@ impl TerminalView {
// Keep the cursor solid while typing (resets the blink phase).
self.cursor_visible = true;
// Typing clears the selection and jumps to the prompt.
let mut term = self.terminal.term.lock();
term.selection = None;
term.scroll_display(Scroll::Bottom);
self.scroll_frac = 0.;
drop(term);
self.jump_to_prompt();
cx.notify();
// Consume so the key isn't also re-sent through the IME path.
cx.stop_propagation();
@@ -1776,11 +1790,43 @@ impl TerminalView {
let m = &ks.modifiers;
let key = ks.key.as_str();
self.cursor_visible = true;
// The raw key path does this per keystroke; the editor owns the keyboard
// at the prompt and every arm below returns early, so it has to happen
// once here instead. Without it a key pressed while scrolled up edits a
// line the viewport isn't showing (#208).
self.jump_to_prompt();
// ⌃P / ⌃N are readline's spelling of ↑ / ↓ (0x10 / 0x0e on the wire, and
// what the shell's own keymap answers when the editor isn't holding the
// line). Rewrite them into the arrow keys here rather than giving them
// arms of their own, so the two spellings can't drift apart — history
// recall, multi-line steps, the completion picker and the reverse-search
// menu all treat them identically from this point down.
let aliased;
let ks = if m.control && !m.platform && !m.alt && matches!(key, "p" | "n") {
aliased = gpui::Keystroke {
modifiers: gpui::Modifiers::default(),
key: if key == "p" { "up" } else { "down" }.to_string(),
key_char: None,
};
&aliased
} else {
ks
};
let m = &ks.modifiers;
let key = ks.key.as_str();
// Any key other than a vertical step drops the sticky goal column, so the
// next ↑/↓ takes its column from wherever the caret ends up.
if key != "up" && key != "down" {
self.editor_goal_col = None;
}
// Likewise, only a repeat of ⌥. continues an insert-last-word walk —
// anything else and the next press starts fresh at the newest entry
// rather than swallowing whatever now sits left of the caret.
if !(m.alt && key == ".") {
self.last_word_nav = None;
}
// A reverse search, when active, owns the keyboard.
if self.reverse_search.is_some() {
@@ -1845,8 +1891,9 @@ impl TerminalView {
self.close_completion();
// Readline-style control combinations, delegated so this dispatcher stays
// scannable. Every Ctrl chord is swallowed at the prompt (recognized or
// not), so this always notifies and returns.
// scannable. A chord the editor answers is consumed here; one it doesn't
// goes on to the shell rather than dying at the prompt. Either way this
// branch returns.
if m.control && !m.platform && !m.alt {
// Off macOS, word navigation and deletion live on Ctrl (the Windows /
// Linux convention): Ctrl+←/→ move by word (Shift extends the
@@ -1901,19 +1948,33 @@ impl TerminalView {
self.handoff_line_to_shell(&[0x12], cx);
return;
}
self.apply_readline_ctrl(key);
cx.notify();
if self.apply_readline_ctrl(key) {
cx.notify();
} else if let Some(bytes) = super::input::keystroke_to_bytes(ks, self.kitty_flags()) {
// No local widget answers this chord. Swallowing it is the one
// thing we mustn't do — the key worked before shell integration
// engaged, and zle's keymap (⌃T transpose, a `bindkey` widget,
// an fzf binding…) still knows what to do with it.
self.handoff_line_to_shell(&bytes, cx);
} else {
cx.notify();
}
return;
}
// Readline-style Meta word chords on the edited line: M-b / M-f motions
// and M-d delete-word, mirroring the Alt+←/→/Delete handling below. On
// macOS these are reachable only with `macos_option_as_alt` on — with it
// off the chord composes a character upstream and arrives here altless,
// through the printable-text arm. Other Alt+letter chords stay swallowed
// no-ops as before (the local editor can't mirror every zle widget).
// Readline-style Meta chords on the edited line: M-b / M-f motions,
// M-d delete-word (mirroring the Alt+←/→/Delete handling below) and
// M-. insert-last-word. On macOS these are reachable only with
// `macos_option_as_alt` on — with it off the chord composes a character
// upstream and arrives here altless, through the printable-text arm.
// Meta chords with no arm here reach the shell instead of dying (see
// the fallthrough at the bottom of the dispatcher).
if m.alt && !m.platform && !m.control {
match key {
"." => {
self.insert_last_word(cx);
return;
}
"b" => {
self.editor_move_h(false, m.shift, true);
cx.notify();
@@ -2038,7 +2099,7 @@ impl TerminalView {
// events carrying `key_char`; feed them through the same commit path
// the IME would use so the local editor sees the text. Skip control /
// Cmd chords and any non-printable char (function keys have no
// `key_char`; Alt combos stay editor no-ops as before).
// `key_char`).
_ => {
if !m.control && !m.platform && !m.alt {
if let Some(ch) = ks.key_char.as_deref() {
@@ -2048,6 +2109,17 @@ impl TerminalView {
}
}
}
// A Meta chord with nothing local behind it (M-t transpose-word,
// M-u/M-l/M-c case widgets, whatever the user bound) goes to the
// shell rather than dying here — same reasoning as the Ctrl side
// above. Built from the key name, not `key_char`: the platforms
// that deliver Alt chords at all don't reliably carry one.
if m.alt && !m.control && !m.platform && key.chars().count() == 1 {
let mut bytes = vec![0x1b];
bytes.extend_from_slice(key.as_bytes());
self.handoff_line_to_shell(&bytes, cx);
return;
}
}
}
cx.notify();
@@ -2055,14 +2127,18 @@ impl TerminalView {
/// Apply a readline-style Ctrl chord to the command editor: Ctrl-A/E/B/F
/// motions (Ctrl-F also accepts the autosuggestion), Ctrl-W/U/K/H deletions
/// (each removing the selection first if there is one), Ctrl-L clear-screen,
/// Ctrl-R reverse search, Ctrl-C interrupt, and Ctrl-D EOF/forward-delete.
/// Unrecognized chords are no-ops (the caller swallows every Ctrl combo at
/// the prompt regardless).
/// (each removing the selection first if there is one), Ctrl-Y yanking the
/// last kill back, Ctrl-L clear-screen, Ctrl-R reverse search, Ctrl-C
/// interrupt, and Ctrl-D EOF/forward-delete.
///
/// The caller resolves Ctrl-J / Ctrl-M (accept-line) and, when the history
/// menu is switched off, Ctrl-R before this point — neither reaches here.
fn apply_readline_ctrl(&mut self, key: &str) {
/// Returns whether the chord was recognized: the caller hands the ones that
/// weren't to the shell, so a widget tty7 has no answer for still reaches
/// the keymap that does.
///
/// The caller resolves Ctrl-J / Ctrl-M (accept-line), Ctrl-P / Ctrl-N (the
/// arrow keys by another name) and, when the history menu is switched off,
/// Ctrl-R before this point — none of them reach here.
fn apply_readline_ctrl(&mut self, key: &str) -> bool {
match key {
"r" => self.start_reverse_search(),
"a" => {
@@ -2103,6 +2179,10 @@ impl TerminalView {
}
}
"h" => self.cmd.backspace(),
// Yank: the other half of ⌃W / ⌃U / ⌃K. Answered locally rather
// than handed to the shell — zle keeps its own kill ring, and
// yanking from it would paste text this editor never cut.
"y" => self.cmd.yank(),
"l" => {
// Clear screen belongs to the shell/readline layer: send the
// same form-feed byte the raw terminal path emits for Ctrl+L.
@@ -2133,8 +2213,9 @@ impl TerminalView {
self.cmd.delete();
}
}
_ => {}
_ => return false,
}
true
}
/// Horizontal caret motion in the editor with selection semantics: Shift
@@ -2272,6 +2353,19 @@ impl TerminalView {
super::input::tab_bytes(shift, self.kitty_flags())
}
/// The housekeeping every input path shares: drop the selection the key
/// invalidated and bring the viewport back to the live prompt, whole lines
/// (`display_offset`) and sub-line remainder (`scroll_frac`) alike. Acting
/// on a line the user can't see is the thing to avoid — so this runs for
/// keys handled locally too, not only for bytes that reach the PTY.
fn jump_to_prompt(&mut self) {
let mut term = self.terminal.term.lock();
term.selection = None;
term.scroll_display(Scroll::Bottom);
drop(term);
self.scroll_frac = 0.;
}
/// Write a fixed byte sequence to the PTY (for keystrokes delivered as
/// actions rather than through `on_key_down`, e.g. Tab / Shift-Tab), applying
/// the same cursor / selection / scroll housekeeping as normal typing.
@@ -2281,11 +2375,7 @@ impl TerminalView {
}
self.terminal.write(bytes.to_vec());
self.cursor_visible = true;
let mut term = self.terminal.term.lock();
term.selection = None;
term.scroll_display(Scroll::Bottom);
self.scroll_frac = 0.;
drop(term);
self.jump_to_prompt();
cx.notify();
}
@@ -3500,11 +3590,54 @@ impl TerminalView {
self.terminal.write(submit_bytes(&line, bracketed));
self.cmd.clear();
self.cursor_visible = true;
let mut term = self.terminal.term.lock();
term.selection = None;
term.scroll_display(Scroll::Bottom);
self.scroll_frac = 0.;
drop(term);
self.jump_to_prompt();
cx.notify();
}
/// Readline's `yank-last-arg` (⌥.): drop the last word of the previous
/// command at the caret. Repeating the chord walks further back through the
/// history, each press swapping out the word the one before it inserted, so
/// a run of presses leaves exactly one word behind. Entries with no words
/// are stepped over rather than inserting nothing.
fn insert_last_word(&mut self, cx: &mut Context<Self>) {
// A repeat resumes one entry older than the last press; a fresh walk
// starts at the newest entry.
let start = match &self.last_word_nav {
Some(walk) => walk.entry.checked_sub(1),
None => self.history.len().checked_sub(1),
};
let Some(mut entry) = start else {
// Nothing older to reach (or no history at all) — leave the line as
// it stands, the word the previous press inserted included.
return;
};
let word = loop {
if let Some(w) = self.history[entry].split_whitespace().next_back() {
break w.to_string();
}
let Some(older) = entry.checked_sub(1) else {
return;
};
entry = older;
};
// Take back what the previous press left, so the walk swaps words in
// place rather than piling them up.
if let Some(walk) = self.last_word_nav.take() {
self.cmd.clear_selection();
self.cmd.set_cursor(walk.at);
self.cmd.extend_to(walk.at + walk.len);
self.cmd.delete_selection();
}
let at = self.cmd.cursor();
self.cmd.insert_str(&word);
self.last_word_nav = Some(LastWordWalk {
entry,
at,
len: word.chars().count(),
});
// The line is now the user's own edit, not a recalled entry.
self.history_nav = None;
cx.notify();
}
@@ -4239,11 +4372,7 @@ impl TerminalView {
self.write_gap_text(text, text.as_bytes().to_vec(), cx);
// Keep the cursor solid while committing input (resets the blink phase).
self.cursor_visible = true;
let mut term = self.terminal.term.lock();
term.selection = None;
term.scroll_display(Scroll::Bottom);
self.scroll_frac = 0.;
drop(term);
self.jump_to_prompt();
cx.notify();
}
@@ -7926,10 +8055,248 @@ mod gpui_tests {
assert_eq!(view.cmd.cursor(), 0);
view.handle_editor_key(&meta("f"), cx);
assert_eq!(view.cmd.cursor(), 4);
// Other Meta letters stay swallowed no-ops (line untouched).
// Other Meta letters have no local widget, so they hand the line
// to the shell rather than dying here — see
// `an_unknown_meta_chord_goes_to_the_shell_with_the_line`.
view.handle_editor_key(&meta("z"), cx);
assert_eq!(view.cmd.text(), "echo ");
assert_eq!(view.cmd.cursor(), 4);
assert_eq!(view.cmd.text(), "");
})
.unwrap();
}
/// Fill the scrollback and park the viewport `offset` lines up inside it,
/// so a test can watch a keystroke snap it back to the live prompt.
fn scroll_into_history(view: &TerminalView, offset: usize) {
let mut parser: alacritty_terminal::vte::ansi::Processor = Default::default();
let mut term = view.terminal.term.lock();
parser.advance(&mut *term, &b"line\r\n".repeat(60));
term.scroll_display(Scroll::Delta(offset as i32));
assert_eq!(
term.grid().display_offset(),
offset,
"the viewport starts parked in the scrollback"
);
}
fn display_offset(view: &TerminalView) -> usize {
view.terminal.term.lock().grid().display_offset()
}
/// Scrolled up into the scrollback, recalling history with ↑ must bring the
/// viewport back to the live prompt (#208). The local editor owns ↑ and
/// returns early, so it never reached the "typing jumps to the prompt"
/// housekeeping on the raw key path — leaving the user editing a line they
/// cannot see.
#[gpui::test]
fn history_recall_snaps_the_viewport_back_to_the_prompt(cx: &mut TestAppContext) {
let (window, _daemon) = harness(cx);
window
.update(cx, |view, _, cx| {
view.history = vec!["echo hello".to_string()];
scroll_into_history(view, 10);
view.scroll_frac = 0.5;
view.handle_editor_key(&key("up"), cx);
assert_eq!(view.cmd.text(), "echo hello", "↑ recalled the entry");
assert_eq!(display_offset(view), 0, "and the viewport followed it down");
assert_eq!(view.scroll_frac, 0., "the sub-line remainder reset too");
})
.unwrap();
}
/// ⌃P / ⌃N are readline's history motions, and a raw terminal passes them
/// to the shell as 0x10 / 0x0e. The local editor swallows every Ctrl chord
/// at the prompt, so without arms of their own they went from "works" to
/// "does nothing" the moment shell integration engaged.
#[gpui::test]
fn ctrl_p_and_ctrl_n_walk_the_history(cx: &mut TestAppContext) {
let (window, _daemon) = harness(cx);
window
.update(cx, |view, _, cx| {
view.history = ["git status", "cargo build", "echo hello"]
.into_iter()
.map(String::from)
.collect();
// ⌃P walks back from the newest entry.
view.handle_editor_key(&key("ctrl-p"), cx);
assert_eq!(view.cmd.text(), "echo hello");
view.handle_editor_key(&key("ctrl-p"), cx);
assert_eq!(view.cmd.text(), "cargo build");
// ⌃N walks forward again.
view.handle_editor_key(&key("ctrl-n"), cx);
assert_eq!(view.cmd.text(), "echo hello");
// Past the newest entry the in-progress line comes back.
view.handle_editor_key(&key("ctrl-n"), cx);
assert_eq!(view.cmd.text(), "");
})
.unwrap();
}
/// A Ctrl chord the local editor has no widget for used to be swallowed, so
/// engaging shell integration *removed* ⌃T, ⌥T, ⌥U and every `bindkey`
/// widget the user had bound. Hand the line to zle instead and let its
/// keymap answer — the same escape hatch ⌃R already uses.
#[gpui::test]
fn an_unknown_ctrl_chord_goes_to_the_shell_with_the_line(cx: &mut TestAppContext) {
let (window, mut daemon) = harness(cx);
window
.update(cx, |view, _, cx| {
view.cmd.set("echo hi");
// ⌃T is readline's transpose-chars; tty7 has no widget for it.
view.handle_editor_key(&key("ctrl-t"), cx);
assert_eq!(
view.cmd.text(),
"",
"the line left for the shell, so the local buffer is empty"
);
assert!(
view.editor_handoff.is_some(),
"the local editor stands down for the rest of the line"
);
})
.unwrap();
assert_eq!(next_input(&mut daemon), b"echo hi".to_vec());
assert_eq!(next_input(&mut daemon), vec![0x14], "⌃T reached the shell");
}
/// The Meta half of the same gap: ⌥U (upcase-word) and friends were dead at
/// the prompt. Unrecognized Meta chords ship the line and the ESC-prefixed
/// key, the way a raw terminal would have.
#[gpui::test]
fn an_unknown_meta_chord_goes_to_the_shell_with_the_line(cx: &mut TestAppContext) {
let (window, mut daemon) = harness(cx);
window
.update(cx, |view, _, cx| {
view.cmd.set("echo hi");
view.handle_editor_key(
&gpui::Keystroke {
modifiers: gpui::Modifiers {
alt: true,
..Default::default()
},
key: "u".to_string(),
key_char: None,
},
cx,
);
assert_eq!(view.cmd.text(), "");
})
.unwrap();
assert_eq!(next_input(&mut daemon), b"echo hi".to_vec());
assert_eq!(next_input(&mut daemon), b"\x1bu".to_vec());
}
/// ⌃W / ⌃U / ⌃K are *kills*, and ⌃Y is what puts a kill back — without it
/// the pair was half-implemented: the editor cut text with nowhere to paste
/// it from. ⌃Y has to stay local rather than reaching the shell, because
/// zle's kill ring is a different buffer and would yank unrelated text.
#[gpui::test]
fn ctrl_y_yanks_back_what_the_kill_chords_cut(cx: &mut TestAppContext) {
let (window, _daemon) = harness(cx);
window
.update(cx, |view, _, cx| {
view.cmd.set("echo hello world");
view.handle_editor_key(&key("ctrl-w"), cx);
assert_eq!(view.cmd.text(), "echo hello ");
view.handle_editor_key(&key("ctrl-y"), cx);
assert_eq!(view.cmd.text(), "echo hello world");
assert!(
view.editor_handoff.is_none(),
"the line never left for the shell"
);
})
.unwrap();
}
/// ⌥. is readline's `yank-last-arg`: it pulls the last word of the previous
/// command into the line, and repeating it walks further back through the
/// history, replacing what the last press inserted. Frequent enough that
/// paying the handoff cost (ghost text and completion gone for the rest of
/// the line) on every press would be the wrong trade — tty7 holds the same
/// history, so it answers locally.
#[gpui::test]
fn meta_dot_walks_back_through_the_last_words(cx: &mut TestAppContext) {
let (window, _daemon) = harness(cx);
window
.update(cx, |view, _, cx| {
let meta_dot = gpui::Keystroke {
modifiers: gpui::Modifiers {
alt: true,
..Default::default()
},
key: ".".to_string(),
key_char: None,
};
view.history = ["git status", "cargo build --release", "echo hello world"]
.into_iter()
.map(String::from)
.collect();
view.cmd.set("ls ");
view.handle_editor_key(&meta_dot, cx);
assert_eq!(view.cmd.text(), "ls world", "newest entry's last word");
view.handle_editor_key(&meta_dot, cx);
assert_eq!(view.cmd.text(), "ls --release", "repeat steps one back");
view.handle_editor_key(&meta_dot, cx);
assert_eq!(view.cmd.text(), "ls status");
// Nothing older to reach: the line holds what it had.
view.handle_editor_key(&meta_dot, cx);
assert_eq!(view.cmd.text(), "ls status");
// The caret sits after the inserted word, ready to keep typing.
assert_eq!(view.cmd.cursor(), "ls status".chars().count());
})
.unwrap();
}
/// The walk is only a walk while ⌥. repeats. Once another key edits the
/// line, the next ⌥. starts over from the newest entry instead of eating
/// whatever happens to sit left of the caret.
#[gpui::test]
fn an_intervening_key_restarts_the_last_word_walk(cx: &mut TestAppContext) {
let (window, _daemon) = harness(cx);
window
.update(cx, |view, _, cx| {
let meta_dot = gpui::Keystroke {
modifiers: gpui::Modifiers {
alt: true,
..Default::default()
},
key: ".".to_string(),
key_char: None,
};
view.history = ["cargo build --release", "echo hello world"]
.into_iter()
.map(String::from)
.collect();
view.handle_editor_key(&meta_dot, cx);
assert_eq!(view.cmd.text(), "world");
view.handle_editor_key(&key("left"), cx);
view.handle_editor_key(&key("end"), cx);
view.handle_editor_key(&meta_dot, cx);
assert_eq!(
view.cmd.text(),
"worldworld",
"a fresh walk appends rather than replacing the earlier word"
);
})
.unwrap();
}
/// Chords the editor *does* answer stay local — handing off would forfeit
/// ghost text and completion for the rest of the line, and ⌃A/⌃E/⌃W are
/// exactly the keys pressed most often mid-edit.
#[gpui::test]
fn a_known_ctrl_chord_stays_in_the_local_editor(cx: &mut TestAppContext) {
let (window, _daemon) = harness(cx);
window
.update(cx, |view, _, cx| {
view.cmd.set("echo hi");
view.handle_editor_key(&key("ctrl-w"), cx);
assert_eq!(view.cmd.text(), "echo ", "⌃W cut the word locally");
assert!(view.editor_handoff.is_none());
})
.unwrap();
}