mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 00:02:23 +00:00
Merge pull request #102 from ayamir/fix/shell-vi-mode
feat(terminal): support shell vi mode
This commit is contained in:
+20
-4
@@ -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).
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user