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:
+122
-37
@@ -114,26 +114,58 @@ pub fn classify_input(input: &str) -> InputState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Ambient hint-panel content for the user's current input
|
/// Ambient hint-panel content for the user's current input
|
||||||
/// (ADR-0022 §6). Returns `None` for empty input — the caller
|
/// (ADR-0022 §6, stage 8b). The renderer dispatches on the
|
||||||
/// then falls back to the existing `panel.hint_empty` content.
|
/// 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:
|
/// Returns `None` for empty input — caller falls back to
|
||||||
/// - **Valid** — `t!("hint.ambient_complete")` (e.g. "submit
|
/// `panel.hint_empty`.
|
||||||
/// 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.
|
|
||||||
#[must_use]
|
#[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() {
|
if input.trim().is_empty() {
|
||||||
return None;
|
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) {
|
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::Empty) => None,
|
||||||
Err(ParseError::Invalid {
|
Err(ParseError::Invalid {
|
||||||
message,
|
message,
|
||||||
@@ -143,24 +175,27 @@ pub fn ambient_hint(input: &str) -> Option<String> {
|
|||||||
}) => {
|
}) => {
|
||||||
if at_eof {
|
if at_eof {
|
||||||
if expected.is_empty() {
|
if expected.is_empty() {
|
||||||
Some(message)
|
Some(AmbientHint::Prose(message))
|
||||||
} else {
|
} else {
|
||||||
let joined = oxford_or(&expected);
|
let joined = oxford_or(&expected);
|
||||||
Some(crate::t!("hint.ambient_expected", expected = joined))
|
Some(AmbientHint::Prose(crate::t!(
|
||||||
|
"hint.ambient_expected",
|
||||||
|
expected = joined
|
||||||
|
)))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let tokens = lex(input);
|
let tokens = lex(input);
|
||||||
let usage = crate::dsl::usage::matched_entry(&tokens, position)
|
let usage = crate::dsl::usage::matched_entry(&tokens, position)
|
||||||
.and_then(|(_, keys)| keys.first().copied())
|
.and_then(|(_, keys)| keys.first().copied())
|
||||||
.map(|key| crate::friendly::translate(key, &[]));
|
.map(|key| crate::friendly::translate(key, &[]));
|
||||||
match usage {
|
Some(AmbientHint::Prose(match usage {
|
||||||
Some(u) => Some(crate::t!(
|
Some(u) => crate::t!(
|
||||||
"hint.ambient_error_with_usage",
|
"hint.ambient_error_with_usage",
|
||||||
message = message,
|
message = message,
|
||||||
usage = u,
|
usage = u,
|
||||||
)),
|
),
|
||||||
None => Some(message),
|
None => message,
|
||||||
}
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -390,34 +425,59 @@ mod tests {
|
|||||||
assert!(reversed(last));
|
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]
|
#[test]
|
||||||
fn ambient_hint_is_none_for_empty_input() {
|
fn ambient_hint_is_none_for_empty_input() {
|
||||||
assert_eq!(ambient_hint(""), None);
|
assert!(ambient_hint("", 0, None).is_none());
|
||||||
assert_eq!(ambient_hint(" "), None);
|
assert!(ambient_hint(" ", 3, None).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ambient_hint_for_valid_input_invites_submit() {
|
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:?}");
|
assert!(h.contains("Enter"), "got {h:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ambient_hint_for_partial_keyword_lists_expected_set() {
|
fn ambient_hint_at_partial_keyword_position_returns_candidates() {
|
||||||
let h = ambient_hint("show").expect("some hint");
|
// `show` mid-keyword: candidates_at_cursor returns
|
||||||
assert!(h.starts_with("expected:"), "got {h:?}");
|
// {data, table} filtered by prefix "show" — but
|
||||||
assert!(h.contains("`data`"), "got {h:?}");
|
// "show" doesn't match any keyword's prefix. The
|
||||||
assert!(h.contains("`table`"), "got {h:?}");
|
// 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]
|
#[test]
|
||||||
fn ambient_hint_for_definite_error_includes_usage_template() {
|
fn ambient_hint_for_definite_error_includes_usage_template() {
|
||||||
let h = ambient_hint("insert into T extra").expect("some hint");
|
let h = prose("insert into T extra", 19).expect("prose hint");
|
||||||
// Definite error after `insert into T` (parser expected
|
|
||||||
// values clause / column list / values keyword).
|
|
||||||
// Composes message with insert usage template.
|
|
||||||
assert!(
|
assert!(
|
||||||
h.contains("usage:"),
|
h.contains("usage:"),
|
||||||
"definite-error hint should include usage template, got {h:?}",
|
"definite-error hint should include usage template, got {h:?}",
|
||||||
@@ -430,9 +490,11 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ambient_hint_for_unknown_command_falls_back_to_message() {
|
fn ambient_hint_for_unknown_command_falls_back_to_message() {
|
||||||
// No consumed entry keyword → no usage template; the
|
// `frobulate widgets` cursor at start: candidates are
|
||||||
// message itself is shown.
|
// computed first; "frobulate" doesn't match any
|
||||||
let h = ambient_hint("frobulate widgets").expect("some hint");
|
// keyword, so candidates = empty → falls back to
|
||||||
|
// prose error message.
|
||||||
|
let h = prose("frobulate widgets", 17).expect("prose hint");
|
||||||
assert!(
|
assert!(
|
||||||
!h.contains("usage:"),
|
!h.contains("usage:"),
|
||||||
"no entry keyword consumed → no usage template; got {h:?}",
|
"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) ----
|
// ---- classify_input + error overlay (stage 4) ----
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -692,25 +692,134 @@ fn render_hint_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect
|
|||||||
// the DSL lexer/parser don't speak SQL.
|
// the DSL lexer/parser don't speak SQL.
|
||||||
let empty_hint = crate::t!("panel.hint_empty");
|
let empty_hint = crate::t!("panel.hint_empty");
|
||||||
let ambient = match app.effective_mode() {
|
let ambient = match app.effective_mode() {
|
||||||
EffectiveMode::Simple => crate::input_render::ambient_hint(&app.input),
|
EffectiveMode::Simple => crate::input_render::ambient_hint(
|
||||||
|
&app.input,
|
||||||
|
app.input_cursor,
|
||||||
|
app.last_completion.as_ref(),
|
||||||
|
),
|
||||||
EffectiveMode::AdvancedPersistent | EffectiveMode::AdvancedOneShot => None,
|
EffectiveMode::AdvancedPersistent | EffectiveMode::AdvancedOneShot => None,
|
||||||
};
|
};
|
||||||
let body: String = app
|
let muted = Style::default().fg(theme.muted);
|
||||||
.hint
|
let line = match (app.hint.as_deref(), ambient) {
|
||||||
.as_deref()
|
(Some(set), _) => Line::from(Span::styled(set.to_string(), muted)),
|
||||||
.map(ToString::to_string)
|
(None, Some(crate::input_render::AmbientHint::Prose(text))) => {
|
||||||
.or(ambient)
|
Line::from(Span::styled(text, muted))
|
||||||
.unwrap_or(empty_hint);
|
}
|
||||||
let paragraph = Paragraph::new(Line::from(Span::styled(
|
(None, Some(crate::input_render::AmbientHint::Candidates { items, selected })) => {
|
||||||
body,
|
// ADR-0022 §7 + user's #2: render items with the
|
||||||
Style::default().fg(theme.muted),
|
// selected one highlighted; if not all fit,
|
||||||
)))
|
// scroll horizontally with `<` / `>` markers at
|
||||||
|
// the edges. Inner width = panel area minus
|
||||||
|
// borders (2).
|
||||||
|
let inner = area.width.saturating_sub(2) as usize;
|
||||||
|
render_candidate_line(&items, selected, inner, theme)
|
||||||
|
}
|
||||||
|
(None, None) => Line::from(Span::styled(empty_hint, muted)),
|
||||||
|
};
|
||||||
|
let paragraph = Paragraph::new(line)
|
||||||
.block(block)
|
.block(block)
|
||||||
.wrap(Wrap { trim: false });
|
.wrap(Wrap { trim: false });
|
||||||
|
|
||||||
frame.render_widget(paragraph, area);
|
frame.render_widget(paragraph, area);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Render the candidate-list line for the hint panel
|
||||||
|
/// (ADR-0022 §7 + the user's #2). Items are space-separated;
|
||||||
|
/// the selected item (if any) gets a brighter colour + bold;
|
||||||
|
/// when the items overflow `width`, a window centred on the
|
||||||
|
/// selected (or starting at item 0 with no selection) shows
|
||||||
|
/// scroll markers `<` / `>` at the edges.
|
||||||
|
///
|
||||||
|
/// Returns `Line<'static>` (each item cloned into its span)
|
||||||
|
/// so the caller doesn't have to manage the items' lifetime.
|
||||||
|
fn render_candidate_line(
|
||||||
|
items: &[String],
|
||||||
|
selected: Option<usize>,
|
||||||
|
width: usize,
|
||||||
|
theme: &Theme,
|
||||||
|
) -> Line<'static> {
|
||||||
|
if items.is_empty() {
|
||||||
|
return Line::default();
|
||||||
|
}
|
||||||
|
let item_style = Style::default().fg(theme.muted);
|
||||||
|
let selected_style = Style::default()
|
||||||
|
.fg(theme.fg)
|
||||||
|
.add_modifier(Modifier::BOLD);
|
||||||
|
let marker_style = Style::default().fg(theme.fg);
|
||||||
|
|
||||||
|
let total_width: usize = items
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.len() + 1)
|
||||||
|
.sum::<usize>()
|
||||||
|
.saturating_sub(1);
|
||||||
|
if total_width <= width {
|
||||||
|
let mut spans: Vec<Span<'static>> = Vec::with_capacity(items.len() * 2);
|
||||||
|
for (i, item) in items.iter().enumerate() {
|
||||||
|
if i > 0 {
|
||||||
|
spans.push(Span::styled(" ".to_string(), item_style));
|
||||||
|
}
|
||||||
|
let style = if Some(i) == selected {
|
||||||
|
selected_style
|
||||||
|
} else {
|
||||||
|
item_style
|
||||||
|
};
|
||||||
|
spans.push(Span::styled(item.clone(), style));
|
||||||
|
}
|
||||||
|
return Line::from(spans);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overflow path: window centred on `selected` (or item 0).
|
||||||
|
// Reserve 4 chars for the `< ` / ` >` markers we may end
|
||||||
|
// up using.
|
||||||
|
let center = selected.unwrap_or(0);
|
||||||
|
let mut left = center;
|
||||||
|
let mut right = center;
|
||||||
|
let mut used = items[center].len();
|
||||||
|
let avail = width.saturating_sub(4);
|
||||||
|
while (left > 0 || right + 1 < items.len()) && used < avail {
|
||||||
|
// Expand right first, then left.
|
||||||
|
if right + 1 < items.len() {
|
||||||
|
let cost = items[right + 1].len() + 1;
|
||||||
|
if used + cost <= avail {
|
||||||
|
right += 1;
|
||||||
|
used += cost;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if left > 0 {
|
||||||
|
let cost = items[left - 1].len() + 1;
|
||||||
|
if used + cost <= avail {
|
||||||
|
left -= 1;
|
||||||
|
used += cost;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let need_left_marker = left > 0;
|
||||||
|
let need_right_marker = right + 1 < items.len();
|
||||||
|
|
||||||
|
let mut spans: Vec<Span<'static>> = Vec::with_capacity((right - left + 1) * 2 + 4);
|
||||||
|
if need_left_marker {
|
||||||
|
spans.push(Span::styled("< ".to_string(), marker_style));
|
||||||
|
}
|
||||||
|
for (offset, item) in items[left..=right].iter().enumerate() {
|
||||||
|
if offset > 0 {
|
||||||
|
spans.push(Span::styled(" ".to_string(), item_style));
|
||||||
|
}
|
||||||
|
let style = if Some(left + offset) == selected {
|
||||||
|
selected_style
|
||||||
|
} else {
|
||||||
|
item_style
|
||||||
|
};
|
||||||
|
spans.push(Span::styled(item.clone(), style));
|
||||||
|
}
|
||||||
|
if need_right_marker {
|
||||||
|
spans.push(Span::styled(" >".to_string(), marker_style));
|
||||||
|
}
|
||||||
|
Line::from(spans)
|
||||||
|
}
|
||||||
|
|
||||||
fn render_status_bar(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
|
fn render_status_bar(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
|
||||||
let key_style = Style::default()
|
let key_style = Style::default()
|
||||||
.fg(theme.fg)
|
.fg(theme.fg)
|
||||||
|
|||||||
Reference in New Issue
Block a user