ADR-0022 stage 8 follow-up: fixes from real-app testing

Three fixes from the user's testing run, plus an
investigation note on a fourth.

#4 Sticky hint during cycling. The previous code recomputed
candidates_at_cursor at the post-Tab cursor position, which
made the panel whiplash through "what comes next at the new
cursor" between cycles. ambient_hint now short-circuits to
the memo's stored candidate list while the memo is alive —
so Tab Tab Tab keeps showing the same list with the
selection moving, then snaps to the post-Tab ambient state
once any non-Tab key clears the memo.

#2 Candidate ordering and kind-coloured rendering. New
`Candidate { text, kind: Keyword|Identifier }` carries the
classification through completion, last-completion memo,
and ambient-hint payload. candidates_at_cursor now sorts
keywords first (alphabetical), identifiers second
(alphabetical), and the hint-panel renderer colours keywords
in `tok_keyword` and identifiers in `tok_identifier`.
Keyword-vs-identifier name collisions resolve in favour of
the keyword (rare; the user can still address their table
via different syntax).

#3 tok_identifier no longer matches theme.fg. Identifiers
in the input pane now render in a distinct cool grey-blue
(dark) / dark steel-blue (light), so they stand out from
prose-like default text without competing with keyword
purple. Same colour drives the identifier candidates in
the hint panel for visual consistency input ↔ hint.

Limitation worth knowing: "keywords first, alphabetical"
is not the same as grammatical order. For "add column "
the hint shows `table to` not `to table` — chumsky's
expected-set doesn't preserve combinator-source order, and
encoding it in the registry adds maintenance overhead the
fix doesn't cleanly justify. Marked for future revisit if
it bites.

#1 (Tab does nothing on "add column ") — not reproduced
through App::update. The internal logic works correctly:
"add column " + Tab inserts "Customers ", second Tab
cycles to "Orders ", third to "Thing ". The most likely
explanation is a stale binary or a terminal-level event
intercept (tmux focus, kitty-keyboard protocol differences,
etc.) — needs user verification with a fresh build.

