Merge pull request #102 from ayamir/fix/shell-vi-mode

feat(terminal): support shell vi mode
This commit is contained in:
l0ng-ai
2026-07-16 15:02:40 +08:00
committed by GitHub
4 changed files with 492 additions and 32 deletions
+20 -4
View File
@@ -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::<i32>().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).
+56
View File
@@ -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 ; <exit> 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<Injection>
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.
+132 -8
View File
@@ -112,6 +112,7 @@ struct ReaderSignals {
exited: Arc<AtomicBool>,
child_exited: Arc<AtomicBool>,
zle_reading: Arc<AtomicBool>,
shell_vi_mode: Arc<AtomicBool>,
/// 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<AtomicBool>,
/// 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<AtomicBool>,
/// 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<Mutex<VecDeque<(u64, AuthPromptKind)>>> =
Arc::new(Mutex::new(VecDeque::new()));
let ssh_phase: Arc<Mutex<Option<SshPhase>>> = 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 {
+284 -20
View File
@@ -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<u8>, cx: &mut Context<Self>) {
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")
}