ADR-0022 stage 8b: hint panel candidate list with scroll markers

Refactor `ambient_hint` to return a richer enum:
  - `Prose(String)` — the existing single-line hint (Valid /
    incomplete-with-no-keywords / definite-error states);
  - `Candidates { items, selected }` — multi-candidate (or
    single-candidate) keyword completion at the cursor.

When `candidates_at_cursor` returns Some, the new
`Candidates` variant wins over the prose framing — the
candidate list is more actionable than "expected: `data` or
`table`". `selected` tracks the live `LastCompletion` memo's
selection_idx for the renderer to highlight.

`render_candidate_line` (new helper in ui.rs):
  - All items fit → render space-separated; selected item
    rendered bold + theme.fg, others theme.muted.
  - Overflow → window centred on the selected item (or
    item 0 with no selection); `< ` / ` >` markers at the
    edges (per the user's #2). Window expands right-first
    then left-first to use available width.
  - Returns `Line<'static>` (items cloned into spans) so the
    caller doesn't fight lifetimes between the
    AmbientHint::Candidates payload and the rendered Line.

Updated callers in ui.rs and input_render tests for the new
signature. Added `ambient_hint_with_memo_carries_selected_index`
test asserting the renderer-side `selected` plumbing.

Tests: 730 passing, 0 failing, 1 ignored (728 baseline →
+2 net: -3 reworked + 5 new candidate-related cases).
Clippy clean.

Stage 8c will plumb identifier completion (schema cache +
candidate fetch from worker on demand or pre-cache) and add
the invalid-identifier hint variant.
This commit is contained in:
claude@clouddev1
2026-05-11 20:48:21 +00:00
parent 06e8d1e769
commit faebeed588
2 changed files with 244 additions and 50 deletions
+122 -37
View File
@@ -114,26 +114,58 @@ pub fn classify_input(input: &str) -> InputState {
}
/// Ambient hint-panel content for the user's current input
/// (ADR-0022 §6). Returns `None` for empty input — the caller
/// then falls back to the existing `panel.hint_empty` content.
/// (ADR-0022 §6, stage 8b). The renderer dispatches on the
/// returned variant.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AmbientHint {
/// Single-line prose hint — used for "submit with Enter",
/// IncompleteAtEof with no keyword candidates (i.e. an
/// 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.
Candidates {
items: Vec<String>,
/// Index into `items` of the currently-inserted Tab
/// candidate (per the live `LastCompletion` memo), or
/// `None` if the user hasn't pressed Tab yet.
selected: Option<usize>,
},
}
/// Compute the ambient hint for the input panel
/// (ADR-0022 §6).
///
/// One of three sub-states:
/// - **Valid** — `t!("hint.ambient_complete")` (e.g. "submit
/// with Enter").
/// - **IncompleteAtEof** — `t!("hint.ambient_expected", …)`
/// listing what the parser was looking for next.
/// - **DefiniteErrorAt** — `t!("hint.ambient_error_with_usage", …)`
/// composing the parse-error message with the matching
/// `parse.usage.*` template if a known command-entry
/// keyword was consumed; falls back to the bare message
/// otherwise.
/// Returns `None` for empty input — caller falls back to
/// `panel.hint_empty`.
#[must_use]
pub fn ambient_hint(input: &str) -> Option<String> {
pub fn ambient_hint(
input: &str,
cursor: usize,
memo: Option<&crate::completion::LastCompletion>,
) -> Option<AmbientHint> {
if input.trim().is_empty() {
return None;
}
// First check for keyword candidates at the cursor.
// When candidates 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) {
let selected = memo.map(|m| m.selection_idx);
return Some(AmbientHint::Candidates {
items: comp.candidates,
selected,
});
}
// Otherwise fall back to the prose framings from stage 5.
match parse_command(input) {
Ok(_) => Some(crate::t!("hint.ambient_complete")),
Ok(_) => Some(AmbientHint::Prose(crate::t!("hint.ambient_complete"))),
Err(ParseError::Empty) => None,
Err(ParseError::Invalid {
message,
@@ -143,24 +175,27 @@ pub fn ambient_hint(input: &str) -> Option<String> {
}) => {
if at_eof {
if expected.is_empty() {
Some(message)
Some(AmbientHint::Prose(message))
} else {
let joined = oxford_or(&expected);
Some(crate::t!("hint.ambient_expected", expected = joined))
Some(AmbientHint::Prose(crate::t!(
"hint.ambient_expected",
expected = joined
)))
}
} else {
let tokens = lex(input);
let usage = crate::dsl::usage::matched_entry(&tokens, position)
.and_then(|(_, keys)| keys.first().copied())
.map(|key| crate::friendly::translate(key, &[]));
match usage {
Some(u) => Some(crate::t!(
Some(AmbientHint::Prose(match usage {
Some(u) => crate::t!(
"hint.ambient_error_with_usage",
message = message,
usage = u,
)),
None => Some(message),
}
),
None => message,
}))
}
}
}
@@ -390,34 +425,59 @@ mod tests {
assert!(reversed(last));
}
// ---- ambient_hint (stage 5) ----
// ---- ambient_hint (stage 5 + stage 8b) ----
fn prose(input: &str, cursor: usize) -> Option<String> {
match ambient_hint(input, cursor, None) {
Some(AmbientHint::Prose(s)) => Some(s),
_ => None,
}
}
fn cands_hint(input: &str, cursor: usize) -> Option<Vec<String>> {
match ambient_hint(input, cursor, None) {
Some(AmbientHint::Candidates { items, .. }) => Some(items),
_ => None,
}
}
#[test]
fn ambient_hint_is_none_for_empty_input() {
assert_eq!(ambient_hint(""), None);
assert_eq!(ambient_hint(" "), None);
assert!(ambient_hint("", 0, None).is_none());
assert!(ambient_hint(" ", 3, None).is_none());
}
#[test]
fn ambient_hint_for_valid_input_invites_submit() {
let h = ambient_hint("create table T with pk").expect("some hint");
let h = prose("create table T with pk", 22).expect("prose hint");
assert!(h.contains("Enter"), "got {h:?}");
}
#[test]
fn ambient_hint_for_partial_keyword_lists_expected_set() {
let h = ambient_hint("show").expect("some hint");
assert!(h.starts_with("expected:"), "got {h:?}");
assert!(h.contains("`data`"), "got {h:?}");
assert!(h.contains("`table`"), "got {h:?}");
fn ambient_hint_at_partial_keyword_position_returns_candidates() {
// `show` mid-keyword: candidates_at_cursor returns
// {data, table} filtered by prefix "show" — but
// "show" doesn't match any keyword's prefix. The
// partial prefix walk finds `show`; expected set at
// start-of-input is the entry keywords; none start
// with "show" except `show` itself. Hmm — let me
// check the actual semantics: at "show" cursor 4,
// start = 0, partial = "show", expected = entry
// keywords. Filter by "show" → just `show`. Single
// candidate.
let cs = cands_hint("show", 4).expect("candidate hint");
assert_eq!(cs, vec!["show".to_string()]);
}
#[test]
fn ambient_hint_at_word_boundary_after_show_returns_data_table() {
let cs = cands_hint("show ", 5).expect("candidate hint");
assert_eq!(cs, vec!["data".to_string(), "table".to_string()]);
}
#[test]
fn ambient_hint_for_definite_error_includes_usage_template() {
let h = ambient_hint("insert into T extra").expect("some hint");
// Definite error after `insert into T` (parser expected
// values clause / column list / values keyword).
// Composes message with insert usage template.
let h = prose("insert into T extra", 19).expect("prose hint");
assert!(
h.contains("usage:"),
"definite-error hint should include usage template, got {h:?}",
@@ -430,9 +490,11 @@ mod tests {
#[test]
fn ambient_hint_for_unknown_command_falls_back_to_message() {
// No consumed entry keyword → no usage template; the
// message itself is shown.
let h = ambient_hint("frobulate widgets").expect("some hint");
// `frobulate widgets` cursor at start: candidates are
// computed first; "frobulate" doesn't match any
// keyword, so candidates = empty → falls back to
// prose error message.
let h = prose("frobulate widgets", 17).expect("prose hint");
assert!(
!h.contains("usage:"),
"no entry keyword consumed → no usage template; got {h:?}",
@@ -443,6 +505,29 @@ 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.
let memo = LastCompletion {
inserted_range: (5, 5),
original_text: String::new(),
candidates: vec!["data".to_string(), "table".to_string()],
selection_idx: 1,
};
match ambient_hint("show ", 5, Some(&memo)) {
Some(AmbientHint::Candidates { items, selected }) => {
assert_eq!(items, vec!["data".to_string(), "table".to_string()]);
assert_eq!(selected, Some(1));
}
other => panic!("expected Candidates, got {other:?}"),
}
}
// ---- classify_input + error overlay (stage 4) ----
#[test]