ADR-0022 stage 8a: non-modal keyword completion + Esc/Backspace undo

Per the user's framing decision: there is no "completion
mode." Tab is just an action that consumes whatever is
expected at the cursor, and the existing always-on hint
panel (stage 5) tells the user what's available.

New `completion` module: `candidates_at_cursor(input,
cursor)` returns a `Completion { replaced_range,
partial_prefix, candidates }` based on the parser's
expected-token set at the cursor position. Filters to bare
keyword candidates only (no punctuation, no descriptive
labels), narrowed by the typed prefix (case-insensitive).

`LastCompletion` memo struct on `App::last_completion`
carries the cycle state: inserted_range, original_text,
candidates, selection_idx. Wrap-around forward/backward
indices.

App key handling (added before the existing matcher):
  - Tab → cycle forward if memo present; else insert first
    candidate; create / advance memo.
  - Shift-Tab → cycle backward if memo present; else
    insert last candidate (alphabetically) so the user can
    jump to the end without cycling through everything.
  - Esc / Backspace while memo alive → restore
    original_text in inserted_range, place cursor at the
    pre-Tab position, clear memo.
  - Any other key → clear memo, then process normally.

The user's symmetry preference was load-bearing here:
"insert with one keystroke, remove with one keystroke."
Both Esc and Backspace honour that — multiple Tab cycles
collapse into one undo. Documented inline.

