From f7ca288fe19df8cc11698e5ae21b15035b607a52 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Sat, 30 May 2026 09:02:12 +0000 Subject: [PATCH] fix: grow the hint panel for long prose hints A long prose hint (insert field-value hints, the parse.usage.* synopses) wrapped but was clipped by the fixed one-row Hint panel, hiding the most useful tail. The candidate list already scrolled horizontally, so only prose was affected. Pre-wrap the prose body and size the Hint panel to the wrapped line count: one row by default, growing to a 3-row cap and reclaiming the space when short, with an ellipsis backstop on the last row. Also shorten the 299-char create-table usage synopsis to a terse one-liner (the full grammar remains under `help`). ADR-0022 Amendment 5. --- docs/adr/0022-ambient-typing-assistance.md | 44 ++++ docs/adr/README.md | 2 +- src/friendly/strings/en-US.yaml | 4 +- ...und__ui__tests__default_advanced_dark.snap | 5 +- ...round__ui__tests__default_simple_dark.snap | 5 +- ...ound__ui__tests__default_simple_light.snap | 5 +- ...hlighted_input_all_token_classes_dark.snap | 5 +- ..._ui__tests__populated_with_table_dark.snap | 5 +- ...ui__tests__rebuild_confirm_modal_dark.snap | 10 +- src/ui.rs | 237 ++++++++++++++---- 10 files changed, 259 insertions(+), 63 deletions(-) diff --git a/docs/adr/0022-ambient-typing-assistance.md b/docs/adr/0022-ambient-typing-assistance.md index 9e066f0..ee433e2 100644 --- a/docs/adr/0022-ambient-typing-assistance.md +++ b/docs/adr/0022-ambient-typing-assistance.md @@ -642,6 +642,50 @@ example), `advanced_double_precision_classified_as_type`, extended theme mapping/contrast tests. Text snapshots are colour-blind (`render_to_string` strips style), so none churned. +## Amendment 5 — Hint panel grows for long prose hints (2026-05-29) + +§6 specified a single-row hint panel. A long **prose** hint +(field-value hints on `insert`, the `parse.usage.*` synopses) simply +didn't fit: the renderer wrapped it but the fixed one-row panel +clipped everything past the first line, hiding the most useful tail +(issue #12). The candidate-list path was already safe — it scrolls +horizontally with `<` / `>` markers (Amendment 2's deferred *two-line +candidate box* remains deferred; this amendment does **not** touch +candidate rendering). + +**Change:** + +1. **Dynamic height.** `resolve_hint_lines` pre-wraps the prose body + to the panel's inner width and `render_right_column` sizes the + Hint panel to the line count — one content row by default, growing + to `MAX_HINT_ROWS` (3), and reclaiming the space when the hint is + short or empty. The candidate list stays one row. +2. **Ellipsis backstop.** `clamp_wrapped` caps the wrap at + `MAX_HINT_ROWS`; if the hint needs more, the last visible row ends + with `…` so overflow is signalled, not silently dropped. (A hint + long enough to ellipsize on a narrow terminal fits in full on a + wide one.) +3. **Shorter `create table` synopsis.** `parse.usage.sql_create_table` + was a 299-char "instruction manual" — by far the longest hint + string (next was 115). It is now a terse one-liner + (`create table [if not exists] ( [constraints], + ...)`); the exhaustive column- and table-level grammar still lives + in `help.ddl.sql_create_table`, reachable via `help`. The + `insert.form_b_*` notes (the other long strings) render in the + scrollable Output panel, not the Hint panel, so they were left + alone. + +**Why 3 rows:** the panel is usually wide (most hints fit one or two +rows), and capping at three keeps the Output panel usable; the value +can be revisited if hints routinely need more. + +**Coverage:** `long_prose_hint_shows_tail_across_multiple_rows`, +`short_hint_keeps_panel_at_one_content_row` (reclaim), +`long_hint_grows_panel_but_caps_at_max_rows`, +`clamp_wrapped_truncates_with_ellipsis_past_max`, and six re-baselined +full-screen snapshots (the empty-state placeholder and the `insert` +usage hint now wrap to their full text instead of being clipped). + ## Out of scope Deliberately deferred to keep this ADR shippable as a single diff --git a/docs/adr/README.md b/docs/adr/README.md index 79a153a..608b6a5 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -27,7 +27,7 @@ This directory contains the project's ADRs, recorded per - [ADR-0019 — Friendly error layer (H1) and i18n message catalog](0019-friendly-error-layer-and-i18n.md) - [ADR-0020 — Tokenization layer for the DSL parser](0020-tokenization-layer-for-the-dsl-parser.md) - [ADR-0021 — Parser-as-source-of-truth for H1a (per-command usage in parse errors)](0021-parser-as-source-of-truth-for-h1a.md) -- [ADR-0022 — Ambient typing assistance: colour, hint panel, completion (I3 + I4)](0022-ambient-typing-assistance.md) — **Amendment 1 supersedes §12's simple-mode-only carve-out**: the unified mode-aware walker (ADR-0030/0031/0032) now speaks SQL, so advanced-mode ambient assistance is re-enabled. `ambient_hint_in_mode` + `hint_resolution_at_input_in_mode` + `expected_for_hint_snapshot` thread `Mode`; `render_hint_panel` calls ambient for all modes (no more advanced-mode `None`); the one-shot `:` sigil is stripped before the ambient walk. Fixes a live bug where advanced-mode SQL hinting/completion-preview were dead despite Phase 2 marking them green (validated at the engine layer, not the UI). Simple-mode gating, highlighting, and the §13 performance posture are unchanged; covered by an app-level render test plus ambient-layer regression locks; **Amendment 2 reverses the handoff-14 keywords-first candidate ordering** — schema identifiers (table/column/relationship names) now sort *before* keywords so a name the user would have to look up stays visible in the single-row, window-scrolled candidate line (keywords are learned over time; the `tok_identifier`/`tok_keyword` colour split marks the boundary); shipped with a `walk_repeated` fix that surfaces a list item's trailing optionals at a clean boundary (`order by Name ` → `asc`/`desc`, `select Name ` → `as`, `create table … Code(text) ` → `not`/`unique`/`default`/`check`; the `,` separator deliberately not surfaced); records a deferred two-line hint box for growing lists; **Amendment 3 makes the ambient-hint fallback rung schema-aware** — Amendment 1's bottom-rung `parse_command_in_mode` was schemaless while every earlier rung was not, so between-values insert hints pointed at `)` (type-blind close) instead of `,` and wrong-arity closed tuples read "submit with Enter" for an input the schema-aware parse rejects (issue #2); now uses `parse_command_with_schema_in_mode`, no extra walk, with the friendly arity diagnostic still winning at its higher rung; **Amendment 4 gives column types a dedicated highlight class** — both `Node::Ident.highlight_override` *and* the `Word.highlight_override` field were dead (driver destructured the former to `_`, `walk_word` hardcoded `Keyword`); now both wired through, with a new `HighlightClass::Type` + eighth `Theme` field `tok_type` (a pink/deep-magenta distinct from both keyword purple and identifier teal) so types no longer render identically to identifiers (issue #8); the three `IdentSource::Types` slots opt in via `Some(Type)` (advanced-mode single-word SQL aliases — `float`, `varchar`, … per ADR-0035 §3 — ride along for free), and the two-word `double precision` alias opts in via the new `Word::type_keyword` constructor so it matches its synonyms +- [ADR-0022 — Ambient typing assistance: colour, hint panel, completion (I3 + I4)](0022-ambient-typing-assistance.md) — **Amendment 1 supersedes §12's simple-mode-only carve-out**: the unified mode-aware walker (ADR-0030/0031/0032) now speaks SQL, so advanced-mode ambient assistance is re-enabled. `ambient_hint_in_mode` + `hint_resolution_at_input_in_mode` + `expected_for_hint_snapshot` thread `Mode`; `render_hint_panel` calls ambient for all modes (no more advanced-mode `None`); the one-shot `:` sigil is stripped before the ambient walk. Fixes a live bug where advanced-mode SQL hinting/completion-preview were dead despite Phase 2 marking them green (validated at the engine layer, not the UI). Simple-mode gating, highlighting, and the §13 performance posture are unchanged; covered by an app-level render test plus ambient-layer regression locks; **Amendment 2 reverses the handoff-14 keywords-first candidate ordering** — schema identifiers (table/column/relationship names) now sort *before* keywords so a name the user would have to look up stays visible in the single-row, window-scrolled candidate line (keywords are learned over time; the `tok_identifier`/`tok_keyword` colour split marks the boundary); shipped with a `walk_repeated` fix that surfaces a list item's trailing optionals at a clean boundary (`order by Name ` → `asc`/`desc`, `select Name ` → `as`, `create table … Code(text) ` → `not`/`unique`/`default`/`check`; the `,` separator deliberately not surfaced); records a deferred two-line hint box for growing lists; **Amendment 3 makes the ambient-hint fallback rung schema-aware** — Amendment 1's bottom-rung `parse_command_in_mode` was schemaless while every earlier rung was not, so between-values insert hints pointed at `)` (type-blind close) instead of `,` and wrong-arity closed tuples read "submit with Enter" for an input the schema-aware parse rejects (issue #2); now uses `parse_command_with_schema_in_mode`, no extra walk, with the friendly arity diagnostic still winning at its higher rung; **Amendment 4 gives column types a dedicated highlight class** — both `Node::Ident.highlight_override` *and* the `Word.highlight_override` field were dead (driver destructured the former to `_`, `walk_word` hardcoded `Keyword`); now both wired through, with a new `HighlightClass::Type` + eighth `Theme` field `tok_type` (a pink/deep-magenta distinct from both keyword purple and identifier teal) so types no longer render identically to identifiers (issue #8); the three `IdentSource::Types` slots opt in via `Some(Type)` (advanced-mode single-word SQL aliases — `float`, `varchar`, … per ADR-0035 §3 — ride along for free), and the two-word `double precision` alias opts in via the new `Word::type_keyword` constructor so it matches its synonyms; **Amendment 5 lets the hint panel grow for long prose hints** — a fixed one-row panel clipped long field-value/usage hints past the first line (issue #12); `resolve_hint_lines` now pre-wraps prose and `render_right_column` sizes the panel to the line count (1 row default, up to `MAX_HINT_ROWS`=3, reclaimed when short) with a `clamp_wrapped` ellipsis backstop; the candidate list still scrolls horizontally on one row (Amendment 2's deferred two-line candidate box stays deferred); also shortens the 299-char `parse.usage.sql_create_table` synopsis to a terse one-liner (full grammar remains in `help.ddl.sql_create_table`) - [ADR-0023 — Unified declarative grammar tree](0023-unified-grammar-tree.md) — direction (superseded for execution detail by ADR-0024) - [ADR-0024 — Unified grammar tree: execution plan](0024-unified-grammar-tree-execution-plan.md) — **Accepted**, the executable spec — implemented (Phases A–F; Phase F shipped "minimal", `parser.rs` retained as the router — see the ADR's Phase F implementation note) - [ADR-0025 — Indexes](0025-indexes.md) — **Accepted** (**Amendment 1, 2026-05-25**: UNIQUE indexes admitted on the **advanced-mode** surface via `CREATE UNIQUE INDEX` — ADR-0035 §4d; the `IndexSchema.unique` flag round-trips through `project.yaml` with no new metadata table since the engine reports uniqueness natively; simple-mode `add unique index` stays deferred), `add index` / `drop index`, persistence, rebuild-table preservation, and items-list display (`C3` index portion + `S2`) diff --git a/src/friendly/strings/en-US.yaml b/src/friendly/strings/en-US.yaml index 36ffa64..1e5e31f 100644 --- a/src/friendly/strings/en-US.yaml +++ b/src/friendly/strings/en-US.yaml @@ -486,7 +486,9 @@ parse: # placeholders. ADR-0009's surface conventions apply. usage: create_table: "create table with pk [()[, ...]]" - sql_create_table: "create table [if not exists] ( [not null] [unique] [primary key] [default ] [check ()] [references [()]], ... [, primary key (, ...)] [, unique (, ...)] [, check ()] [, [constraint ] foreign key () references [()]])" + # Terse one-line synopsis (issue #12): the full grammar — every + # column- and table-level constraint — lives in `help.ddl.sql_create_table`. + sql_create_table: "create table [if not exists] ( [constraints], ...)" sql_drop_table: "drop table [if exists] " sql_create_index: "create [unique] index [if not exists] [] on ([, ...])" sql_drop_index: "drop index [if exists] " diff --git a/src/snapshots/rdbms_playground__ui__tests__default_advanced_dark.snap b/src/snapshots/rdbms_playground__ui__tests__default_advanced_dark.snap index 5b9c31b..8bc79f9 100644 --- a/src/snapshots/rdbms_playground__ui__tests__default_advanced_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__default_advanced_dark.snap @@ -1,5 +1,6 @@ --- source: src/ui.rs +assertion_line: 1540 expression: snapshot --- ╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ @@ -16,13 +17,13 @@ expression: snapshot │ ││ │ │ ││ │ │ ││ │ -│ ││ │ │ │╰──────────────────────────────────────────────────╯ │ │╭ ADVANCED ────────────────────────────────────────╮ │ ││ │ │ │╰──────────────────────────────────────────────────╯ │ │╭ Hint ────────────────────────────────────────────╮ -│ ││Type a command — press Tab for options, `help` for│ +│ ││Type a command — press Tab for options, `help` │ +│ ││for a list │ ╰──────────────────────────╯╰──────────────────────────────────────────────────╯ Project: Term Planner Enter submit · mode simple switch · Ctrl-C quit diff --git a/src/snapshots/rdbms_playground__ui__tests__default_simple_dark.snap b/src/snapshots/rdbms_playground__ui__tests__default_simple_dark.snap index 3ccb6d0..51a442a 100644 --- a/src/snapshots/rdbms_playground__ui__tests__default_simple_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__default_simple_dark.snap @@ -1,5 +1,6 @@ --- source: src/ui.rs +assertion_line: 1523 expression: snapshot --- ╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ @@ -16,13 +17,13 @@ expression: snapshot │ ││ │ │ ││ │ │ ││ │ -│ ││ │ │ │╰──────────────────────────────────────────────────╯ │ │╭ SIMPLE ──────────────────────────────────────────╮ │ ││ │ │ │╰──────────────────────────────────────────────────╯ │ │╭ Hint ────────────────────────────────────────────╮ -│ ││Type a command — press Tab for options, `help` for│ +│ ││Type a command — press Tab for options, `help` │ +│ ││for a list │ ╰──────────────────────────╯╰──────────────────────────────────────────────────╯ Project: Term Planner Enter submit · : advanced once · mode advanced switch · Ctrl-C quit diff --git a/src/snapshots/rdbms_playground__ui__tests__default_simple_light.snap b/src/snapshots/rdbms_playground__ui__tests__default_simple_light.snap index 3ccb6d0..7253e06 100644 --- a/src/snapshots/rdbms_playground__ui__tests__default_simple_light.snap +++ b/src/snapshots/rdbms_playground__ui__tests__default_simple_light.snap @@ -1,5 +1,6 @@ --- source: src/ui.rs +assertion_line: 1531 expression: snapshot --- ╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ @@ -16,13 +17,13 @@ expression: snapshot │ ││ │ │ ││ │ │ ││ │ -│ ││ │ │ │╰──────────────────────────────────────────────────╯ │ │╭ SIMPLE ──────────────────────────────────────────╮ │ ││ │ │ │╰──────────────────────────────────────────────────╯ │ │╭ Hint ────────────────────────────────────────────╮ -│ ││Type a command — press Tab for options, `help` for│ +│ ││Type a command — press Tab for options, `help` │ +│ ││for a list │ ╰──────────────────────────╯╰──────────────────────────────────────────────────╯ Project: Term Planner Enter submit · : advanced once · mode advanced switch · Ctrl-C quit diff --git a/src/snapshots/rdbms_playground__ui__tests__highlighted_input_all_token_classes_dark.snap b/src/snapshots/rdbms_playground__ui__tests__highlighted_input_all_token_classes_dark.snap index 2cafed7..5d836be 100644 --- a/src/snapshots/rdbms_playground__ui__tests__highlighted_input_all_token_classes_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__highlighted_input_all_token_classes_dark.snap @@ -1,5 +1,6 @@ --- source: src/ui.rs +assertion_line: 1583 expression: snapshot --- ╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ @@ -15,14 +16,14 @@ expression: snapshot │ ││ │ │ ││ │ │ ││ │ -│ ││ │ -│ ││ │ │ │╰──────────────────────────────────────────────────╯ │ │╭ SIMPLE ──────────────────────────────────────────╮ │ ││insert into T values (1, 'hi', null) --all-r │ │ │╰──────────────────────────────────────────────────╯ │ │╭ Hint ────────────────────────────────────────────╮ │ ││after `insert into T values (1, 'hi', null)`, │ +│ ││expected end of input — usage: insert into │ +│ ││
[([, ...])] [values] ([, ...])│ ╰──────────────────────────╯╰──────────────────────────────────────────────────╯ Project: Term Planner Enter submit · : advanced once · mode advanced switch · Ctrl-C quit diff --git a/src/snapshots/rdbms_playground__ui__tests__populated_with_table_dark.snap b/src/snapshots/rdbms_playground__ui__tests__populated_with_table_dark.snap index 46e7e01..bbb52b7 100644 --- a/src/snapshots/rdbms_playground__ui__tests__populated_with_table_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__populated_with_table_dark.snap @@ -1,5 +1,6 @@ --- source: src/ui.rs +assertion_line: 1771 expression: snapshot --- ╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ @@ -16,13 +17,13 @@ expression: snapshot │ ││ │ │ ││ │ │ ││ │ -│ ││ │ │ │╰──────────────────────────────────────────────────╯ │ │╭ SIMPLE ──────────────────────────────────────────╮ │ ││ │ │ │╰──────────────────────────────────────────────────╯ │ │╭ Hint ────────────────────────────────────────────╮ -│ ││Type a command — press Tab for options, `help` for│ +│ ││Type a command — press Tab for options, `help` │ +│ ││for a list │ ╰──────────────────────────╯╰──────────────────────────────────────────────────╯ Project: Term Planner Enter submit · : advanced once · mode advanced switch · Ctrl-C quit diff --git a/src/snapshots/rdbms_playground__ui__tests__rebuild_confirm_modal_dark.snap b/src/snapshots/rdbms_playground__ui__tests__rebuild_confirm_modal_dark.snap index f34c9e6..8b3a591 100644 --- a/src/snapshots/rdbms_playground__ui__tests__rebuild_confirm_modal_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__rebuild_confirm_modal_dark.snap @@ -1,6 +1,6 @@ --- source: src/ui.rs -assertion_line: 1469 +assertion_line: 1613 expression: snapshot --- ╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ @@ -17,13 +17,13 @@ expression: snapshot │ │ │ │ │ │Continue? │ │ │ │ │ │ -│ │[Y] Yes [N] No Esc cancel │ │ -│ ╰──────────────────────────────────────────────────────────╯─────────╯ -│ │╭ SIMPLE ──────────────────────────────────────────╮ +│ │[Y] Yes [N] No Esc cancel │─────────╯ +│ ╰──────────────────────────────────────────────────────────╯─────────╮ │ ││ │ │ │╰──────────────────────────────────────────────────╯ │ │╭ Hint ────────────────────────────────────────────╮ -│ ││Type a command — press Tab for options, `help` for│ +│ ││Type a command — press Tab for options, `help` │ +│ ││for a list │ ╰──────────────────────────╯╰──────────────────────────────────────────────────╯ Project: Term Planner Enter submit · : advanced once · mode advanced switch · Ctrl-C quit diff --git a/src/ui.rs b/src/ui.rs index 5b804a5..8959ea5 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -493,6 +493,36 @@ fn wrap_lines(s: &str, width: usize) -> Vec { lines } +/// Maximum content rows the Hint panel may grow to before its last +/// visible row is ellipsis-truncated (issue #12). The panel starts +/// at one row and grows only as far as a wrapped hint needs, up to +/// this cap, reclaiming the space when the hint is short. +const MAX_HINT_ROWS: usize = 3; + +/// Word-wrap `text` to `width`, then clamp to at most `max_rows` +/// rows. If wrapping produced more rows than the cap, the last kept +/// row is truncated to end with an ellipsis so the overflow is +/// signalled rather than silently dropped (issue #12). Every +/// returned row fits within `width`. +fn clamp_wrapped(text: &str, width: usize, max_rows: usize) -> Vec { + let mut lines = wrap_lines(text, width); + if lines.len() <= max_rows { + return lines; + } + lines.truncate(max_rows.max(1)); + if let Some(last) = lines.last_mut() { + // Reserve one column for the ellipsis. + let budget = width.saturating_sub(1); + let mut chars: Vec = last.chars().collect(); + while chars.len() > budget { + chars.pop(); + } + chars.push('…'); + *last = chars.into_iter().collect(); + } + lines +} + fn render_project_label(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { let label_style = Style::default().fg(theme.muted); let value_style = Style::default() @@ -521,18 +551,27 @@ fn render_project_label(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: R } fn render_right_column(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { + // Resolve the hint first so the layout can size the Hint panel to + // the wrapped hint (issue #12): one content row by default, + // growing up to MAX_HINT_ROWS, reclaiming the space when short. + // The hint panel spans the full column width, so `area.width` is + // its width too. + let hint_lines = resolve_hint_lines(app, theme, area.width); + let hint_content = + (hint_lines.len().clamp(1, MAX_HINT_ROWS) as u16).saturating_add(2); + let rows = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Min(5), // Output panel - Constraint::Length(3), // Input panel - Constraint::Length(3), // Hint panel + Constraint::Min(5), // Output panel + Constraint::Length(3), // Input panel + Constraint::Length(hint_content), // Hint panel (dynamic) ]) .split(area); render_output_panel(app, theme, frame, rows[0]); render_input_panel(app, theme, frame, rows[1]); - render_hint_panel(app, theme, frame, rows[2]); + render_hint_panel(theme, frame, rows[2], hint_lines); } fn paint_background(theme: &Theme, frame: &mut Frame<'_>, area: Rect) { @@ -932,31 +971,40 @@ fn strip_one_shot_prefix(input: &str, cursor: usize) -> (&str, usize) { (effective, effective_cursor) } -fn render_hint_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { - let block = Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(theme.border)) - .title(Span::styled( - format!(" {} ", crate::t!("panel.hint_title")), - Style::default() - .fg(theme.fg) - .add_modifier(Modifier::BOLD), - )) - .style(Style::default().bg(theme.bg).fg(theme.fg)); +/// Resolve the Hint panel body into its rendered lines, pre-wrapped +/// to the panel's inner width and clamped to `MAX_HINT_ROWS` with an +/// ellipsis backstop (issue #12). The returned line count is the +/// content-row count `render_right_column` allocates for, so the +/// panel grows for a long hint and reclaims the space for a short +/// one. +/// +/// Resolution order for the body: +/// 1. An explicit app-set hint (e.g. modal contexts) wins. +/// 2. Otherwise, with non-empty input, the ambient +/// typing-assistance hint (ADR-0022 §6) in the effective mode. +/// 3. Otherwise, the empty-state placeholder. +/// +/// Prose hints (1, the ambient `Prose` arm, and the placeholder) +/// word-wrap across up to `MAX_HINT_ROWS` rows. The candidate list +/// stays a single row and scrolls horizontally with `<` / `>` +/// markers (`render_candidate_line`) — it already self-fits, so it +/// is not wrapped. +/// +/// ADR-0022 Amendment 1: advanced mode no longer skips ambient +/// hinting. The original §12 carve-out predated the unified +/// mode-aware walker (ADR-0030/0031/0032); the walker now speaks +/// SQL, so `ambient_hint_in_mode` surfaces SQL slot hints + +/// completion candidates in advanced mode too. +fn resolve_hint_lines(app: &App, theme: &Theme, area_width: u16) -> Vec> { + let inner = area_width.saturating_sub(2) as usize; + let muted = Style::default().fg(theme.muted); + let prose = |text: &str| { + clamp_wrapped(text, inner, MAX_HINT_ROWS) + .into_iter() + .map(|l| Line::from(Span::styled(l, muted))) + .collect::>>() + }; - // Resolution order for the hint panel body: - // 1. An explicit app-set hint (e.g. modal contexts) wins. - // 2. Otherwise, with non-empty input, the ambient - // typing-assistance hint (ADR-0022 §6) computed in the - // effective mode. - // 3. Otherwise, the existing empty-state placeholder. - // ADR-0022 Amendment 1: advanced mode no longer skips ambient - // hinting. The original §12 carve-out predated the unified - // mode-aware walker (ADR-0030/0031/0032); the walker now - // speaks SQL, so `ambient_hint_in_mode` surfaces SQL slot - // hints + completion candidates in advanced mode too. - let empty_hint = crate::t!("panel.hint_empty"); // In one-shot advanced mode (`:` prefix in simple mode) the // raw input carries the `:` sigil, which is not part of the // grammar. Strip it for the ambient computation so the hint @@ -974,27 +1022,37 @@ fn render_hint_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect &app.schema_cache, app.effective_mode().as_mode(), ); - 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)) - } + match (app.hint.as_deref(), ambient) { + (Some(set), _) => prose(set), + (None, Some(crate::input_render::AmbientHint::Prose(text))) => prose(&text), (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) + vec![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 }); + (None, None) => prose(&crate::t!("panel.hint_empty")), + } +} +fn render_hint_panel( + theme: &Theme, + frame: &mut Frame<'_>, + area: Rect, + lines: Vec>, +) { + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(theme.border)) + .title(Span::styled( + format!(" {} ", crate::t!("panel.hint_title")), + Style::default() + .fg(theme.fg) + .add_modifier(Modifier::BOLD), + )) + .style(Style::default().bg(theme.bg).fg(theme.fg)); + + // Lines are already wrapped to the inner width by + // `resolve_hint_lines`, so no Paragraph-level wrapping is needed. + let paragraph = Paragraph::new(lines).block(block); frame.render_widget(paragraph, area); } @@ -1371,6 +1429,93 @@ mod tests { out } + // ---- Issue #12: long hints no longer clipped to one row ----- + + const LONG_HINT: &str = "(id, created_at auto-generated — skipped here; list columns explicitly, e.g. `insert into T (...) values (...)`, to set it.)"; + + #[test] + fn long_prose_hint_shows_tail_across_multiple_rows() { + // Before the fix the Hint panel was a fixed 1 content row, + // so this hint's useful tail was clipped. Now the panel + // grows (to MAX_HINT_ROWS) so the tail is visible. + let mut app = App::new(); + app.hint = Some(LONG_HINT.to_string()); + let theme = Theme::dark(); + let out = render_to_string(&mut app, &theme, 80, 20); + assert!( + out.contains("list columns explicitly"), + "the hint tail must be visible, not clipped:\n{out}" + ); + } + + #[test] + fn short_hint_keeps_panel_at_one_content_row() { + // Reclaim: a short hint must not inflate the panel. + let mut app = App::new(); + app.hint = Some("Type a command".to_string()); + let theme = Theme::dark(); + let out = render_to_string(&mut app, &theme, 80, 20); + assert!( + out.lines().any(|l| l.contains("Type a command")), + "short hint visible:\n{out}" + ); + assert_eq!( + hint_content_rows(&out), + 1, + "short hint should occupy exactly one content row:\n{out}" + ); + } + + #[test] + fn long_hint_grows_panel_but_caps_at_max_rows() { + let mut app = App::new(); + app.hint = Some(LONG_HINT.to_string()); + let theme = Theme::dark(); + // Narrow width forces more wrapped lines than the cap. + let out = render_to_string(&mut app, &theme, 44, 20); + assert_eq!( + hint_content_rows(&out), + MAX_HINT_ROWS, + "long hint caps at MAX_HINT_ROWS content rows:\n{out}" + ); + } + + /// Count the content rows inside the Hint panel of a rendered + /// screen: the rows between the `╭ Hint …` title border and the + /// next `╰…╯` bottom border. + fn hint_content_rows(out: &str) -> usize { + let lines: Vec<&str> = out.lines().collect(); + let top = lines + .iter() + .position(|l| l.contains("Hint") && l.contains('╭')) + .expect("hint title border present"); + // Rows strictly between the title border and the next + // bottom border == the content-row count. + lines[top + 1..] + .iter() + .position(|l| l.contains('╰')) + .expect("hint bottom border present") + } + + #[test] + fn clamp_wrapped_truncates_with_ellipsis_past_max() { + // ≤ max rows: untouched. + let two = clamp_wrapped("alpha beta gamma delta", 11, 3); + assert_eq!(two, vec!["alpha beta", "gamma delta"]); + // > max rows: clamp to max, last row ends with an ellipsis, + // and every row stays within the width. + let many = clamp_wrapped( + "alpha beta gamma delta epsilon zeta eta theta iota", + 11, + 3, + ); + assert_eq!(many.len(), 3); + assert!(many[2].ends_with('…'), "last row ellipsized: {many:?}"); + for row in &many { + assert!(row.chars().count() <= 11, "row within width: {row:?}"); + } + } + #[test] fn dark_theme_default_view_snapshot() { let mut app = App::new();