feat: cycle command completions with Tab

This commit is contained in:
2026-10-17 18:05:45 +02:00
parent 5697a08c0a
commit 5d75964ee2
7 changed files with 175 additions and 8 deletions
+2 -7
View File
@@ -1,10 +1,5 @@
- [x] syntax highlighting for Honey (honeylang)
- honeylang treesitter project located at `/Users/valdemar/Developer/Personal/Languages/tree-sitter-honey`.
- [x] bug: `x` cut action does not copy the hovered character if not in selection mode, though should always copy.
- [x] make diff comments word wrap / line wrap by default
- [x] currently, a review comment seems to be attached even if the user doesn't "save" or write the comment buffer (e.g. using `:w`). just closing it with escape still seems to write it to the db.
- comments should be discardable
- [x] cycle previous commands and search text in command and search input when pressing arrows up/down or ctrl+j/k.
- [x] command autocompletion via `tab` (multiple presses cycles candidates)
- [ ] fix ruby linting for the vamp config file (see attached image)
- [ ] diff view: GitHub code review integration after the local review UX is usable.
- publish local reviews explicitly: general PR comments, inline line/range comments, review summaries, approval, and requests for changes.
- preserve explicit PR identity and reviewed commit IDs; validate anchors against GitHub's PR diff and recheck the PR head before submission.
+3
View File
@@ -199,6 +199,8 @@ 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.
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.
Normal mode uses literal `_` and `|` for above/below and left/right splits, and `alt+h/j/k/l` for directional pane focus. Bind the produced punctuation rather than a physical Shift combination. Close actions have no default binding; for example, `config.bind "leader x", config.core.pane.close`. See [panes and splits](editing.md#panes-and-splits) for shared-buffer and unsaved-change policies.
@@ -225,6 +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` |
| 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` |
+1
View File
@@ -43,6 +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.
- 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.
+3 -1
View File
@@ -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 | Enter executes; ↑/↓ or Ctrl+k/j browse history; Backspace edits; Esc cancels |
| Command | Tab completes/cycles command names; 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,6 +194,8 @@ 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.
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.
The latest reported operation result appears in a non-focusable popup at the workspace's bottom-right, just above the bottom bar when visible, with one column of inner padding on each side when space permits. It remains until dismissed or replaced, with no timer. In Normal mode, the first Escape dismisses it without collapsing carets; a subsequent Escape retains ordinary Normal behavior. Existing modal windows, focused explorers/diff selectors, and pending prefixes retain Escape priority. New operation messages reopen the popup; routine successful buffer switches do not duplicate the buffer/path information already shown in the bar.
+50
View File
@@ -805,6 +805,11 @@ core_actions![
Action::Submit,
&[Mode::Command, Mode::Search],
),
(
"core.command_complete",
Action::CommandComplete,
&[Mode::Command],
),
(
"core.palette",
Action::Palette,
@@ -1584,6 +1589,10 @@ fn default_bindings() -> HashMap<Mode, HashMap<Input, Binding>> {
.insert(key, Binding::Action(action));
}
}
bindings
.entry(Mode::Command)
.or_default()
.insert(Input::Char('\t'), Binding::Action(CommandComplete));
for mode in [Mode::Command, Mode::Search] {
for (key, action) in [(Input::Up, Up), (Input::Ctrl('k'), Up), (Input::Down, Down)] {
bindings
@@ -1751,6 +1760,7 @@ struct ViewState {
search_highlight: bool,
search_match: Option<SearchMatch>,
command_line: String,
command_completion_prefix: Option<String>,
prompt_history_index: Option<usize>,
prompt_draft: String,
command_from_visual: bool,
@@ -1775,6 +1785,7 @@ impl ViewState {
search_highlight: false,
search_match: None,
command_line: String::new(),
command_completion_prefix: None,
prompt_history_index: None,
prompt_draft: String::new(),
command_from_visual: false,
@@ -1799,6 +1810,7 @@ impl ViewState {
search_highlight: self.search_highlight,
search_match: self.search_match,
command_line: self.command_line.clone(),
command_completion_prefix: self.command_completion_prefix.clone(),
prompt_history_index: self.prompt_history_index,
prompt_draft: self.prompt_draft.clone(),
command_from_visual: self.command_from_visual,
@@ -8006,6 +8018,12 @@ impl Editor {
fn handle_input(&mut self, input: Input) -> Option<Invocation> {
let mode = self.binding_mode();
if !matches!(
self.bindings.get(&mode).and_then(|keys| keys.get(&input)),
Some(Binding::Action(Action::CommandComplete))
) {
self.active.view.command_completion_prefix = None;
}
if self.completion.is_some() && (self.diff().is_none() || self.diff_editing()) {
let request = matches!(
self.bindings
@@ -8419,6 +8437,37 @@ impl Editor {
self.active.view.mode = Mode::Command;
}
fn complete_command(&mut self) {
let view = &mut self.active.view;
let command = view.command_line.trim_start();
let name = command.strip_prefix(':').unwrap_or(command);
if name.contains(char::is_whitespace) {
return;
}
let start = view.command_line.len() - name.len();
let cycling = view.command_completion_prefix.is_some();
let prefix = view
.command_completion_prefix
.get_or_insert_with(|| name.to_owned());
let mut first = None;
let mut next = None;
for candidate in self.features.values().flat_map(|feature| &feature.commands) {
let candidate = candidate.as_str();
if !candidate.starts_with(prefix.as_str()) {
continue;
}
if first.is_none_or(|first| candidate < first) {
first = Some(candidate);
}
if cycling && candidate > name && next.is_none_or(|next| candidate < next) {
next = Some(candidate);
}
}
if let Some(candidate) = next.or(first) {
view.command_line.replace_range(start.., candidate);
}
}
fn navigate_prompt_history(&mut self, previous: bool) {
let history = if self.active.view.mode == Mode::Search {
&self.search_history
@@ -8514,6 +8563,7 @@ impl Editor {
let count = self.count.take().unwrap_or(1);
self.clear_sequence();
match action {
Action::CommandComplete => self.complete_command(),
Action::SnippetNext => {
if self.snippet.is_some() {
self.navigate_snippet(true);
+1
View File
@@ -57,6 +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)
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]
+115
View File
@@ -1067,6 +1067,121 @@ fn focused_text_fields_keep_printable_normal_bindings_as_text() {
}
}
#[test]
fn command_completion_cycles_core_alias_and_live_plugin_names_without_executing() {
let mut editor = editor();
editor.register(plugin("fixture", &["save_note"])).unwrap();
let document = editor.context().text;
type_text(&mut editor, ":sav");
for expected in ["save", "save_and_quit", "save_note", "save"] {
assert_eq!(editor.handle(Input::Char('\t')), None);
assert_eq!(editor.command_line(), expected);
assert_eq!(editor.mode(), Mode::Command);
assert_eq!(editor.context().text, document);
assert!(!editor.should_quit());
}
editor.handle(Input::Char('\t'));
editor.handle(Input::Char('\t'));
let invocation = editor.handle(Input::Enter).unwrap();
assert_eq!(invocation.plugin, "fixture");
assert_eq!(invocation.command, "save_note");
assert_eq!(invocation.args, "");
type_text(&mut editor, ":q");
for expected in ["q", "q!", "quit", "q"] {
editor.handle(Input::Char('\t'));
assert_eq!(editor.command_line(), expected);
assert!(!editor.should_quit());
}
editor.handle(Input::Escape);
type_text(&mut editor, ":sav");
editor.handle(Input::Char('\t'));
editor.disable_plugin("fixture", "test");
for expected in ["save_and_quit", "save"] {
editor.handle(Input::Char('\t'));
assert_eq!(editor.command_line(), expected);
}
}
#[test]
fn command_completion_restarts_after_edits_history_and_cancellation_and_ignores_arguments() {
let mut editor = editor();
type_text(&mut editor, ":plugins\n:sav");
editor.handle(Input::Char('\t'));
type_text(&mut editor, "_");
editor.handle(Input::Char('\t'));
assert_eq!(editor.command_line(), "save_and_quit");
editor.handle(Input::Ctrl('u'));
type_text(&mut editor, "q!");
editor.handle(Input::Backspace);
editor.handle(Input::Char('\t'));
assert_eq!(editor.command_line(), "q");
editor.handle(Input::Char('\t'));
assert_eq!(editor.command_line(), "q!");
editor.handle(Input::Up);
editor.handle(Input::Char('\t'));
assert_eq!(editor.command_line(), "plugins");
editor.handle(Input::Down);
editor.handle(Input::Char('\t'));
assert_eq!(editor.command_line(), "q!");
editor.handle(Input::Escape);
for (draft, expected) in [
("", "bdelete"),
(" :sav", " :save"),
("no_such_command", "no_such_command"),
("界e\u{301}", "界e\u{301}"),
("edit ", "edit "),
("save 界 file.txt", "save 界 file.txt"),
] {
type_text(&mut editor, ":");
type_text(&mut editor, draft);
editor.handle(Input::Char('\t'));
assert_eq!(editor.command_line(), expected);
editor.handle(Input::Escape);
}
}
#[test]
fn command_completion_respects_bindings_and_preserves_visual_diff_and_search_input() {
let directory = Directory::new();
let mut editor = editor();
editor
.bind("command", "ctrl+n", Some("core.command_complete".into()))
.unwrap();
editor.bind("command", "tab", None).unwrap();
historical_diff(
&mut editor,
&directory.0,
vec!["needle before".into()],
vec!["needle after".into()],
);
editor.handle(Input::Char('V'));
let selections = editor.selections();
let cursor = editor.cursor();
type_text(&mut editor, ":diff.com");
editor.handle(Input::Char('\t'));
assert_eq!(editor.command_line(), "diff.com");
editor.handle(Input::Ctrl('n'));
assert_eq!(editor.command_line(), "diff.comment");
assert_eq!(editor.selections(), selections);
assert_eq!(editor.cursor(), cursor);
editor.handle(Input::Escape);
assert_eq!(editor.mode(), Mode::Visual);
assert_eq!(editor.selections(), selections);
editor.handle(Input::Escape);
type_text(&mut editor, "/sav");
editor.handle(Input::Char('\t'));
assert_eq!(editor.command_line(), "sav\t");
editor.handle(Input::Escape);
editor
.bind("command", "tab", Some("core.up".into()))
.unwrap();
type_text(&mut editor, ":plugins\n:sav");
editor.handle(Input::Char('\t'));
assert_eq!(editor.command_line(), "plugins");
}
#[test]
fn prompt_history_browses_without_executing_and_restores_unicode_drafts() {
for prefix in [':', '/'] {