diff --git a/src/daemon/pane.rs b/src/daemon/pane.rs index 98b68665..de005b84 100644 --- a/src/daemon/pane.rs +++ b/src/daemon/pane.rs @@ -1835,8 +1835,9 @@ impl OscSniffer { if let Some(path) = parse_osc7(payload) { signals.cwd = Some(path); } else if let Some(rest) = payload.strip_prefix(b"133;") { - handle_osc133(shell, rest); - signals.shell = Some(shell.clone()); + if handle_osc133(shell, rest) { + signals.shell = Some(shell.clone()); + } } else if let Some(event) = crate::core::cli_agent::parse_agent_event(payload) { signals.agent_events.push(event); } else if let Some((title, body)) = crate::core::osc::parse_notification(payload) { @@ -1852,7 +1853,7 @@ impl OscSniffer { } /// Fold one OSC 133 marker into the running shell state. -fn handle_osc133(shell: &mut ShellState, rest: &[u8]) { +fn handle_osc133(shell: &mut ShellState, rest: &[u8]) -> bool { shell.active = true; // `at_prompt` means "no foreground command is running" — i.e. the shell is // drawing or sitting at its prompt, so tty7's local line editor should own @@ -1877,8 +1878,9 @@ fn handle_osc133(shell: &mut ShellState, rest: &[u8]) { .and_then(|c| std::str::from_utf8(c).ok()) .and_then(|s| s.trim().parse::().ok()); } - _ => {} + _ => return false, } + true } /// Build a `PathBuf` from raw OSC-7 path bytes. On Unix paths are arbitrary bytes, @@ -2404,6 +2406,20 @@ mod tests { assert_eq!(d.shell.as_ref().unwrap().last_exit_code, Some(130)); } + #[test] + fn sniff_osc133_edit_mode_does_not_emit_prompt_state() { + let mut s = OscSniffer::new(); + let sig = s.feed(b"\x1b]133;V;1\x07"); + assert!( + sig.shell.is_none(), + "edit-mode metadata must not bump prompt state or prompt sequence" + ); + + let b = s.feed(b"\x1b]133;B\x07"); + assert!(b.shell.as_ref().unwrap().active); + assert!(b.shell.as_ref().unwrap().at_prompt); + } + /// The foreground-command predicate: only a process group *other* than the /// shell counts as a running command; matching pids, or missing data, mean /// the shell is idle at its own prompt (so we never suppress a real prompt). diff --git a/src/daemon/shell_integration.rs b/src/daemon/shell_integration.rs index 724efe50..59474e71 100644 --- a/src/daemon/shell_integration.rs +++ b/src/daemon/shell_integration.rs @@ -9,6 +9,7 @@ //! - `OSC 133 ; B ST` prompt end / command input begins //! - `OSC 133 ; C ST` command output begins (command executing) //! - `OSC 133 ; D ; ST` command finished, with its exit code +//! - `OSC 133 ; V ; 0/1 ST` tty7 extension: shell edit mode //! plus `OSC 7` to report the cwd precisely (many login shells don't emit it //! unless they think they're in Terminal.app). //! @@ -54,6 +55,18 @@ if [[ -o interactive ]] && [[ -z "$TTY7_SHELL_INTEGRATION" ]]; then __tty7_osc() { builtin printf '\e]%s\a' "$1"; } + # Vi mode links the `main` keymap to `viins` (`bindkey -A viins main`); + # emacs mode links it to `emacs`. The link survives plugins like + # zsh-vi-mode that rebind `^[` to their own widgets, so it beats sniffing + # the Esc widget name. + __tty7_report_edit_mode() { + if [[ "$(builtin bindkey -lL main)" == *viins* ]]; then + __tty7_osc "133;V;1" + else + __tty7_osc "133;V;0" + fi + } + # OSC 7: report the working directory so the app tracks it precisely (used for # opening new tabs / splits in the same place). The daemon percent-DECODES the # payload (OSC 7 carries a file: URI), so a literal `%` in the path must be @@ -79,6 +92,7 @@ if [[ -o interactive ]] && [[ -z "$TTY7_SHELL_INTEGRATION" ]]; then # *after* the user's hooks: report cwd, then open a fresh prompt (A). __tty7_precmd() { __tty7_report_cwd + __tty7_report_edit_mode __tty7_osc "133;A" # Prompt-end marker (B): emitted at the very end of the prompt — exactly where # input begins — by living in PS1 (wrapped in %{...%} so zsh excludes it from @@ -148,6 +162,15 @@ if status is-interactive; and test -z "$TTY7_SHELL_INTEGRATION" printf '\e]%s\a' $argv[1] end + function __tty7_report_edit_mode + switch $fish_key_bindings + case '*vi*' + __tty7_osc "133;V;1" + case '*' + __tty7_osc "133;V;0" + end + end + # The daemon percent-decodes the OSC 7 payload; escape literal `%` as %25 so # a path like /tmp/a%20b round-trips instead of decoding to /tmp/a b. function __tty7_report_cwd @@ -169,6 +192,7 @@ if status is-interactive; and test -z "$TTY7_SHELL_INTEGRATION" set -e __tty7_cmd_active end __tty7_report_cwd + __tty7_report_edit_mode __tty7_osc "133;A" end @@ -204,6 +228,16 @@ if [[ $- == *i* ]] && [[ -z "$TTY7_SHELL_INTEGRATION" ]]; then export TTY7_SHELL_INTEGRATION=1 __tty7_osc() { builtin printf '\e]%s\a' "$1"; } + # `bind -v` reports readline's actual editing mode; `[[ -o vi ]]` misses + # vi mode configured only in ~/.inputrc (`set editing-mode vi` flips + # readline without setting the shell option). + __tty7_report_edit_mode() { + if [[ "$(builtin bind -v 2>/dev/null)" == *"set editing-mode vi"* ]]; then + __tty7_osc "133;V;1" + else + __tty7_osc "133;V;0" + fi + } # Escape literal `%` as %25 — the daemon percent-decodes the OSC 7 payload. __tty7_report_cwd() { builtin printf '\e]7;file://%s%s\a' "${HOSTNAME:-localhost}" "${PWD//\%/%25}"; } @@ -222,6 +256,7 @@ if [[ $- == *i* ]] && [[ -z "$TTY7_SHELL_INTEGRATION" ]]; then __tty7_precmd() { local ret=$? __tty7_report_cwd + __tty7_report_edit_mode __tty7_osc "133;A" # Prompt-end marker (B), wrapped in \[...\] so readline excludes it from the # prompt's on-screen width. Re-appended every precmd (like the zsh path) @@ -859,6 +894,27 @@ pub fn setup(program: Option<&str>, has_custom_args: bool) -> Option mod tests { use super::*; + #[test] + fn edit_mode_detection_survives_rebound_escape_and_inputrc() { + // zsh: plugins like zsh-vi-mode rebind `^[` to their own widgets + // (`zvm_readkeys_handler`), so sniffing the Esc widget for + // `vi-cmd-mode` misses them. The `main` keymap link is durable: both + // plain `bindkey -v` and zsh-vi-mode link main to viins, and emacs + // mode links it to emacs (`bindkey -A viins main` vs `-A emacs main`). + assert!( + ZSH_INTEGRATION.contains("bindkey -lL main"), + "zsh edit-mode detection must key off the main keymap link" + ); + assert!(ZSH_INTEGRATION.contains("viins")); + // bash: `[[ -o vi ]]` misses vi mode set only via ~/.inputrc + // (`set editing-mode vi` flips readline but not the shell option); + // `bind -v` reports readline's actual mode either way. + assert!( + BASH_INTEGRATION.contains("editing-mode vi"), + "bash edit-mode detection must read readline's mode via bind -v" + ); + } + #[test] fn is_our_zdotdir_matches_only_our_prefix() { // A dir we created (basename carries the tty7 prefix) is recognized. diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index 9e861faf..cf5500bb 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -112,6 +112,7 @@ struct ReaderSignals { exited: Arc, child_exited: Arc, zle_reading: Arc, + shell_vi_mode: Arc, /// FIFO of pending native-SSH auth/host-key prompts (and banners, id 0) /// pushed by the reader as `DaemonMsg::AuthPrompt` frames arrive. The view /// drains these into the in-pane auth sheet (`ui::ssh_prompt`). Keyed per @@ -170,6 +171,10 @@ pub struct RemoteTerminal { /// touch it (a historical `B` says nothing about now). Gates the typeahead /// wipe: a `^U` written before zle reads is kernel-echoed as literal junk. zle_reading: Arc, + /// Whether the shell reports vi editing mode for the current prompt. Sniffed + /// client-side from tty7's shell integration marker (`OSC 133;V;0/1`) so the + /// daemon/client wire protocol stays compatible across versions. + shell_vi_mode: Arc, /// Pending native-SSH auth/host-key prompts, filled by the reader thread. The /// view drains these each event batch (`take_auth_prompt`) into the in-pane /// sheet. Shared with the reader thread. @@ -316,6 +321,7 @@ impl RemoteTerminal { let exited_flag = Arc::new(AtomicBool::new(false)); let child_exited = Arc::new(AtomicBool::new(false)); let zle_reading = Arc::new(AtomicBool::new(false)); + let shell_vi_mode = Arc::new(AtomicBool::new(false)); let auth_prompts: Arc>> = Arc::new(Mutex::new(VecDeque::new())); let ssh_phase: Arc>> = Arc::new(Mutex::new(None)); @@ -333,6 +339,7 @@ impl RemoteTerminal { exited: exited_flag.clone(), child_exited: child_exited.clone(), zle_reading: zle_reading.clone(), + shell_vi_mode: shell_vi_mode.clone(), auth: auth_prompts.clone(), phase: ssh_phase.clone(), }, @@ -352,6 +359,7 @@ impl RemoteTerminal { exited_flag, child_exited, zle_reading, + shell_vi_mode, auth_prompts, ssh_phase, ssh_endpoint: None, @@ -397,6 +405,7 @@ impl RemoteTerminal { exited: exited_flag, child_exited, zle_reading, + shell_vi_mode, auth, phase, } = signals; @@ -415,10 +424,14 @@ impl RemoteTerminal { // view-channel plumbing needed. Its state persists across frames so a // sequence split over two `Output` reads is still recognized. let mut osc = OscNotifyScanner::default(); - // Sniffs OSC 133 marks out of the same live stream to track - // whether zle is reading (see the `zle_reading` field docs). - // Client-side on purpose: the daemon protocol stays untouched, - // so mixed client/daemon versions keep working. + // Sniffs tty7's OSC 133;V edit-mode metadata from both replayed + // snapshots and live output. Unlike zle_reading, this is durable + // prompt state: an attached client should inherit the last mode + // marker already present in the replay ring. + let mut mode_tok = OscTokenizer::new(&[b"133"]); + // Sniffs OSC 133 marks out of the live stream to track whether + // zle is reading (see the `zle_reading` field docs). Historical + // Snapshot replays deliberately do not feed this tokenizer. let mut zle_tok = OscTokenizer::new(&[b"133"]); // Bytes read but not yet framed, plus the recorded geometry // waiting for its paired Snapshot: the attach replay is a @@ -494,16 +507,33 @@ impl RemoteTerminal { for (title, body) in notes { notify_desktop(title.as_deref(), &body); } + mode_tok.feed(&out_batch, |payload| { + if let Some(mode) = payload.strip_prefix(b"133;V;") { + shell_vi_mode.store( + mode.first() == Some(&b'1'), + Ordering::Relaxed, + ); + } + }); // Live 133 marks: `B` = prompt fully printed, zle // takes the keyboard right after; anything else // (C command start, D precmd, A prompt start) // means it isn't reading. zle_tok.feed(&out_batch, |payload| { if let Some(mark) = payload.strip_prefix(b"133;") { - zle_reading.store( - mark.first() == Some(&b'B'), - Ordering::Relaxed, - ); + match mark.first() { + Some(b'B') => { + zle_reading.store(true, Ordering::Relaxed) + } + Some(b'V') => { + shell_vi_mode.store( + mark.strip_prefix(b"V;") + .is_some_and(|v| v.first() == Some(&b'1')), + Ordering::Relaxed, + ); + } + _ => zle_reading.store(false, Ordering::Relaxed), + } } }); proxy.send_event(AlacEvent::Wakeup); @@ -570,6 +600,14 @@ impl RemoteTerminal { processor.stop_sync(&mut *term); } } + mode_tok.feed(&bytes, |payload| { + if let Some(mode) = payload.strip_prefix(b"133;V;") { + shell_vi_mode.store( + mode.first() == Some(&b'1'), + Ordering::Relaxed, + ); + } + }); proxy.replaying.store(false, Ordering::Relaxed); proxy.send_event(AlacEvent::Wakeup); } @@ -897,6 +935,10 @@ impl RemoteTerminal { self.zle_reading.load(Ordering::Relaxed) } + pub fn shell_vi_mode(&self) -> bool { + self.shell_vi_mode.load(Ordering::Relaxed) + } + pub fn size(&self) -> TermSize { self.size } @@ -2367,6 +2409,88 @@ mod tests { assert!(poll(false), "C (command start) should disarm zle_reading"); } + #[test] + fn shell_vi_mode_follows_live_prompt_mode_marks_without_disarming_zle() { + let (client_side, mut daemon_side) = UnixStream::pair().unwrap(); + let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap(); + let poll = |vi: bool, zle: bool| { + for _ in 0..200 { + if term.shell_vi_mode() == vi && term.zle_reading() == zle { + return true; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + false + }; + + assert!(!term.shell_vi_mode(), "conservative false before any mark"); + assert!(!term.zle_reading(), "zle also starts false"); + + DaemonMsg::Output(b"\x1b]133;B\x07".to_vec()) + .encode(&mut daemon_side) + .unwrap(); + daemon_side.flush().unwrap(); + assert!(poll(false, true), "B should arm zle only"); + + DaemonMsg::Output(b"\x1b]133;V;1\x07".to_vec()) + .encode(&mut daemon_side) + .unwrap(); + daemon_side.flush().unwrap(); + assert!( + poll(true, true), + "V;1 should set shell vi-mode without disarming zle" + ); + + DaemonMsg::Output(b"\x1b]133;V;0\x07".to_vec()) + .encode(&mut daemon_side) + .unwrap(); + daemon_side.flush().unwrap(); + assert!( + poll(false, true), + "V;0 should clear shell vi-mode without disarming zle" + ); + + DaemonMsg::Output(b"\x1b]133;C\x07".to_vec()) + .encode(&mut daemon_side) + .unwrap(); + daemon_side.flush().unwrap(); + assert!(poll(false, false), "C still disarms zle"); + } + + #[test] + fn shell_vi_mode_is_restored_from_snapshot_replay() { + let (client_side, mut daemon_side) = UnixStream::pair().unwrap(); + let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap(); + let poll = |vi: bool| { + for _ in 0..200 { + if term.shell_vi_mode() == vi { + return true; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + false + }; + + DaemonMsg::Snapshot(b"\x1b]133;V;1\x07".to_vec()) + .encode(&mut daemon_side) + .unwrap(); + daemon_side.flush().unwrap(); + assert!( + poll(true), + "attached clients should inherit the prompt's vi-mode state" + ); + assert!( + !term.zle_reading(), + "historical replay must not imply zle is currently reading" + ); + + DaemonMsg::Snapshot(b"\x1b]133;V;0\x07".to_vec()) + .encode(&mut daemon_side) + .unwrap(); + daemon_side.flush().unwrap(); + assert!(poll(false), "a replayed V;0 should clear vi-mode state"); + } + /// Everything in the grid — screen rows plus scrollback — flattened to one /// string, one row per line, for substring counting in the replay test. fn full_dump(term: &RemoteTerminal) -> String { diff --git a/src/terminal/view.rs b/src/terminal/view.rs index dc92fd72..78acc5f4 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -1316,6 +1316,7 @@ impl TerminalView { let kitty = self.kitty_flags(); if let Some(bytes) = super::input::keystroke_to_bytes(ks, kitty) { let plain = !m.control && !m.alt && !m.platform; + let shell_vi_prompt = self.shell_vi_prompt(); // A plain Backspace is reconstructable gap input: offer it to the // hold, so a fast command's typeahead never touches the PTY (see // `hold`). Anything else releases the hold first — FIFO order on @@ -1323,6 +1324,7 @@ impl TerminalView { // for the deferred wipe. let held = plain && ks.key == "backspace" + && !shell_vi_prompt && self.gap_holdable() && match self.hold.hold_backspace(&bytes) { Verdict::Held(arm) => { @@ -1336,13 +1338,15 @@ impl TerminalView { if !held { self.release_hold(); self.terminal.write(bytes); - self.typeahead.observe( - RawInput::Key { - key: ks.key.as_str(), - plain, - }, - self.on_alt_screen(), - ); + if !shell_vi_prompt { + self.typeahead.observe( + RawInput::Key { + key: ks.key.as_str(), + plain, + }, + self.on_alt_screen(), + ); + } } // Keep the cursor solid while typing (resets the blink phase). self.cursor_visible = true; @@ -1737,17 +1741,11 @@ impl TerminalView { } "escape" => { // Esc carries no local-editor meaning, so pass it straight to the - // shell — its own zle bindings act on it (vi command mode from - // `bindkey -v`, `\e`-prefixed widgets, menu-select cancel). Encode - // through the shared path so Alt-prefixing and the Kitty `CSI 27 u` - // form stay identical to the raw path; `escape` always encodes, the - // fallback is just belt-and-braces. - // - // Unlike printable text — which the editor mirrors locally and only - // ships on Enter — a bare control byte leaves nothing on zle's line - // to reconcile, so it is deliberately NOT fed to `typeahead.observe`: - // a non-text key taints the record, firing a spurious `^U` on the - // next flush. + // shell — its own zle/readline bindings act on it (vi command + // mode from bindkey/readline vi mode, `\e`-prefixed widgets, + // menu-select cancel). Shell vi-mode itself disables the local + // editor from prompt start, so this is only the emacs-mode + // fallback path. let bytes = super::input::keystroke_to_bytes(ks, self.kitty_flags()) .unwrap_or_else(|| vec![0x1b]); self.terminal.write(bytes); @@ -2773,9 +2771,16 @@ impl TerminalView { if self.on_alt_screen() { return false; } + if self.shell_vi_prompt() { + return false; + } self.at_shell_prompt() } + fn shell_vi_prompt(&self) -> bool { + self.terminal.shell_vi_mode() && self.terminal.at_prompt() && !self.on_alt_screen() + } + /// True while the emulator is on the alternate screen — a full-screen TUI /// owns the pane, so raw input belongs to that program, not the shell's /// next command line. @@ -2825,7 +2830,7 @@ impl TerminalView { /// pane. Only consulted on the raw path, so "the editor is disengaged" is /// already implied. fn gap_holdable(&self) -> bool { - self.terminal.shell_active() && !self.on_alt_screen() + self.terminal.shell_active() && !self.on_alt_screen() && !self.shell_vi_prompt() } /// Write printable gap text (IME commit, paste) toward the shell: offered @@ -2833,6 +2838,11 @@ impl TerminalView { /// written raw and recorded for the deferred wipe. `bytes` is the exact /// PTY encoding (paste may be bracketed-wrapped). fn write_gap_text(&mut self, text: &str, bytes: Vec, cx: &mut Context) { + if self.shell_vi_prompt() { + self.release_hold(); + self.terminal.write(bytes); + return; + } if self.gap_holdable() && !text.chars().any(char::is_control) { match self.hold.hold_text(text, &bytes) { Verdict::Held(arm) => { @@ -4533,7 +4543,19 @@ impl Render for TerminalView { // that arms the flag arrives as pane output, so a render always // follows it (Output → Wakeup → notify). Both prepend: they were // typed before any post-engage keys already sitting in the editor. - if self.input_active() { + if self.shell_vi_prompt() { + // A vi prompt never engages the editor: release held gap input to + // the shell's own line editor (raw, no typeahead record — there is + // no local adoption to reconcile against) and drop any pending + // record without its `^U`. Those bytes land on zle's line and are + // the shell's to keep; a record surviving the vi prompt would + // flush at the next emacs-mode prompt and resurrect long-consumed + // text into the editor. + if let Some((_net, bytes)) = self.hold.release() { + self.terminal.write(bytes); + } + self.typeahead.drain(); + } else if self.input_active() { if let Some(net) = self.hold.engage() { self.cmd.prepend_str(&net); } @@ -5914,6 +5936,248 @@ mod gpui_tests { assert_eq!(next_input_until_timeout(&mut daemon), Some(vec![0x0c])); } + #[gpui::test] + fn shell_vi_mode_prompt_bypasses_the_local_editor(cx: &mut TestAppContext) { + let (window, mut daemon) = harness(cx); + DaemonMsg::Prompt { + active: true, + at_prompt: true, + last_exit: None, + } + .encode(&mut daemon) + .unwrap(); + DaemonMsg::Output(b"\x1b]133;V;1\x07\x1b]133;B\x07".to_vec()) + .encode(&mut daemon) + .unwrap(); + + for _ in 0..200 { + cx.run_until_parked(); + let ready = window + .update(cx, |view, _, _| { + view.terminal.shell_vi_mode() && view.terminal.zle_reading() + }) + .unwrap(); + if ready { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + + window + .update(cx, |view, window, cx| { + assert!( + !view.input_active(), + "shell vi-mode lets the shell line editor own prompt input" + ); + let a = KeyDownEvent { + keystroke: gpui::Keystroke { + modifiers: gpui::Modifiers::default(), + key: "a".to_string(), + key_char: Some("a".to_string()), + }, + is_held: false, + prefer_character_input: false, + }; + view.on_key_down(&a, window, cx); + assert_eq!( + view.cmd.text(), + "", + "vi-mode prompt input must not draw through the local overlay" + ); + }) + .unwrap(); + assert_eq!( + next_input_until_timeout(&mut daemon), + Some(b"a".to_vec()), + "shell vi-mode prompt input must reach the shell directly" + ); + + DaemonMsg::Prompt { + active: true, + at_prompt: true, + last_exit: Some(0), + } + .encode(&mut daemon) + .unwrap(); + DaemonMsg::Output(b"\x1b]133;V;0\x07\x1b]133;B\x07".to_vec()) + .encode(&mut daemon) + .unwrap(); + for _ in 0..200 { + cx.run_until_parked(); + let active = window.update(cx, |view, _, _| view.input_active()).unwrap(); + if active { + return; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + panic!("an emacs-mode prompt should re-enable tty7's local editor"); + } + + #[gpui::test] + fn shell_vi_mode_prompt_input_is_not_typeahead(cx: &mut TestAppContext) { + let (window, mut daemon) = harness(cx); + DaemonMsg::Prompt { + active: true, + at_prompt: true, + last_exit: None, + } + .encode(&mut daemon) + .unwrap(); + DaemonMsg::Output(b"\x1b]133;V;1\x07\x1b]133;B\x07".to_vec()) + .encode(&mut daemon) + .unwrap(); + + for _ in 0..200 { + cx.run_until_parked(); + let ready = window + .update(cx, |view, _, _| { + !view.input_active() + && view.terminal.shell_vi_mode() + && view.terminal.zle_reading() + }) + .unwrap(); + if ready { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + + window + .update(cx, |view, window, cx| { + let i = KeyDownEvent { + keystroke: gpui::Keystroke { + modifiers: gpui::Modifiers::default(), + key: "i".to_string(), + key_char: Some("i".to_string()), + }, + is_held: false, + prefer_character_input: false, + }; + view.on_key_down(&i, window, cx); + }) + .unwrap(); + assert_eq!( + next_input_until_timeout(&mut daemon), + Some(b"i".to_vec()), + "vi prompt input is normal shell input, not deferred gap typeahead" + ); + + DaemonMsg::Output(b"\x1b]133;V;0\x07\x1b]133;B\x07".to_vec()) + .encode(&mut daemon) + .unwrap(); + for _ in 0..200 { + cx.run_until_parked(); + let active = window.update(cx, |view, _, _| view.input_active()).unwrap(); + if active { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + window + .update(cx, |view, _, _| assert!(view.input_active())) + .unwrap(); + assert_eq!( + next_input_until_timeout(&mut daemon), + None, + "leaving shell vi-mode must not flush a stale typeahead wipe" + ); + } + + /// Text typed during a command gap is held for the next prompt's editor — + /// but a vi prompt never engages the editor, so the hold must be released + /// raw (the shell's own line editor consumes it) and the typeahead record + /// dropped. Without that, the record lingers past the whole vi prompt and + /// flushes at the next emacs-mode prompt: a spurious `^U` plus the long- + /// consumed gap text resurrected into the local editor. + #[gpui::test] + fn shell_vi_mode_prompt_releases_gap_hold_without_stale_typeahead(cx: &mut TestAppContext) { + let (window, mut daemon) = harness(cx); + // Shell integration live, a command running: gap input gets held. + DaemonMsg::Prompt { + active: true, + at_prompt: false, + last_exit: None, + } + .encode(&mut daemon) + .unwrap(); + for _ in 0..200 { + cx.run_until_parked(); + let gap = window + .update(cx, |view, _, _| { + view.terminal.shell_active() && !view.terminal.at_prompt() + }) + .unwrap(); + if gap { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + window + .update(cx, |view, _, cx| view.commit_text("ls", cx)) + .unwrap(); + + // The command finishes into a vi-mode prompt. + DaemonMsg::Prompt { + active: true, + at_prompt: true, + last_exit: Some(0), + } + .encode(&mut daemon) + .unwrap(); + DaemonMsg::Output(b"\x1b]133;V;1\x07\x1b]133;B\x07".to_vec()) + .encode(&mut daemon) + .unwrap(); + for _ in 0..200 { + cx.run_until_parked(); + let ready = window + .update(cx, |view, _, _| { + view.terminal.shell_vi_mode() && view.terminal.zle_reading() + }) + .unwrap(); + if ready { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + // Fire any pending hold-window timer too, so both release paths are + // covered regardless of which one runs first. + cx.executor().advance_clock(HOLD_WINDOW * 2); + cx.run_until_parked(); + assert_eq!( + next_input_until_timeout(&mut daemon), + Some(b"ls".to_vec()), + "gap text typed before a vi prompt must reach the shell" + ); + + // Back to an emacs-mode prompt: the editor re-engages empty-handed. + DaemonMsg::Output(b"\x1b]133;V;0\x07\x1b]133;B\x07".to_vec()) + .encode(&mut daemon) + .unwrap(); + for _ in 0..200 { + cx.run_until_parked(); + let active = window.update(cx, |view, _, _| view.input_active()).unwrap(); + if active { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + window + .update(cx, |view, _, _| { + assert!(view.input_active()); + assert_eq!( + view.cmd.text(), + "", + "gap text consumed at the vi prompt must not resurrect in the editor" + ); + }) + .unwrap(); + assert_eq!( + next_input_until_timeout(&mut daemon), + None, + "no stale ^U wipe once the vi prompt consumed the gap text" + ); + } + fn key(spec: &str) -> gpui::Keystroke { gpui::Keystroke::parse(spec).expect("valid keystroke spec") }