diff --git a/docs/configuration.md b/docs/configuration.md index 2987743..f32b1cb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -199,7 +199,7 @@ Normal mode binds `ctrl+o` to `config.core.jump_back` and `ctrl+i` to `config.co `config.core.backspace_word` and `config.core.backspace_line` are typed actions eligible in Insert, Command, and Search. Ctrl+u defaults to `config.core.backspace_line` in all three modes on every platform; it can be rebound or disabled with the ordinary per-mode binding interface. macOS additionally defaults Option+Backspace and Command+Backspace to word and line deletion respectively. Any platform can explicitly bind these actions to supported keys; for example, `config.bind "ctrl+w", config.core.backspace_word, mode: config.modes.insert`. Word deletion follows Unicode word boundaries after skipping trailing whitespace; line deletion removes the caret-to-logical-line-start prefix, not the suffix or newline. Active snippets confine both actions to their argument. macOS prompt and filter queries also recognize these modified Backspace keys. -Command-mode Tab defaults to `config.core.command_complete`: complete a case-sensitive command-name prefix, then cycle alphabetical matches with repeated presses. Core commands, shorthand aliases, and enabled plugin commands share the candidate list; arguments are not completed. For example, `config.bind "ctrl+n", config.core.command_complete, mode: config.modes.command` adds another completion key. Override or disable Tab independently; Search-mode Tab remains literal text. +Command-mode Tab defaults to `config.core.command_complete`, and Shift+Tab to `config.core.command_complete_previous`: complete a case-sensitive command-name prefix, then cycle alphabetical matches forward/backward with wraparound. Shift+Tab starts at the last match; switching direction retains the original prefix. Core commands, shorthand aliases, and enabled plugin commands share the candidate list; arguments are not completed. For example, `config.bind "ctrl+n", config.core.command_complete, mode: config.modes.command` adds another forward completion key. Override or disable Tab and Shift+Tab independently; Search-mode Tab remains literal text. Configuration accepts the Option/Alt and Command/Super Backspace names on every platform, but terminal modifier-event translation for these keys is macOS-only. Native modifier events must deliver Option or Super (Command in Kitty CSI-u); Meta is not Command. Ghostty's default Command+Backspace encoding is Ctrl+u (`\x15`), which invokes the same line-deletion action without a Ghostty override. It remains a distinct Ctrl+u binding, not a reconstructed Command modifier. Legacy ESC+DEL supports Option+Backspace without keyboard protocol negotiation. @@ -227,7 +227,7 @@ Normal and Visual Ctrl+u/Ctrl+d invoke `config.core.scroll_step_up` / `config.co | Normal, Visual | `insert_first_nonblank`, `append_line_end`, `clone_above`, `clone_below` | | Normal | `join_lines`, `undo`, `redo` | | Normal, Visual | `command` | -| Command | `command_complete` | +| Command | `command_complete`, `command_complete_previous` | | Normal | `copy_line`, `delete_line`, `change_line` | | Normal | `change_to_line_end`, `delete_to_line_end`, `copy_to_line_end`, `substitute_character`, `delete_word_forward`, `delete_word_backward`, `change_word_forward`, `change_word_backward` | | Normal, Visual | `replace_character` | diff --git a/docs/development.md b/docs/development.md index 1340ce5..c7429af 100644 --- a/docs/development.md +++ b/docs/development.md @@ -43,7 +43,7 @@ Rust tests live under [`tests/`](../tests/). Private unit tests are in [`tests/u - Editor checks cover Unicode editing, multi-target state, undo/redo, saving failures, and modal paste acceptance and rejection atomicity. - Prompt-history checks cover separate session-wide command/search entries, Unicode draft restoration, editable recalls, clamped/empty/bounded history, duplicate/empty/cancelled submissions, remapped/disabled keys, and preserved Visual/read-only diff targets. Live PTY smoke exercises arrows and raw Ctrl+k/j, restores drafts, accepts a recalled search with Enter, and checks command/search isolation and unchanged source bytes. -- Command-completion checks cover alphabetical cycling/wrap across core names, aliases and live plugin registrations, explicit execution, edits/history/cancel resets, empty/unknown/Unicode prefixes, untouched arguments, remapped/disabled keys, and preserved Visual/read-only diff targets and literal search tabs. Live PTY smoke cycles `tab.*` through wraparound, submits a completed `tab.new`, starts another cycle, and exits with unchanged source bytes. +- Command-completion checks cover forward/backward alphabetical cycling/wrap across core names, aliases and live plugin registrations, reverse-first completion and direction changes retaining the prefix, explicit execution, edits/history/cancel resets, empty/unknown/Unicode prefixes, untouched arguments, remapped/disabled keys, and preserved Visual/read-only diff targets and literal search tabs. Live PTY smoke cycles `tab.*` in both directions through wraparound, submits a completed `tab.new`, and exits with unchanged source bytes. - Visual-command checks preserve directed characterwise/linewise ranges and multiple carets through command cancellation and plugin dispatch, exercise remapped command entry, and submit `:diff.comment` on either historical side. Live PTY smoke selects lines, cancels a prompt, then runs `:diff.comment` directly and verifies the saved original range without using the palette. - Presentation regressions render retained characterwise/linewise highlights during command entry in ordinary, split-diff, and unified views. Floating-review checks preserve source rectangles and the workspace layout, place the caret inside the overlay, retain explicitly saved text across tab switches, and restore source focus on Escape or pane close. - Review prose checks render long comments and summaries with wrapping enabled by default in split/unified views, preserve exact single-line text and source wrapping, and retain an explicit wrap toggle on reopening. Live PTY smoke displays a long comment across continuation rows, saves it, verifies exact text through the review CLI, and reopens it still wrapped. diff --git a/docs/editing.md b/docs/editing.md index f86bbb6..5b6a4bb 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -51,7 +51,7 @@ Indicators refresh asynchronously. Typing and undo/redo retain the last complete | Insert | Type, Enter, Tab, Backspace/Delete, arrows, Home/End at every caret outside snippets; Esc returns to Normal, retaining carets | | Insert | Ctrl+n requests completion at the primary caret or cycles next; Ctrl+j/k cycles next/previous while completion is open | | Insert, active snippet | Type to replace the highlighted argument and its mirrors; Tab / Shift+Tab moves next/previous | -| Command | Tab completes/cycles command names; Enter executes; ↑/↓ or Ctrl+k/j browse history; Backspace edits; Esc cancels | +| Command | Tab / Shift+Tab completes/cycles command names forward/backward; Enter executes; ↑/↓ or Ctrl+k/j browse history; Backspace edits; Esc cancels | | Search | Type a literal query; Enter accepts the current occurrence or finds the next match; ↑/↓ or Ctrl+k/j browse history; Backspace edits; Esc cancels | | Insert, Command, Search | Ctrl+u deletes backward to the logical line start | | Insert, Command, Search (macOS) | Option+Backspace deletes backward by word; Command+Backspace deletes backward to the logical line start | @@ -194,7 +194,7 @@ Submitting an empty search repeats the last query. Esc cancels without moving th Up/Ctrl+k recalls older entries; Down/Ctrl+j recalls newer entries and restores the unfinished input after the newest. Navigation stops at either end without executing a command or moving search targets. Recalled text is editable without changing stored entries. Command and search histories are separate, shared across buffers/panes/tabs, and retained only for this editor session, up to 100 entries each. Enter records nonempty submissions, including unknown commands and searches with no matches; consecutive duplicates are skipped. Empty submissions and cancelled drafts are not recorded. Ctrl+j no longer submits Command/Search input; use Enter. -Tab replaces the command-name prefix with the first case-sensitive match in alphabetical order; repeated presses cycle through the same matches and wrap. Candidates include canonical core commands, shorthand aliases, and commands from currently enabled plugins. Empty input cycles all command names. Typing, deleting, or browsing history starts a fresh completion cycle. Completion only changes the prompt: Enter still executes, and Escape cancels. Arguments (including paths) are not completed; Tab leaves them unchanged. Search-mode Tab remains literal text. Rebind or disable completion with `config.core.command_complete` in Command mode. +Tab replaces the command-name prefix with the first case-sensitive match in alphabetical order; repeated presses cycle forward through the same matches and wrap. Shift+Tab cycles backward, starting at the last match when no cycle is active. Switching direction retains the original prefix. Candidates include canonical core commands, shorthand aliases, and commands from currently enabled plugins. Empty input cycles all command names. Typing, deleting, or browsing history starts a fresh completion cycle. Completion only changes the prompt: Enter still executes, and Escape cancels. Arguments (including paths) are not completed; Tab and Shift+Tab leave them unchanged. Search-mode Tab remains literal text. Rebind or disable forward/backward completion with `config.core.command_complete` / `config.core.command_complete_previous` in Command mode. Command input works in both Normal and Visual modes, including read-only diff panes. The original selection stays highlighted while typing. Enter dispatches with its ranges, direction, and linewise shape intact; individual commands decide whether to consume or retain them. Escape returns to the original Visual selection without executing. For example, select lines with `V`, then type `:diff.comment` and press Enter—no command palette is needed. `config.core.command` can also be bound in Visual mode. diff --git a/src/editor.rs b/src/editor.rs index 48d0288..6139120 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -810,6 +810,11 @@ core_actions![ Action::CommandComplete, &[Mode::Command], ), + ( + "core.command_complete_previous", + Action::CommandCompletePrevious, + &[Mode::Command], + ), ( "core.palette", Action::Palette, @@ -1589,10 +1594,15 @@ fn default_bindings() -> HashMap> { .insert(key, Binding::Action(action)); } } - bindings - .entry(Mode::Command) - .or_default() - .insert(Input::Char('\t'), Binding::Action(CommandComplete)); + for (key, action) in [ + (Input::Char('\t'), CommandComplete), + (Input::BackTab, CommandCompletePrevious), + ] { + bindings + .entry(Mode::Command) + .or_default() + .insert(key, Binding::Action(action)); + } for mode in [Mode::Command, Mode::Search] { for (key, action) in [(Input::Up, Up), (Input::Ctrl('k'), Up), (Input::Down, Down)] { bindings @@ -8020,7 +8030,9 @@ impl Editor { let mode = self.binding_mode(); if !matches!( self.bindings.get(&mode).and_then(|keys| keys.get(&input)), - Some(Binding::Action(Action::CommandComplete)) + Some(Binding::Action( + Action::CommandComplete | Action::CommandCompletePrevious + )) ) { self.active.view.command_completion_prefix = None; } @@ -8437,7 +8449,7 @@ impl Editor { self.active.view.mode = Mode::Command; } - fn complete_command(&mut self) { + fn complete_command(&mut self, forward: bool) { let view = &mut self.active.view; let command = view.command_line.trim_start(); let name = command.strip_prefix(':').unwrap_or(command); @@ -8449,6 +8461,9 @@ impl Editor { let prefix = view .command_completion_prefix .get_or_insert_with(|| name.to_owned()); + let precedes = |left: &str, right: &str| { + if forward { left < right } else { left > right } + }; let mut first = None; let mut next = None; for candidate in self.features.values().flat_map(|feature| &feature.commands) { @@ -8456,10 +8471,13 @@ impl Editor { if !candidate.starts_with(prefix.as_str()) { continue; } - if first.is_none_or(|first| candidate < first) { + if first.is_none_or(|first| precedes(candidate, first)) { first = Some(candidate); } - if cycling && candidate > name && next.is_none_or(|next| candidate < next) { + if cycling + && precedes(name, candidate) + && next.is_none_or(|next| precedes(candidate, next)) + { next = Some(candidate); } } @@ -8563,7 +8581,9 @@ impl Editor { let count = self.count.take().unwrap_or(1); self.clear_sequence(); match action { - Action::CommandComplete => self.complete_command(), + Action::CommandComplete | Action::CommandCompletePrevious => { + self.complete_command(action == Action::CommandComplete); + } Action::SnippetNext => { if self.snippet.is_some() { self.navigate_snippet(true); diff --git a/src/main.rs b/src/main.rs index ca4ff7a..25f455f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -57,7 +57,7 @@ Insert: Ctrl+n requests LSP completion at primary caret, then cycles next; Ctrl+ Outside snippets Enter inserts newline, Tab inserts a tab or configured spaces; Esc returns to Normal Insert/Command/Search: Ctrl+u deletes backward to the logical line start Command/Search: Up/Ctrl+k older history; Down/Ctrl+j newer history or unfinished input; Enter submits -Command: Tab completes command names; repeated presses cycle alphabetical matches (not arguments) +Command: Tab/Shift+Tab completes command names and cycles forward/backward with wraparound (not arguments) Search: Enter accepts current/next literal match, Esc cancels (case-sensitive, wraps) Commands: :save [PATH], :force_save [PATH], :quit, :force_quit, :save_and_quit [PATH], :force_save_and_quit [PATH], :plugins, :PLUGIN [ARGS] diff --git a/tests/unit/editor.rs b/tests/unit/editor.rs index 72e81c0..b60871c 100644 --- a/tests/unit/editor.rs +++ b/tests/unit/editor.rs @@ -1080,8 +1080,10 @@ fn command_completion_cycles_core_alias_and_live_plugin_names_without_executing( assert_eq!(editor.context().text, document); assert!(!editor.should_quit()); } - editor.handle(Input::Char('\t')); - editor.handle(Input::Char('\t')); + for expected in ["save_note", "save_and_quit", "save", "save_note"] { + assert_eq!(editor.handle(Input::BackTab), None); + assert_eq!(editor.command_line(), expected); + } let invocation = editor.handle(Input::Enter).unwrap(); assert_eq!(invocation.plugin, "fixture"); assert_eq!(invocation.command, "save_note"); @@ -1103,6 +1105,50 @@ fn command_completion_cycles_core_alias_and_live_plugin_names_without_executing( } } +#[test] +fn command_completion_reverse_starts_at_last_match_and_retains_prefix_on_direction_changes() { + let mut editor = editor(); + type_text(&mut editor, ":tab."); + for (key, expected) in [ + (Input::BackTab, "tab.previous"), + (Input::BackTab, "tab.next"), + (Input::Char('\t'), "tab.previous"), + (Input::Char('\t'), "tab.close"), + (Input::BackTab, "tab.previous"), + ] { + editor.handle(key); + assert_eq!(editor.command_line(), expected); + assert_eq!(editor.tab_count(), 1); + } + editor.handle(Input::Escape); + for draft in ["plugins", "unknown", "界", "edit file name"] { + type_text(&mut editor, ":"); + type_text(&mut editor, draft); + for _ in 0..2 { + editor.handle(Input::BackTab); + assert_eq!(editor.command_line(), draft); + } + editor.handle(Input::Escape); + } + editor.bind("command", "shift+tab", None).unwrap(); + editor + .bind( + "command", + "ctrl+b", + Some("core.command_complete_previous".into()), + ) + .unwrap(); + type_text(&mut editor, ":tab."); + editor.handle(Input::BackTab); + assert_eq!(editor.command_line(), "tab."); + editor.handle(Input::Ctrl('b')); + assert_eq!(editor.command_line(), "tab.previous"); + editor.handle(Input::Char('\t')); + assert_eq!(editor.command_line(), "tab.close"); + editor.handle(Input::Ctrl('b')); + assert_eq!(editor.command_line(), "tab.previous"); +} + #[test] fn command_completion_restarts_after_edits_history_and_cancellation_and_ignores_arguments() { let mut editor = editor();