A single-candidate completion still creates a memo so
Esc/Backspace can undo it. Multiple Tabs in a row cycle
through the candidate list with wrap-around at both ends
(per the user's #2).

Tests: 728 passing, 0 failing, 1 ignored (705 baseline →
+23: 13 completion module + 10 app integration tests
covering Tab, Shift-Tab, cycling, wrap-around, Esc-undo,
Backspace-undo, multi-Tab-then-Esc, memo invalidation by
typing or cursor movement). Clippy clean.

Stage 8b will add multi-candidate hint-panel rendering
with scroll markers (`<` `>`) per the user's #2. Stage 8c
will plumb in identifier completion + invalid-identifier
detection.
This commit is contained in:
claude@clouddev1
2026-05-11 20:43:06 +00:00
parent aea3224da2
commit 06e8d1e769
3 changed files with 557 additions and 0 deletions
+275
View File
@@ -127,6 +127,14 @@ pub struct App {
/// dispatches keys to the modal instead of the input
/// field.
pub modal: Option<Modal>,
/// Memo of the most recent Tab-completion (ADR-0022
/// stage 8). Carries enough state to cycle to the next /
/// previous candidate on subsequent Tab / Shift-Tab
/// presses, and to undo the whole insertion in one
/// keystroke via Esc / Backspace. Cleared by *any* other
/// keystroke — no completion mode, just a transient
/// memory of "the last thing Tab did."
pub last_completion: Option<crate::completion::LastCompletion>,
}
/// Dialogs that take over keyboard input when active.
@@ -236,6 +244,7 @@ impl App {
project_is_temp: false,
fatal_message: None,
modal: None,
last_completion: None,
}
}
@@ -483,6 +492,27 @@ impl App {
if self.modal.is_some() {
return self.handle_modal_key(key);
}
// ADR-0022 stage 8 — non-modal completion. Tab /
// Shift-Tab cycle; Esc / Backspace undo the whole
// last-Tab insertion in one keystroke while the memo
// is alive (per the user's symmetry preference: one
// keystroke to insert, one to remove). Any other key
// clears the memo before being processed normally.
match (key.code, key.modifiers) {
(KeyCode::Tab, _) => return self.completion_tab_forward(),
(KeyCode::BackTab, _) => return self.completion_tab_backward(),
(KeyCode::Esc, _) if self.last_completion.is_some() => {
self.undo_last_completion();
return Vec::new();
}
(KeyCode::Backspace, _) if self.last_completion.is_some() => {
self.undo_last_completion();
return Vec::new();
}
_ => self.last_completion = None,
}
match (key.code, key.modifiers) {
(KeyCode::Char('c'), KeyModifiers::CONTROL) => vec![Action::Quit],
(KeyCode::Enter, _) => self.submit(),
@@ -592,6 +622,118 @@ impl App {
self.input_cursor = idx;
}
/// Tab key handler — insert / cycle forward through the
/// candidates at the cursor (ADR-0022 stage 8). Three
/// shapes:
/// - memo present: cycle to next candidate, replace
/// inserted_range, advance memo.selection_idx;
/// - memo absent + multi-candidate: insert first
/// candidate (with trailing space) and create memo;
/// - memo absent + single candidate: insert it and
/// create a one-element memo so Esc/Backspace can
/// still undo;
/// - no candidates: no-op.
fn completion_tab_forward(&mut self) -> Vec<Action> {
self.cancel_history_navigation();
if let Some(memo) = self.last_completion.take() {
let next = memo.next_idx();
self.last_completion = Some(self.replace_inserted(memo, next));
return Vec::new();
}
if let Some(memo) = self.start_completion(0) {
self.last_completion = Some(memo);
}
Vec::new()
}
/// Shift-Tab key handler — symmetric to
/// `completion_tab_forward` but cycles backward, and
/// starts from the last candidate (alphabetically) if no
/// memo exists. Per the user's #2: wrap-around at both
/// ends.
fn completion_tab_backward(&mut self) -> Vec<Action> {
self.cancel_history_navigation();
if let Some(memo) = self.last_completion.take() {
let prev = memo.prev_idx();
self.last_completion = Some(self.replace_inserted(memo, prev));
return Vec::new();
}
if let Some(memo) = self.start_completion_last() {
self.last_completion = Some(memo);
}
Vec::new()
}
/// Esc / Backspace handler while a completion memo is
/// alive — restore the original text in
/// `inserted_range` and place the cursor where the user
/// was when they hit Tab. The memo is cleared.
fn undo_last_completion(&mut self) {
let Some(memo) = self.last_completion.take() else {
return;
};
let (start, end) = memo.inserted_range;
self.input.replace_range(start..end, &memo.original_text);
self.input_cursor = start + memo.original_text.len();
}
/// Compute the candidates at the cursor and insert the
/// candidate at index `start_idx` (with a trailing space).
/// Returns the freshly-built memo. `None` if there are no
/// candidates to insert.
fn start_completion(&mut self, start_idx: usize) -> Option<crate::completion::LastCompletion> {
let cursor = self.input_cursor.min(self.input.len());
let comp = crate::completion::candidates_at_cursor(&self.input, cursor)?;
let idx = start_idx % comp.candidates.len();
let inserted = format!("{} ", comp.candidates[idx]);
let original_text =
self.input[comp.replaced_range.0..comp.replaced_range.1].to_string();
self.input
.replace_range(comp.replaced_range.0..comp.replaced_range.1, &inserted);
let new_end = comp.replaced_range.0 + inserted.len();
self.input_cursor = new_end;
Some(crate::completion::LastCompletion {
inserted_range: (comp.replaced_range.0, new_end),
original_text,
candidates: comp.candidates,
selection_idx: idx,
})
}
/// Same as `start_completion` but starts from the last
/// candidate alphabetically (for Shift-Tab from a fresh
/// state).
fn start_completion_last(&mut self) -> Option<crate::completion::LastCompletion> {
// We need to know how many candidates there will be
// before we can pick "last". Compute the completion
// first, then call start_completion with that index.
let cursor = self.input_cursor.min(self.input.len());
let comp = crate::completion::candidates_at_cursor(&self.input, cursor)?;
let last = comp.candidates.len() - 1;
// Fall through to the same path so the inserted text
// and memo construction stay in one place.
self.start_completion(last)
}
/// Replace the inserted text with `candidates[idx]` and
/// return an updated memo. Used by Tab/Shift-Tab cycling.
fn replace_inserted(
&mut self,
memo: crate::completion::LastCompletion,
idx: usize,
) -> crate::completion::LastCompletion {
let new_inserted = format!("{} ", memo.candidates[idx]);
let (start, end) = memo.inserted_range;
self.input.replace_range(start..end, &new_inserted);
let new_end = start + new_inserted.len();
self.input_cursor = new_end;
crate::completion::LastCompletion {
inserted_range: (start, new_end),
selection_idx: idx,
..memo
}
}
fn delete_at_cursor(&mut self) {
if self.input_cursor >= self.input.len() {
return;
@@ -1607,6 +1749,139 @@ mod tests {
.join("\n")
}
// ---- ADR-0022 stage 8: Tab completion + Esc/Backspace undo ----
#[test]
fn tab_inserts_unique_keyword_with_trailing_space() {
let mut app = App::new();
type_str(&mut app, "cre");
let actions = app.update(key(KeyCode::Tab));
assert!(actions.is_empty());
assert_eq!(app.input, "create ");
assert_eq!(app.input_cursor, 7);
// Memo is alive even for a single-candidate completion
// so Esc/Backspace can still undo.
assert!(app.last_completion.is_some());
}
#[test]
fn tab_at_word_boundary_inserts_next_expected_keyword() {
let mut app = App::new();
type_str(&mut app, "create ");
let actions = app.update(key(KeyCode::Tab));
assert!(actions.is_empty());
assert_eq!(app.input, "create table ");
}
#[test]
fn tab_with_no_candidates_is_a_noop() {
// After `create table T with pk` the parser succeeds —
// no candidates, Tab does nothing.
let mut app = App::new();
type_str(&mut app, "create table T with pk");
let len = app.input.len();
app.update(key(KeyCode::Tab));
assert_eq!(app.input.len(), len);
assert!(app.last_completion.is_none());
}
#[test]
fn tab_cycles_forward_through_multi_candidate_set() {
// After `show ` two candidates: data, table.
let mut app = App::new();
type_str(&mut app, "show ");
app.update(key(KeyCode::Tab));
assert_eq!(app.input, "show data ");
app.update(key(KeyCode::Tab));
assert_eq!(app.input, "show table ");
// Wrap-around per the user's #2.
app.update(key(KeyCode::Tab));
assert_eq!(app.input, "show data ");
}
#[test]
fn shift_tab_cycles_backward_starting_from_last() {
// Fresh Shift-Tab on multi-candidate position starts
// at the last candidate alphabetically.
let mut app = App::new();
type_str(&mut app, "show ");
app.update(key(KeyCode::BackTab));
assert_eq!(app.input, "show table ");
app.update(key(KeyCode::BackTab));
assert_eq!(app.input, "show data ");
// Wrap.
app.update(key(KeyCode::BackTab));
assert_eq!(app.input, "show table ");
}
#[test]
fn esc_after_tab_restores_original_in_one_keystroke() {
let mut app = App::new();
type_str(&mut app, "cre");
app.update(key(KeyCode::Tab));
assert_eq!(app.input, "create ");
app.update(key(KeyCode::Esc));
assert_eq!(app.input, "cre");
assert_eq!(app.input_cursor, 3);
assert!(app.last_completion.is_none());
}
#[test]
fn backspace_after_tab_restores_original_in_one_keystroke() {
// Symmetry: one keystroke to insert, one to remove.
let mut app = App::new();
type_str(&mut app, "cre");
app.update(key(KeyCode::Tab));
assert_eq!(app.input, "create ");
app.update(key(KeyCode::Backspace));
assert_eq!(app.input, "cre");
assert_eq!(app.input_cursor, 3);
assert!(app.last_completion.is_none());
}
#[test]
fn esc_after_multiple_tabs_restores_original_state_not_previous_cycle() {
// Tab Tab Tab cycled through three candidates; Esc
// restores the pre-completion state (the original
// partial prefix), not the previous cycle.
let mut app = App::new();
type_str(&mut app, "drop ");
app.update(key(KeyCode::Tab)); // column
app.update(key(KeyCode::Tab)); // relationship
app.update(key(KeyCode::Tab)); // table
assert_eq!(app.input, "drop table ");
app.update(key(KeyCode::Esc));
assert_eq!(app.input, "drop ");
}
#[test]
fn typing_a_letter_clears_the_completion_memo() {
let mut app = App::new();
type_str(&mut app, "cre");
app.update(key(KeyCode::Tab));
assert!(app.last_completion.is_some());
// Typing any non-Tab key clears the memo. The
// inserted text stays in the buffer.
type_str(&mut app, "x");
assert_eq!(app.input, "create x");
assert!(app.last_completion.is_none());
// Backspace now does its normal job — delete one char.
app.update(key(KeyCode::Backspace));
assert_eq!(app.input, "create ");
}
#[test]
fn cursor_movement_clears_the_completion_memo() {
let mut app = App::new();
type_str(&mut app, "cre");
app.update(key(KeyCode::Tab));
// Per the user's #3 — cursor movement clears the
// memo. After this, Esc / Backspace behave normally
// and don't trigger the whole-span undo.
app.update(key(KeyCode::Home));
assert!(app.last_completion.is_none());
}
fn sample_description(name: &str) -> TableDescription {
TableDescription {
name: name.to_string(),