From 6753b9697bd78a3152ca0d708fe5897f2c5bffd9 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:25:49 +0800 Subject: [PATCH] fix(completion): fall through to shell completion, dir-only candidates, opt-out (#136) Three fixes for tty7's Tab completion: - Tab is no longer swallowed when the engine has no candidates: the locally edited line is handed off to the shell (text shipped raw, cursor walked back, Tab sent) and the local editor suspends until the next prompt cycle, so shell-native completion (compsys, fzf-tab, ...) answers instead. The handoff release keys off a new entered-prompt cycle counter rather than the raw Prompt-frame seq, so same-prompt redraws (PS1-embedded 133;B re-emissions) cannot re-engage the editor while zle still holds the handed-off text. - cd/pushd/popd/rmdir complete directories only in the no-signature path fallback; Fig 'folders' templates narrow signature slots the same way. Symlinks now classify by their target. - New tab_completion config field (default true) plus a Settings -> Terminal -> Keyboard toggle; when off every Tab goes to the shell. --- docs/features.md | 2 +- docs/features.zh-CN.md | 2 +- src/core/config.rs | 14 ++ src/terminal/completion.rs | 73 ++++++++++- src/terminal/remote.rs | 17 +++ src/terminal/signature.rs | 7 + src/terminal/view.rs | 259 +++++++++++++++++++++++++++++++++++-- src/ui/app.rs | 4 + src/ui/settings.rs | 20 ++- 9 files changed, 373 insertions(+), 25 deletions(-) diff --git a/docs/features.md b/docs/features.md index 2ca7c936..f6656f43 100644 --- a/docs/features.md +++ b/docs/features.md @@ -5,7 +5,7 @@ ## Input - **Ghost suggestions** — your history completes the whole line as you type; to accept -- **Explained tab completion** — every flag and subcommand with its description, for ~100 common commands +- **Explained tab completion** — every flag and subcommand with its description, for ~100 common commands; when tty7 has nothing to offer the Tab falls through to your shell's own completion, and the whole feature can be turned off (Settings → Terminal → Keyboard, or `tab_completion` in `config.json`) - **Syntax highlighting** — as you type, nothing to install - **Fuzzy history search** — ⌃ R shows what you ran, where, and whether it failed - **History from day one** — your existing shell history works as-is and carries across sessions diff --git a/docs/features.zh-CN.md b/docs/features.zh-CN.md index 3afeea44..b9cac2bf 100644 --- a/docs/features.zh-CN.md +++ b/docs/features.zh-CN.md @@ -5,7 +5,7 @@ ## 输入 - **影子建议** —— 边打字边用你的历史补全整条命令, 接受 -- **带说明的 Tab 补全** —— 每个 flag、每个子命令都带说明,覆盖约 100 个常用命令 +- **带说明的 Tab 补全** —— 每个 flag、每个子命令都带说明,覆盖约 100 个常用命令;tty7 没有候选时 Tab 自动交给 shell 自己的补全,整个功能也可关闭(设置 → 终端 → 键盘,或 `config.json` 里的 `tab_completion`) - **语法高亮** —— 边打边亮,什么都不用装 - **模糊历史搜索** —— ⌃ R 看到每条命令在哪跑的、什么时候、有没有失败 - **历史开箱即用** —— 你已有的 shell 历史直接生效,并跨会话延续 diff --git a/src/core/config.rs b/src/core/config.rs index 7f5927d9..1d63a1b8 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -153,6 +153,13 @@ pub struct Config { /// visual flash (the current behavior). #[serde(default, deserialize_with = "de_lenient")] pub bell: BellMode, + /// Tab at the prompt opens tty7's own completion menu (commands, paths, + /// per-command signatures). On by default. When off — or whenever the + /// engine has nothing to offer — the prompt line is handed to the shell + /// and Tab goes to the PTY, so the shell's native completion (compsys, + /// fzf-tab, …) answers instead. + #[serde(default = "default_true")] + pub tab_completion: bool, // ── Appearance ────────────────────────────────────────────────────────── /// The shape drawn for the terminal cursor. @@ -503,6 +510,7 @@ impl Default for Config { // Visual flash preserves the pre-config behavior (the bell always // flashed); opting into None/Audible is a deliberate change. bell: BellMode::Visual, + tab_completion: true, cursor_style: CursorStyle::Block, // Input/mouse defaults preserve today's behavior: Option composes // characters as macOS ships it (opt into Option-as-Meta); GPUI @@ -1109,6 +1117,7 @@ mod tests { let cfg = Config::default(); assert!(cfg.restore_session); assert!(cfg.mouse_reporting); + assert!(cfg.tab_completion); assert_eq!(cfg.notify_threshold_secs, 10); assert_eq!(cfg.bell, BellMode::Visual); @@ -1117,9 +1126,14 @@ mod tests { let cfg: Config = serde_json::from_str(r#"{"font_size": 15.0}"#).unwrap(); assert!(cfg.restore_session); assert!(cfg.mouse_reporting); + assert!(cfg.tab_completion); assert_eq!(cfg.notify_threshold_secs, 10); assert_eq!(cfg.bell, BellMode::Visual); + // The opt-out round-trips. + let cfg: Config = serde_json::from_str(r#"{"tab_completion": false}"#).unwrap(); + assert!(!cfg.tab_completion); + // Valid values round-trip; a bad bell string falls back without failing // the whole parse. let cfg: Config = serde_json::from_str( diff --git a/src/terminal/completion.rs b/src/terminal/completion.rs index 76cce717..8a033375 100644 --- a/src/terminal/completion.rs +++ b/src/terminal/completion.rs @@ -118,6 +118,30 @@ const BUILTINS: &[&str] = &[ /// (or `$PATH` entries) can't blow up the UI or the cycle. const MAX_CANDIDATES: usize = 400; +/// Commands whose arguments are directories, never files. They have no Fig +/// signature (shell builtins), so the generic path fallback handles them — +/// which must not offer files (`cd tar` completing to `tar.exe` is never +/// right). +const DIR_ONLY_COMMANDS: &[&str] = &["cd", "pushd", "popd", "rmdir"]; + +/// The command name the cursor's word is an argument of: the first token of +/// the current simple command (after the last shell separator), reduced to its +/// basename so `/bin/rmdir` matches like `rmdir`. `None` when there is no +/// command token before the word. +fn current_command(chars: &[char], word_start: usize) -> Option { + let prefix: String = chars[..word_start].iter().collect(); + let seg_start = prefix + .rfind(['|', '&', ';', '\n', '(']) + .map(|i| i + 1) + .unwrap_or(0); + let cmd = prefix[seg_start..].split_whitespace().next()?; + let base = cmd + .rfind(std::path::is_separator) + .map(|i| &cmd[i + 1..]) + .unwrap_or(cmd); + (!base.is_empty()).then(|| base.to_string()) +} + /// Compute completions for `line` at char position `cursor`, resolving relative /// paths against `cwd`: command names in command position, filesystem paths /// elsewhere. Returns `None` when there's nothing to offer. @@ -155,7 +179,13 @@ pub fn complete(line: &str, cursor: usize, cwd: Option<&Path>) -> Option (Vec::new(), Vec::new()), Some(cwd) => match complete_signature(&chars, word_start, &word, cwd) { Some(sig) => (sig.cands, sig.pending), - None => (complete_path(&word, cwd), Vec::new()), + None => { + // No signature: generic paths, narrowed to directories when + // the command only takes those (`cd`, `pushd`, …). + let dirs_only = current_command(&chars, word_start) + .is_some_and(|c| DIR_ONLY_COMMANDS.contains(&c.as_str())); + (complete_path(&word, cwd, dirs_only), Vec::new()) + } }, } }; @@ -246,7 +276,9 @@ fn sort_candidates_by_closeness(cands: &mut [Candidate]) { /// Filesystem path completion. Splits `word` into the directory part (kept /// verbatim in each candidate so the typed path prefix is preserved) and the /// final-segment prefix to match in that directory. Ordered by closeness. -fn complete_path(word: &str, cwd: &Path) -> Vec { +/// `dirs_only` drops file entries — for commands / argument slots that only +/// accept directories. +fn complete_path(word: &str, cwd: &Path, dirs_only: bool) -> Vec { // Split on the last path separator. `is_separator` is `/` on Unix and both // `/` and `\` on Windows, so a `C:\Users\me\f`-style word splits correctly // under the (future) Windows line editor; separators are ASCII so the byte @@ -270,7 +302,15 @@ fn complete_path(word: &str, cwd: &Path) -> Vec { if !name.starts_with(prefix) { continue; } - let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false); + // Follow symlinks when classifying: a symlink to a directory must count + // as one (it both takes the trailing `/` and survives a dirs-only + // filter — `cd` into a linked dir is routine). + let is_dir = entry + .file_type() + .is_ok_and(|t| t.is_dir() || (t.is_symlink() && entry.path().is_dir())); + if dirs_only && !is_dir { + continue; + } let kind = if is_dir { CandidateKind::Dir } else { @@ -359,7 +399,7 @@ fn complete_signature( let mut out = Vec::new(); push_arg_suggestions(&mut out, arg, word); if arg.wants_paths() { - out.extend(complete_path(word, cwd)); + out.extend(complete_path(word, cwd, arg.wants_dirs_only())); } let pending = collect_generators(arg); // A slot that declares suggestions or generators owns the position even @@ -396,7 +436,7 @@ fn complete_signature( if let Some(arg) = node.args().first() { push_arg_suggestions(&mut out, arg, word); if arg.wants_paths() { - out.extend(complete_path(word, cwd)); + out.extend(complete_path(word, cwd, arg.wants_dirs_only())); } pending = collect_generators(arg); // Suggestions/generators mean this positional owns the slot: don't cede @@ -835,6 +875,29 @@ mod tests { assert_eq!(s.selected().unwrap().text, "branch-b"); } + #[test] + fn dir_only_commands_complete_only_directories() { + // `cd tar` must offer `target/`, never `tar.gz` (#136) — same for the + // other dir-only builtins, and for absolute spellings by basename. + let dir = temp_tree("dironly", &[("target", true), ("tar.gz", false)]); + let only_dirs = |line: &str| { + complete(line, line.chars().count(), Some(dir.as_path())) + .map(|c| c.candidates.into_iter().map(|c| c.text).collect::>()) + .unwrap_or_default() + }; + assert_eq!(only_dirs("cd tar"), vec!["target"]); + assert_eq!(only_dirs("pushd tar"), vec!["target"]); + assert_eq!(only_dirs("/bin/rmdir tar"), vec!["target"]); + // Only the current simple command counts: `cd` after a pipe governs. + assert_eq!(only_dirs("foo | cd tar"), vec!["target"]); + // A bare argument slot narrows too. + assert_eq!(only_dirs("cd "), vec!["target"]); + // A generic command keeps offering files alongside directories. + let both = only_dirs("frobnicate tar"); + assert!(both.contains(&"tar.gz".to_string()), "{both:?}"); + assert!(both.contains(&"target".to_string()), "{both:?}"); + } + #[test] fn unknown_command_falls_back_to_paths() { // A command with no signature still path-completes (no panic, no menu here). diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index 799a6184..1ed0799b 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -98,6 +98,14 @@ struct ShellState { /// back) from the stale pre-submit state — even when 1 Hz polling misses /// the intermediate not-at-prompt window of a fast command. seq: u64, + /// Monotonic count of *entered-prompt edges*: bumped only when a report + /// flips `at_prompt` false → true. Unlike `seq` it ignores same-prompt + /// redraws — prompt frameworks re-emit the PS1-embedded `133;B` on every + /// `reset-prompt` / completion-list reprint, and each re-emission is + /// another `Prompt` frame. The Tab handoff keys its release off this + /// (see `TerminalView::editor_handoff`): only a command actually running + /// (`133;C` → not-at-prompt) starts a new cycle. + cycle: u64, } /// The shared handles the reader thread writes into as daemon frames arrive; @@ -636,6 +644,8 @@ impl RemoteTerminal { at_prompt, last_exit, seq: guard.seq + 1, + cycle: guard.cycle + + u64::from(at_prompt && !guard.at_prompt), }; } // The shell just reported a fresh prompt, so at @@ -910,6 +920,13 @@ impl RemoteTerminal { self.shell_state.lock().map(|s| s.seq).unwrap_or(0) } + /// Monotonic count of entered-prompt edges — see [`ShellState::cycle`]. + /// Stable across same-prompt redraws (which bump `seq` but not this); + /// only leaving the prompt for a command and coming back advances it. + pub fn prompt_cycle(&self) -> u64 { + self.shell_state.lock().map(|s| s.cycle).unwrap_or(0) + } + /// Exit code of the most recently completed foreground command, as sniffed /// from OSC 133;D daemon-side. `None` before any command has finished. pub fn last_exit_code(&self) -> Option { diff --git a/src/terminal/signature.rs b/src/terminal/signature.rs index 3c346ddb..86b77bef 100644 --- a/src/terminal/signature.rs +++ b/src/terminal/signature.rs @@ -125,6 +125,13 @@ impl Arg { .iter() .any(|t| t == "filepaths" || t == "folders") } + + /// Whether this arg's filesystem completion is directories only — a Fig + /// `folders` template with no `filepaths` alongside it. + pub fn wants_dirs_only(&self) -> bool { + self.template.iter().any(|t| t == "folders") + && !self.template.iter().any(|t| t == "filepaths") + } } /// A static value suggestion for an argument. diff --git a/src/terminal/view.rs b/src/terminal/view.rs index eee4bc3b..b59a1fd8 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -328,6 +328,18 @@ pub struct TerminalView { /// its result is dropped unless it still matches — so output from a session /// the user has since closed (or replaced) can never leak into a later menu. completion_generation: u64, + /// While equal to the terminal's current `prompt_cycle`, the local line + /// editor has handed this prompt's line over to the shell (Tab fell + /// through to shell-native completion — see + /// [`Self::handoff_tab_to_shell`]): the shell's own editor now holds the + /// text, so keys go raw to the PTY exactly as on a shell-vi-mode prompt. + /// Keyed to the entered-prompt *cycle*, not the raw report seq — a + /// same-prompt redraw (completion list, `reset-prompt`) re-emits the + /// PS1-embedded `133;B` and would bump the seq while zle still holds the + /// handed-off text; re-engaging there would fork the two line buffers. + /// Only a command actually running starts a new cycle and re-engages the + /// editor. + editor_handoff: Option, /// Active Ctrl+R history search, if any. While set, the editor shows a /// `(reverse-i-search)` prompt instead of the line and a menu of the ranked /// matches floats beside it: typing edits the query (fuzzy, blended with @@ -1025,6 +1037,7 @@ impl TerminalView { pending_history: None, completion: None, completion_generation: 0, + editor_handoff: None, reverse_search: None, integration_notice: None, integration_notice_shown: false, @@ -1431,7 +1444,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(); + let shell_owns_prompt = self.shell_owns_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 @@ -1439,7 +1452,7 @@ impl TerminalView { // for the deferred wipe. let held = plain && ks.key == "backspace" - && !shell_vi_prompt + && !shell_owns_prompt && self.gap_holdable() && match self.hold.hold_backspace(&bytes) { Verdict::Held(arm) => { @@ -1453,7 +1466,7 @@ impl TerminalView { if !held { self.release_hold(); self.terminal.write(bytes); - if !shell_vi_prompt { + if !shell_owns_prompt { self.typeahead.observe( RawInput::Key { key: ks.key.as_str(), @@ -2965,6 +2978,12 @@ impl TerminalView { if self.shell_vi_prompt() { return false; } + // A Tab handoff gave this prompt's line to the shell; until a command + // runs and a fresh prompt cycle starts, the shell's editor owns it, + // and re-engaging ours would fork the two line buffers. + if self.editor_handoff == Some(self.terminal.prompt_cycle()) { + return false; + } self.at_shell_prompt() } @@ -2972,6 +2991,25 @@ impl TerminalView { self.terminal.shell_vi_mode() && self.terminal.at_prompt() && !self.on_alt_screen() } + /// True while a Tab handoff has given the current prompt's line to the + /// shell (see [`Self::handoff_tab_to_shell`]) and the shell is still in + /// that prompt cycle. Over once a command runs and the next prompt + /// arrives (a false→true `at_prompt` edge bumps the cycle). + fn handoff_active(&self) -> bool { + self.editor_handoff == Some(self.terminal.prompt_cycle()) + && self.terminal.at_prompt() + && !self.on_alt_screen() + } + + /// True while the shell's own line editor owns the prompt line — a + /// vi-mode prompt, or one whose line a Tab handoff shipped over. Raw + /// input then goes to the PTY with no hold and no typeahead record: + /// those bytes land on zle's line and are the shell's to keep, so a + /// deferred `^U` wipe would erase text the user can see. + fn shell_owns_prompt(&self) -> bool { + self.shell_vi_prompt() || self.handoff_active() + } + /// 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. @@ -3021,7 +3059,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.shell_vi_prompt() + self.terminal.shell_active() && !self.on_alt_screen() && !self.shell_owns_prompt() } /// Write printable gap text (IME commit, paste) toward the shell: offered @@ -3029,7 +3067,7 @@ 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() { + if self.shell_owns_prompt() { self.release_hold(); self.terminal.write(bytes); return; @@ -3364,6 +3402,47 @@ impl TerminalView { cx.notify(); } + /// Hand the prompt line over to the shell so its native completion + /// (compsys, fzf-tab, …) answers the Tab tty7 has nothing for: ship the + /// locally edited text to the PTY (no newline), clear the editor, send + /// the Tab / Shift-Tab bytes, and suspend the local editor until the + /// shell's next report. From here the shell's own editor holds the text — + /// re-engaging ours mid-line would fork the two buffers (its Enter would + /// submit an empty local line on top of zle's populated one). + fn handoff_tab_to_shell(&mut self, shift: bool, cx: &mut Context) { + // Fold in any gap input still held, so the shipped line is what the + // user actually typed. + if let Some(net) = self.hold.engage() { + self.cmd.prepend_str(&net); + } + let line = self.cmd.text(); + // An embedded newline would submit on the shell side (zle runs the + // line on `\r`), so a multi-line draft can't be handed over losslessly + // — keep it local and swallow the Tab as before. + if line.contains('\n') { + cx.notify(); + return; + } + self.close_completion(); + // A pending typeahead wipe's deferred `^U` would erase the very text + // we're about to ship; flush it first (FIFO keeps it ahead). + self.wipe_pending_typeahead(); + // Chars right of the caret: after the shipped text lands, walk zle's + // cursor back over them so the shell completes the word the caret was + // on, not the line's tail. + let tail = line.chars().count().saturating_sub(self.cmd.cursor()); + if !line.is_empty() { + self.terminal.write(line.into_bytes()); + if tail > 0 { + self.terminal.write(b"\x1b[D".repeat(tail)); + } + } + self.cmd.clear(); + self.editor_handoff = Some(self.terminal.prompt_cycle()); + let bytes = self.tab_bytes(shift); + self.send_to_pty(&bytes, cx); + } + /// Tab completion over our own engine (command names in command /// position, filesystem paths elsewhere — history is deliberately absent: /// whole-line recall is ghost text's and Ctrl+R's job). A fresh Tab applies a @@ -3373,6 +3452,17 @@ impl TerminalView { /// With the menu open, Tab fills any further common prefix, else moves the /// highlight (`forward` reverses for Shift-Tab). fn complete_tab(&mut self, forward: bool, cx: &mut Context) { + // Ctrl+R search owns the keyboard: `self.cmd` still holds the stale + // pre-search line, so neither completing it nor shipping it to the + // shell makes sense here. + if self.reverse_search.is_some() { + return; + } + // tty7 completion switched off: every Tab goes to the shell. + if !cx.global::().tab_completion { + self.handoff_tab_to_shell(!forward, cx); + return; + } if self.completion.is_some() { self.completion_tab_step(forward, cx); return; @@ -3390,6 +3480,9 @@ impl TerminalView { let line = self.cmd.text(); let cursor = self.cmd.cursor(); let Some(comp) = super::completion::complete(&line, cursor, cwd.as_deref()) else { + // Nothing to offer. Don't swallow the keypress (#136) — hand the + // line to the shell and let its completion have the Tab. + self.handoff_tab_to_shell(!forward, cx); return; }; @@ -4788,14 +4881,14 @@ 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.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 self.shell_owns_prompt() { + // A vi-mode (or handed-off) 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 this prompt would flush at the next editor-engaged + // prompt and resurrect long-consumed text into the editor. if let Some((_net, bytes)) = self.hold.release() { self.terminal.write(bytes); } @@ -6431,6 +6524,146 @@ mod gpui_tests { panic!("an emacs-mode prompt should re-enable tty7's local editor"); } + /// Wait until the daemon-fed prompt state makes the local editor live. + fn wait_for_input_active(window: &gpui::WindowHandle, cx: &mut TestAppContext) { + 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!("the local editor never engaged at the prompt"); + } + + /// Tab the engine has nothing for must not be swallowed (#136): the + /// locally edited line is shipped to the shell followed by the Tab + /// itself, and the local editor stays out of the way until the shell + /// reports its next prompt — from there the shell's own completion owns + /// the line. + #[gpui::test] + fn tab_with_no_candidates_hands_the_line_to_the_shell(cx: &mut TestAppContext) { + let (window, mut daemon) = harness(cx); + DaemonMsg::Prompt { + active: true, + at_prompt: true, + last_exit: None, + } + .encode(&mut daemon) + .unwrap(); + wait_for_input_active(&window, cx); + + window + .update(cx, |view, window, cx| { + // A command-position word matching no builtin or $PATH entry, + // so the completion engine returns `None`. + for ch in ["z", "z", "q", "q", "x"] { + type_char(view, ch, window, cx); + } + assert_eq!(view.cmd.text(), "zzqqx"); + view.complete_tab(true, cx); + assert_eq!(view.cmd.text(), "", "the line moved to the shell"); + assert!( + !view.input_active(), + "the shell owns the prompt after the handoff" + ); + }) + .unwrap(); + assert_eq!( + next_input_until_timeout(&mut daemon), + Some(b"zzqqx".to_vec()), + "the edited line ships ahead of the Tab" + ); + assert_eq!( + next_input_until_timeout(&mut daemon), + Some(b"\t".to_vec()), + "the Tab reaches the PTY instead of being swallowed" + ); + + // A same-prompt redraw (a prompt framework re-emitting the + // PS1-embedded `133;B` on reset-prompt / a completion list reprint) + // must NOT re-engage the editor — zle still holds the handed-off + // text, and an engaged-empty editor would fork the two buffers. + DaemonMsg::Prompt { + active: true, + at_prompt: true, + last_exit: None, + } + .encode(&mut daemon) + .unwrap(); + for _ in 0..200 { + cx.run_until_parked(); + let applied = window + .update(cx, |view, _, _| view.terminal.prompt_seq() >= 2) + .unwrap(); + if applied { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + window + .update(cx, |view, _, _| { + assert!( + !view.input_active(), + "a same-prompt redraw must not re-engage the editor" + ); + }) + .unwrap(); + + // A real command cycle — the shell leaves the prompt and comes back — + // re-engages the local editor. + DaemonMsg::Prompt { + active: true, + at_prompt: false, + last_exit: None, + } + .encode(&mut daemon) + .unwrap(); + DaemonMsg::Prompt { + active: true, + at_prompt: true, + last_exit: Some(0), + } + .encode(&mut daemon) + .unwrap(); + wait_for_input_active(&window, cx); + } + + /// With `tab_completion` off, Tab never opens tty7's menu — even when the + /// engine would have candidates, the line and the Tab go to the shell. + #[gpui::test] + fn tab_completion_off_sends_every_tab_to_the_shell(cx: &mut TestAppContext) { + let (window, mut daemon) = harness(cx); + cx.update(|cx| { + let mut cfg = cx.global::().clone(); + cfg.tab_completion = false; + cx.set_global(cfg); + }); + DaemonMsg::Prompt { + active: true, + at_prompt: true, + last_exit: None, + } + .encode(&mut daemon) + .unwrap(); + wait_for_input_active(&window, cx); + + window + .update(cx, |view, window, cx| { + // "cd " would offer path candidates were the engine consulted. + for ch in ["c", "d", " "] { + type_char(view, ch, window, cx); + } + view.complete_tab(true, cx); + assert!(view.completion.is_none(), "no tty7 menu while opted out"); + assert_eq!(view.cmd.text(), ""); + }) + .unwrap(); + assert_eq!(next_input_until_timeout(&mut daemon), Some(b"cd ".to_vec())); + assert_eq!(next_input_until_timeout(&mut daemon), Some(b"\t".to_vec())); + } + #[gpui::test] fn shell_vi_mode_prompt_input_is_not_typeahead(cx: &mut TestAppContext) { let (window, mut daemon) = harness(cx); diff --git a/src/ui/app.rs b/src/ui/app.rs index 666bb225..e19592ed 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -1996,6 +1996,10 @@ impl Tty7App { self.update_config(cx, |cfg| cfg.smart_select = on); } + pub(crate) fn set_tab_completion(&mut self, on: bool, cx: &mut Context) { + self.update_config(cx, |cfg| cfg.tab_completion = on); + } + pub(crate) fn set_startup_mode( &mut self, mode: crate::core::config::StartupMode, diff --git a/src/ui/settings.rs b/src/ui/settings.rs index fe07863a..e12bd0c4 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -2772,6 +2772,7 @@ impl Tty7App { let copy_on_select = cfg.copy_on_select; let mouse_reporting = cfg.mouse_reporting; let smart_select = cfg.smart_select; + let tab_completion = cfg.tab_completion; let bell = cfg.bell; // Map the persisted threshold onto its preset radio index (nearest slot // for any off-preset value a hand-edit might leave). @@ -2869,6 +2870,10 @@ impl Tty7App { .checked(smart_select) .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_smart_select(*on, cx))) .into_any_element(); + let tab_completion_switch = Switch::new("term-tab-completion") + .checked(tab_completion) + .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_tab_completion(*on, cx))) + .into_any_element(); let bell_idx = match bell { BellMode::None => 0, BellMode::Visual => 1, @@ -2975,11 +2980,16 @@ impl Tty7App { smart_select_switch, cx, )) - .when_some(option_alt_row, |v, row| { - v.child(self.section_rule(cx)) - .child(self.section_header("Keyboard", cx)) - .child(row) - }) + .child(self.section_rule(cx)) + .child(self.section_header("Keyboard", cx)) + .child(self.settings_row( + "Tab completion", + "Tab at the prompt opens tty7's completion menu. When off, Tab goes to the \ + shell's own completion instead.", + tab_completion_switch, + cx, + )) + .when_some(option_alt_row, |v, row| v.child(row)) .child(self.section_rule(cx)) .child(self.section_header("Links", cx)) .child(self.settings_row(