diff --git a/src/terminal/cmd_editor.rs b/src/terminal/cmd_editor.rs index 971bf46f..24c02ea2 100644 --- a/src/terminal/cmd_editor.rs +++ b/src/terminal/cmd_editor.rs @@ -24,6 +24,11 @@ pub struct CmdEditor { /// shuttle states between the two. undo: Vec<(Vec, usize)>, redo: Vec<(Vec, 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); diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 7fa9d42c..ed5d8f2c 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -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, /// 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,23 @@ 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 — the next press swaps that span + /// for the word from an older entry. + at: usize, + /// The word itself: both the span's length and a fingerprint. Edits that + /// bypass `handle_editor_key` (IME-committed text, a paste, a completion + /// pick, ⌘Z) can't clear `last_word_nav`, so before resuming, the walk + /// checks the line still holds this word at `at` with the caret at its + /// end — anything else means an edit intervened and the walk starts over + /// rather than eating it. + word: String, +} + /// Seconds since the unix epoch — the timestamp history records carry. fn unix_now() -> u64 { std::time::SystemTime::now() @@ -1172,6 +1195,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 +1679,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 +1796,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 +1897,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 +1954,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 +2105,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 +2115,29 @@ 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. The shared encoder goes first (it knows the shifted + // character and the Kitty form when `key_char` is there to + // consult), but the platforms that deliver Alt chords at all + // don't reliably carry one — then fall back to ESC + the key + // name, uppercased under Shift, as a raw terminal would send. + if m.alt && !m.control && !m.platform && key.chars().count() == 1 { + let bytes = super::input::keystroke_to_bytes(ks, self.kitty_flags()) + .unwrap_or_else(|| { + let name = if m.shift { + key.to_uppercase() + } else { + key.to_string() + }; + let mut b = vec![0x1b]; + b.extend_from_slice(name.as_bytes()); + b + }); + self.handoff_line_to_shell(&bytes, cx); + return; + } } } cx.notify(); @@ -2055,14 +2145,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 +2197,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 +2231,9 @@ impl TerminalView { self.cmd.delete(); } } - _ => {} + _ => return false, } + true } /// Horizontal caret motion in the editor with selection semantics: Shift @@ -2272,6 +2371,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 +2393,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 +3608,72 @@ 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) { + // Only trust the recorded walk while the line still shows it: its word + // sitting at `at`, caret at the word's end, nothing selected. The keys + // this dispatcher sees reset `last_word_nav` themselves, but edits that + // bypass it (IME-committed text, a paste, a completion pick, ⌘Z) don't + // — resuming over those would delete text the walk never inserted. + let resumed = self.last_word_nav.take().filter(|walk| { + let len = walk.word.chars().count(); + self.cmd.cursor() == walk.at + len + && self.cmd.selection().is_none() + && self + .cmd + .text() + .chars() + .skip(walk.at) + .take(len) + .eq(walk.word.chars()) + }); + // A repeat resumes one entry older than the last press; a fresh walk + // starts at the newest entry. + let start = match &resumed { + 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. + self.last_word_nav = resumed; + 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 { + self.last_word_nav = resumed; + 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) = resumed { + self.cmd.clear_selection(); + self.cmd.set_cursor(walk.at); + self.cmd.extend_to(walk.at + walk.word.chars().count()); + self.cmd.delete_selection(); + } + self.cmd.insert_str(&word); + // `insert_str` replaces a live selection first, which moves the caret + // to the selection's start — so the word's position is wherever the + // caret landed minus the word, not the pre-insert cursor. + let at = self.cmd.cursor() - word.chars().count(); + self.last_word_nav = Some(LastWordWalk { entry, at, word }); + // The line is now the user's own edit, not a recalled entry. + self.history_nav = None; cx.notify(); } @@ -4228,6 +4397,9 @@ impl TerminalView { self.cmd.insert_str(text); self.history_nav = None; self.editor_goal_col = None; + // Typed text ends an ⌥. run: IME-committed text bypasses + // `handle_editor_key`'s reset, so it has to happen here too. + self.last_word_nav = None; self.completion_refilter(); self.cursor_visible = true; cx.notify(); @@ -4239,11 +4411,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 +8094,365 @@ 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(); + } + + /// Edits that bypass `handle_editor_key` — IME-committed text is the + /// everyday one (it's how all typing arrives on macOS and Windows) — must + /// end the walk too. Without that, the next ⌥. deletes the span the walk + /// recorded even though the user's typing now sits inside it. + #[gpui::test] + fn an_intervening_ime_commit_restarts_the_last_word_walk(cx: &mut TestAppContext) { + let (window, mut daemon) = harness(cx); + // `commit_text` edits the local line only while the editor is engaged + // at a shell prompt; anywhere else it writes gap text to the PTY. + DaemonMsg::Prompt { + active: true, + at_prompt: true, + last_exit: None, + } + .encode(&mut daemon) + .unwrap(); + wait_for_input_active(&window, 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.commit_text("x", cx); + view.handle_editor_key(&meta_dot, cx); + assert_eq!( + view.cmd.text(), + "worldxworld", + "the typed char survives; the walk starts over after it" + ); + }) + .unwrap(); + } + + /// ⌥. with a selection active: the word replaces the selection (insertion + /// replaces selections everywhere in this editor), and the walk records + /// where the word actually landed — the caret the selection collapsed to, + /// not where the caret stood before the insert — so a repeat swaps the + /// word cleanly. + #[gpui::test] + fn meta_dot_over_a_selection_records_where_the_word_landed(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.cmd.set("ls foo"); + // Select "foo" with the caret at the selection's far end. + view.cmd.set_cursor(3); + view.cmd.extend_to(6); + + view.handle_editor_key(&meta_dot, cx); + assert_eq!( + view.cmd.text(), + "ls world", + "the word replaced the selection" + ); + view.handle_editor_key(&meta_dot, cx); + assert_eq!( + view.cmd.text(), + "ls --release", + "the repeat swapped the word, not some other span" + ); + }) + .unwrap(); + } + + /// A shifted Meta chord must ship the shifted character: ⌥⇧U is `ESC U` + /// on the wire (upcase-region in zsh's keymap), not the `ESC u` of plain + /// ⌥U — gpui reports the key name unshifted, so the handoff has to apply + /// Shift itself when no `key_char` is there to consult. + #[gpui::test] + fn a_shifted_meta_chord_hands_off_the_shifted_character(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, + shift: 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()); + } + + /// 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(); }