From 8180d2216653c631ba00cc75dbeff8c081ed15ef Mon Sep 17 00:00:00 2001 From: ayamir Date: Thu, 16 Jul 2026 12:20:21 +0800 Subject: [PATCH 1/3] fix(terminal): support shell vi mode --- src/daemon/pane.rs | 24 +++- src/daemon/shell_integration.rs | 28 +++++ src/terminal/remote.rs | 140 +++++++++++++++++++++-- src/terminal/view.rs | 195 ++++++++++++++++++++++++++++---- 4 files changed, 356 insertions(+), 31 deletions(-) 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..0442935e 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,14 @@ if [[ -o interactive ]] && [[ -z "$TTY7_SHELL_INTEGRATION" ]]; then __tty7_osc() { builtin printf '\e]%s\a' "$1"; } + __tty7_report_edit_mode() { + if [[ "$(builtin bindkey '^[')" == *"vi-cmd-mode"* ]]; 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 +88,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 +158,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 +188,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 +224,13 @@ if [[ $- == *i* ]] && [[ -z "$TTY7_SHELL_INTEGRATION" ]]; then export TTY7_SHELL_INTEGRATION=1 __tty7_osc() { builtin printf '\e]%s\a' "$1"; } + __tty7_report_edit_mode() { + if [[ -o 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 +249,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) diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index ad68b8ec..b74dbf94 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -111,6 +111,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 @@ -169,6 +170,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. @@ -317,6 +322,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)); @@ -334,6 +340,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(), }, @@ -353,6 +360,7 @@ impl RemoteTerminal { exited_flag, child_exited, zle_reading, + shell_vi_mode, auth_prompts, ssh_phase, ssh_endpoint: None, @@ -393,6 +401,7 @@ impl RemoteTerminal { exited: exited_flag, child_exited, zle_reading, + shell_vi_mode, auth, phase, } = signals; @@ -411,10 +420,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 @@ -490,16 +503,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); @@ -566,6 +596,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); } @@ -893,6 +931,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 } @@ -2300,6 +2342,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..05537313 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) => { @@ -5914,6 +5924,153 @@ 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" + ); + } + fn key(spec: &str) -> gpui::Keystroke { gpui::Keystroke::parse(spec).expect("valid keystroke spec") } From 418433395bfd084ccce1e6755be526f72d02033a Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:56:51 +0800 Subject: [PATCH 2/3] fix(terminal): release gap hold at vi prompts without a stale typeahead record Text typed during a command gap is held for the next prompt's local editor, but a vi prompt never engages the editor: the hold sat until the 150ms timer dumped it and recorded it as typeahead, and that record could never drain during vi prompts. It lingered and flushed at the next emacs-mode prompt, firing a spurious ^U and resurrecting the long-consumed gap text into the editor. A vi prompt now releases held gap input raw (the shell's line editor owns it) and drops any pending typeahead record without its wipe. --- src/terminal/view.rs | 109 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 108 insertions(+), 1 deletion(-) diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 05537313..78acc5f4 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -4543,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); } @@ -6071,6 +6083,101 @@ mod gpui_tests { ); } + /// 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") } From f4e63d076e8ec3d8772fc130eba50a85232a8554 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:56:51 +0800 Subject: [PATCH 3/3] fix(daemon): detect shell vi mode via durable signals zsh: plugins like zsh-vi-mode rebind ^[ to their own widgets, so sniffing the Esc widget for vi-cmd-mode missed them. Key off the main keymap link instead (bindkey -A viins main), which both plain bindkey -v and zsh-vi-mode establish. bash: [[ -o vi ]] misses vi mode configured only in ~/.inputrc (set editing-mode vi flips readline without the shell option); read readline's actual mode via bind -v instead. --- src/daemon/shell_integration.rs | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/src/daemon/shell_integration.rs b/src/daemon/shell_integration.rs index 0442935e..59474e71 100644 --- a/src/daemon/shell_integration.rs +++ b/src/daemon/shell_integration.rs @@ -55,8 +55,12 @@ 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 '^[')" == *"vi-cmd-mode"* ]]; then + if [[ "$(builtin bindkey -lL main)" == *viins* ]]; then __tty7_osc "133;V;1" else __tty7_osc "133;V;0" @@ -224,8 +228,11 @@ 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 [[ -o vi ]]; then + if [[ "$(builtin bind -v 2>/dev/null)" == *"set editing-mode vi"* ]]; then __tty7_osc "133;V;1" else __tty7_osc "133;V;0" @@ -887,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.