Tests: 747 passing, 0 failing, 1 ignored (744 baseline →
+3: 2 new completion-ordering cases including the
keyword-wins-on-name-collision edge, plus 1 hint-mid-cycle
sticky test). Clippy clean.
This commit is contained in:
claude@clouddev1
2026-05-11 22:12:16 +00:00
parent 8214e4136a
commit bd1cce672d
5 changed files with 237 additions and 82 deletions
+72 -22
View File
@@ -133,13 +133,15 @@ pub enum AmbientHint {
/// identifier or punctuation slot), and definite-error
/// states with optional usage template.
Prose(String),
/// Multi-candidate (or single-candidate keyword)
/// completion at the cursor. Stage 8b renders these as
/// styled spans with the selected item highlighted (if
/// any) and `<` / `>` scroll markers when items overflow
/// the panel width.
/// Multi-candidate (or single-candidate) completion at
/// the cursor. Each item carries its kind so the
/// renderer can colour keywords differently from
/// schema-identifiers (post-stage-8 user feedback).
/// The selected item — if any — gets bold + brighter
/// colour; `<` / `>` markers appear at the edges when
/// items overflow the panel width.
Candidates {
items: Vec<String>,
items: Vec<crate::completion::Candidate>,
/// Index into `items` of the currently-inserted Tab
/// candidate (per the live `LastCompletion` memo), or
/// `None` if the user hasn't pressed Tab yet.
@@ -162,16 +164,28 @@ pub fn ambient_hint(
if input.trim().is_empty() {
return None;
}
// First check for candidates at the cursor (keywords +
// schema identifiers). When any exist the user can Tab to
// insert one, and the panel surfaces them directly — this
// wins over the prose IncompleteAtEof framing because the
// candidate list is more actionable.
// Mid-cycle through Tab candidates: the memo carries the
// candidate list captured when Tab was first pressed, plus
// the current selection_idx. While the memo is alive the
// hint shows that exact list — recomputing at the
// post-insert cursor would whiplash the panel through "what
// comes next at the new cursor" between cycles. Closes
// the user-reported #4 in stage-8 testing.
if let Some(m) = memo {
return Some(AmbientHint::Candidates {
items: m.candidates.clone(),
selected: Some(m.selection_idx),
});
}
// No memo: fall back to candidates_at_cursor. When any
// exist the user can Tab to insert one, and the panel
// surfaces them directly — this wins over the prose
// IncompleteAtEof framing because the candidate list is
// more actionable.
if let Some(comp) = crate::completion::candidates_at_cursor(input, cursor, cache) {
let selected = memo.map(|m| m.selection_idx);
return Some(AmbientHint::Candidates {
items: comp.candidates,
selected,
selected: None,
});
}
// Invalid identifier: cursor sits in a known-set slot but
@@ -471,7 +485,9 @@ mod tests {
fn cands_hint(input: &str, cursor: usize) -> Option<Vec<String>> {
match ambient_hint(input, cursor, None, &empty_cache()) {
Some(AmbientHint::Candidates { items, .. }) => Some(items),
Some(AmbientHint::Candidates { items, .. }) => {
Some(items.into_iter().map(|c| c.text).collect())
}
_ => None,
}
}
@@ -562,27 +578,61 @@ mod tests {
#[test]
fn ambient_hint_with_memo_carries_selected_index() {
use crate::completion::LastCompletion;
// Simulate the post-Tab state at "show " — but with
// the original word still pending (cursor placed
// after `show ` to expose the multi-candidate slot).
// The memo's selection_idx is what the renderer uses
// to highlight one of the items.
use crate::completion::{Candidate, CandidateKind, LastCompletion};
let memo = LastCompletion {
inserted_range: (5, 5),
original_text: String::new(),
candidates: vec!["data".to_string(), "table".to_string()],
candidates: vec![
Candidate { text: "data".to_string(), kind: CandidateKind::Keyword },
Candidate { text: "table".to_string(), kind: CandidateKind::Keyword },
],
selection_idx: 1,
};
match ambient_hint("show ", 5, Some(&memo), &empty_cache()) {
Some(AmbientHint::Candidates { items, selected }) => {
assert_eq!(items, vec!["data".to_string(), "table".to_string()]);
assert_eq!(items.len(), 2);
assert_eq!(items[0].text, "data");
assert_eq!(items[1].text, "table");
assert_eq!(selected, Some(1));
}
other => panic!("expected Candidates, got {other:?}"),
}
}
#[test]
fn ambient_hint_during_cycling_shows_memo_list_not_recomputed() {
// Stage-8 user-reported #4: cycling through candidates
// moves the cursor; the panel should NOT shift to "what
// comes next at the new cursor position" — it should
// keep showing the memo's candidate list with the
// updated selection. Without the memo short-circuit,
// ambient_hint would recompute candidates_at_cursor
// post-Tab and produce a different list.
use crate::completion::{Candidate, CandidateKind, LastCompletion};
let memo = LastCompletion {
inserted_range: (5, 11),
original_text: String::new(),
// Include candidates whose order would NOT match
// what candidates_at_cursor("show table ", 11) would
// produce — proves the memo's list is being used,
// not a recomputed one.
candidates: vec![
Candidate { text: "data".to_string(), kind: CandidateKind::Keyword },
Candidate { text: "table".to_string(), kind: CandidateKind::Keyword },
],
selection_idx: 1,
};
match ambient_hint("show table ", 11, Some(&memo), &empty_cache()) {
Some(AmbientHint::Candidates { items, selected }) => {
assert_eq!(items.len(), 2);
assert_eq!(items[0].text, "data");
assert_eq!(items[1].text, "table");
assert_eq!(selected, Some(1));
}
other => panic!("expected Candidates from memo, got {other:?}"),
}
}
// ---- classify_input + error overlay (stage 4) ----
#[test]