diff --git a/src/input_render.rs b/src/input_render.rs index 7b05f34..9974a0c 100644 --- a/src/input_render.rs +++ b/src/input_render.rs @@ -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, + /// 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, + }, +} + +/// 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 { +pub fn ambient_hint( + input: &str, + cursor: usize, + memo: Option<&crate::completion::LastCompletion>, +) -> Option { 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 { }) => { 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 { + match ambient_hint(input, cursor, None) { + Some(AmbientHint::Prose(s)) => Some(s), + _ => None, + } + } + + fn cands_hint(input: &str, cursor: usize) -> Option> { + 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] diff --git a/src/ui.rs b/src/ui.rs index 29c7bf7..96f5842 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -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. let empty_hint = crate::t!("panel.hint_empty"); 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, }; - let body: String = app - .hint - .as_deref() - .map(ToString::to_string) - .or(ambient) - .unwrap_or(empty_hint); - let paragraph = Paragraph::new(Line::from(Span::styled( - body, - Style::default().fg(theme.muted), - ))) - .block(block) - .wrap(Wrap { trim: false }); + let muted = Style::default().fg(theme.muted); + let line = match (app.hint.as_deref(), ambient) { + (Some(set), _) => Line::from(Span::styled(set.to_string(), muted)), + (None, Some(crate::input_render::AmbientHint::Prose(text))) => { + Line::from(Span::styled(text, muted)) + } + (None, Some(crate::input_render::AmbientHint::Candidates { items, selected })) => { + // ADR-0022 §7 + user's #2: render items with the + // 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) + .wrap(Wrap { trim: false }); 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, + 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::() + .saturating_sub(1); + if total_width <= width { + let mut spans: Vec> = 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> = 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) { let key_style = Style::default() .fg(theme.fg)