Merge pull request #146 from l0ng-ai/fix/tab-completion-fallthrough

fix(completion): fall through to shell completion, dir-only candidates, opt-out
This commit is contained in:
l0ng-ai
2026-07-21 18:39:23 +08:00
committed by GitHub
9 changed files with 373 additions and 25 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
## Input
- **Ghost suggestions** — your history completes the whole line as you type; <kbd>→</kbd> 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** — <kbd>⌃ R</kbd> shows what you ran, where, and whether it failed
- **History from day one** — your existing shell history works as-is and carries across sessions
+1 -1
View File
@@ -5,7 +5,7 @@
## 输入
- **影子建议** —— 边打字边用你的历史补全整条命令,<kbd>→</kbd> 接受
- **带说明的 Tab 补全** —— 每个 flag、每个子命令都带说明,覆盖约 100 个常用命令
- **带说明的 Tab 补全** —— 每个 flag、每个子命令都带说明,覆盖约 100 个常用命令;tty7 没有候选时 Tab 自动交给 shell 自己的补全,整个功能也可关闭(设置 → 终端 → 键盘,或 `config.json` 里的 `tab_completion`
- **语法高亮** —— 边打边亮,什么都不用装
- **模糊历史搜索** —— <kbd>⌃ R</kbd> 看到每条命令在哪跑的、什么时候、有没有失败
- **历史开箱即用** —— 你已有的 shell 历史直接生效,并跨会话延续
+14
View File
@@ -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(
+68 -5
View File
@@ -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<String> {
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<Complet
None => (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<WordCand> {
/// `dirs_only` drops file entries — for commands / argument slots that only
/// accept directories.
fn complete_path(word: &str, cwd: &Path, dirs_only: bool) -> Vec<WordCand> {
// 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<WordCand> {
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::<Vec<_>>())
.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).
+17
View File
@@ -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<i32> {
+7
View File
@@ -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.
+246 -13
View File
@@ -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<u64>,
/// 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<u8>, cx: &mut Context<Self>) {
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<Self>) {
// 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<Self>) {
// 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::<Config>().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<TerminalView>, 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::<Config>().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);
+4
View File
@@ -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>) {
self.update_config(cx, |cfg| cfg.tab_completion = on);
}
pub(crate) fn set_startup_mode(
&mut self,
mode: crate::core::config::StartupMode,
+15 -5
View File
@@ -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(