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.
This commit is contained in:
claude@clouddev1
2026-05-30 09:02:12 +00:00
parent 5ea69dbc08
commit f7ca288fe1
10 changed files with 259 additions and 63 deletions
@@ -642,6 +642,50 @@ example), `advanced_double_precision_classified_as_type`,
extended theme mapping/contrast tests. Text snapshots are colour-blind extended theme mapping/contrast tests. Text snapshots are colour-blind
(`render_to_string` strips style), so none churned. (`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] <Name> (<col> <type> [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 ## Out of scope
Deliberately deferred to keep this ADR shippable as a single Deliberately deferred to keep this ADR shippable as a single
+1 -1
View File
@@ -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-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-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-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-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 AF; Phase F shipped "minimal", `parser.rs` retained as the router — see the ADR's Phase F implementation note) - [ADR-0024 — Unified grammar tree: execution plan](0024-unified-grammar-tree-execution-plan.md) — **Accepted**, the executable spec — implemented (Phases AF; 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`) - [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`)
+3 -1
View File
@@ -486,7 +486,9 @@ parse:
# placeholders. ADR-0009's surface conventions apply. # placeholders. ADR-0009's surface conventions apply.
usage: usage:
create_table: "create table <Name> with pk [<col>(<type>)[, ...]]" create_table: "create table <Name> with pk [<col>(<type>)[, ...]]"
sql_create_table: "create table [if not exists] <Name> (<col> <type> [not null] [unique] [primary key] [default <expr>] [check (<expr>)] [references <Parent>[(<col>)]], ... [, primary key (<col>, ...)] [, unique (<col>, ...)] [, check (<expr>)] [, [constraint <name>] foreign key (<col>) references <Parent>[(<col>)]])" # 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] <Name> (<col> <type> [constraints], ...)"
sql_drop_table: "drop table [if exists] <Name>" sql_drop_table: "drop table [if exists] <Name>"
sql_create_index: "create [unique] index [if not exists] [<Name>] on <Table> (<col>[, ...])" sql_create_index: "create [unique] index [if not exists] [<Name>] on <Table> (<col>[, ...])"
sql_drop_index: "drop index [if exists] <Name>" sql_drop_index: "drop index [if exists] <Name>"
@@ -1,5 +1,6 @@
--- ---
source: src/ui.rs source: src/ui.rs
assertion_line: 1540
expression: snapshot expression: snapshot
--- ---
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ ╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
@@ -16,13 +17,13 @@ expression: snapshot
│ ││ │ │ ││ │
│ ││ │ │ ││ │
│ ││ │ │ ││ │
│ ││ │
│ │╰──────────────────────────────────────────────────╯ │ │╰──────────────────────────────────────────────────╯
│ │╭ ADVANCED ────────────────────────────────────────╮ │ │╭ ADVANCED ────────────────────────────────────────╮
│ ││ │ │ ││ │
│ │╰──────────────────────────────────────────────────╯ │ │╰──────────────────────────────────────────────────╯
│ │╭ Hint ────────────────────────────────────────────╮ │ │╭ Hint ────────────────────────────────────────────╮
│ ││Type a command — press Tab for options, `help` for │ ││Type a command — press Tab for options, `help`
│ ││for a list │
╰──────────────────────────╯╰──────────────────────────────────────────────────╯ ╰──────────────────────────╯╰──────────────────────────────────────────────────╯
Project: Term Planner Project: Term Planner
Enter submit · mode simple switch · Ctrl-C quit Enter submit · mode simple switch · Ctrl-C quit
@@ -1,5 +1,6 @@
--- ---
source: src/ui.rs source: src/ui.rs
assertion_line: 1523
expression: snapshot expression: snapshot
--- ---
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ ╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
@@ -16,13 +17,13 @@ expression: snapshot
│ ││ │ │ ││ │
│ ││ │ │ ││ │
│ ││ │ │ ││ │
│ ││ │
│ │╰──────────────────────────────────────────────────╯ │ │╰──────────────────────────────────────────────────╯
│ │╭ SIMPLE ──────────────────────────────────────────╮ │ │╭ SIMPLE ──────────────────────────────────────────╮
│ ││ │ │ ││ │
│ │╰──────────────────────────────────────────────────╯ │ │╰──────────────────────────────────────────────────╯
│ │╭ Hint ────────────────────────────────────────────╮ │ │╭ Hint ────────────────────────────────────────────╮
│ ││Type a command — press Tab for options, `help` for │ ││Type a command — press Tab for options, `help`
│ ││for a list │
╰──────────────────────────╯╰──────────────────────────────────────────────────╯ ╰──────────────────────────╯╰──────────────────────────────────────────────────╯
Project: Term Planner Project: Term Planner
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
@@ -1,5 +1,6 @@
--- ---
source: src/ui.rs source: src/ui.rs
assertion_line: 1531
expression: snapshot expression: snapshot
--- ---
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ ╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
@@ -16,13 +17,13 @@ expression: snapshot
│ ││ │ │ ││ │
│ ││ │ │ ││ │
│ ││ │ │ ││ │
│ ││ │
│ │╰──────────────────────────────────────────────────╯ │ │╰──────────────────────────────────────────────────╯
│ │╭ SIMPLE ──────────────────────────────────────────╮ │ │╭ SIMPLE ──────────────────────────────────────────╮
│ ││ │ │ ││ │
│ │╰──────────────────────────────────────────────────╯ │ │╰──────────────────────────────────────────────────╯
│ │╭ Hint ────────────────────────────────────────────╮ │ │╭ Hint ────────────────────────────────────────────╮
│ ││Type a command — press Tab for options, `help` for │ ││Type a command — press Tab for options, `help`
│ ││for a list │
╰──────────────────────────╯╰──────────────────────────────────────────────────╯ ╰──────────────────────────╯╰──────────────────────────────────────────────────╯
Project: Term Planner Project: Term Planner
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
@@ -1,5 +1,6 @@
--- ---
source: src/ui.rs source: src/ui.rs
assertion_line: 1583
expression: snapshot expression: snapshot
--- ---
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ ╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
@@ -15,14 +16,14 @@ expression: snapshot
│ ││ │ │ ││ │
│ ││ │ │ ││ │
│ ││ │ │ ││ │
│ ││ │
│ ││ │
│ │╰──────────────────────────────────────────────────╯ │ │╰──────────────────────────────────────────────────╯
│ │╭ SIMPLE ──────────────────────────────────────────╮ │ │╭ SIMPLE ──────────────────────────────────────────╮
│ ││insert into T values (1, 'hi', null) --all-r │ │ ││insert into T values (1, 'hi', null) --all-r │
│ │╰──────────────────────────────────────────────────╯ │ │╰──────────────────────────────────────────────────╯
│ │╭ Hint ────────────────────────────────────────────╮ │ │╭ Hint ────────────────────────────────────────────╮
│ ││after `insert into T values (1, 'hi', null)`, │ │ ││after `insert into T values (1, 'hi', null)`, │
│ ││expected end of input — usage: insert into │
│ ││<Table> [(<col>[, ...])] [values] (<value>[, ...])│
╰──────────────────────────╯╰──────────────────────────────────────────────────╯ ╰──────────────────────────╯╰──────────────────────────────────────────────────╯
Project: Term Planner Project: Term Planner
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
@@ -1,5 +1,6 @@
--- ---
source: src/ui.rs source: src/ui.rs
assertion_line: 1771
expression: snapshot expression: snapshot
--- ---
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ ╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
@@ -16,13 +17,13 @@ expression: snapshot
│ ││ │ │ ││ │
│ ││ │ │ ││ │
│ ││ │ │ ││ │
│ ││ │
│ │╰──────────────────────────────────────────────────╯ │ │╰──────────────────────────────────────────────────╯
│ │╭ SIMPLE ──────────────────────────────────────────╮ │ │╭ SIMPLE ──────────────────────────────────────────╮
│ ││ │ │ ││ │
│ │╰──────────────────────────────────────────────────╯ │ │╰──────────────────────────────────────────────────╯
│ │╭ Hint ────────────────────────────────────────────╮ │ │╭ Hint ────────────────────────────────────────────╮
│ ││Type a command — press Tab for options, `help` for │ ││Type a command — press Tab for options, `help`
│ ││for a list │
╰──────────────────────────╯╰──────────────────────────────────────────────────╯ ╰──────────────────────────╯╰──────────────────────────────────────────────────╯
Project: Term Planner Project: Term Planner
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
@@ -1,6 +1,6 @@
--- ---
source: src/ui.rs source: src/ui.rs
assertion_line: 1469 assertion_line: 1613
expression: snapshot expression: snapshot
--- ---
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ ╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
@@ -17,13 +17,13 @@ expression: snapshot
│ │ │ │ │ │ │ │
│ │Continue? │ │ │ │Continue? │ │
│ │ │ │ │ │ │ │
│ │[Y] Yes [N] No Esc cancel │ │ │[Y] Yes [N] No Esc cancel │─────────╯
│ ╰──────────────────────────────────────────────────────────╯───────── │ ╰──────────────────────────────────────────────────────────╯─────────
│ │╭ SIMPLE ──────────────────────────────────────────╮
│ ││ │ │ ││ │
│ │╰──────────────────────────────────────────────────╯ │ │╰──────────────────────────────────────────────────╯
│ │╭ Hint ────────────────────────────────────────────╮ │ │╭ Hint ────────────────────────────────────────────╮
│ ││Type a command — press Tab for options, `help` for │ ││Type a command — press Tab for options, `help`
│ ││for a list │
╰──────────────────────────╯╰──────────────────────────────────────────────────╯ ╰──────────────────────────╯╰──────────────────────────────────────────────────╯
Project: Term Planner Project: Term Planner
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
+191 -46
View File
@@ -493,6 +493,36 @@ fn wrap_lines(s: &str, width: usize) -> Vec<String> {
lines 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<String> {
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<char> = 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) { fn render_project_label(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
let label_style = Style::default().fg(theme.muted); let label_style = Style::default().fg(theme.muted);
let value_style = Style::default() 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) { 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() let rows = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([ .constraints([
Constraint::Min(5), // Output panel Constraint::Min(5), // Output panel
Constraint::Length(3), // Input panel Constraint::Length(3), // Input panel
Constraint::Length(3), // Hint panel Constraint::Length(hint_content), // Hint panel (dynamic)
]) ])
.split(area); .split(area);
render_output_panel(app, theme, frame, rows[0]); render_output_panel(app, theme, frame, rows[0]);
render_input_panel(app, theme, frame, rows[1]); 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) { 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) (effective, effective_cursor)
} }
fn render_hint_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { /// Resolve the Hint panel body into its rendered lines, pre-wrapped
let block = Block::default() /// to the panel's inner width and clamped to `MAX_HINT_ROWS` with an
.borders(Borders::ALL) /// ellipsis backstop (issue #12). The returned line count is the
.border_type(BorderType::Rounded) /// content-row count `render_right_column` allocates for, so the
.border_style(Style::default().fg(theme.border)) /// panel grows for a long hint and reclaims the space for a short
.title(Span::styled( /// one.
format!(" {} ", crate::t!("panel.hint_title")), ///
Style::default() /// Resolution order for the body:
.fg(theme.fg) /// 1. An explicit app-set hint (e.g. modal contexts) wins.
.add_modifier(Modifier::BOLD), /// 2. Otherwise, with non-empty input, the ambient
)) /// typing-assistance hint (ADR-0022 §6) in the effective mode.
.style(Style::default().bg(theme.bg).fg(theme.fg)); /// 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<Line<'static>> {
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::<Vec<Line<'static>>>()
};
// 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 // In one-shot advanced mode (`:` prefix in simple mode) the
// raw input carries the `:` sigil, which is not part of the // raw input carries the `:` sigil, which is not part of the
// grammar. Strip it for the ambient computation so the hint // 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.schema_cache,
app.effective_mode().as_mode(), app.effective_mode().as_mode(),
); );
let muted = Style::default().fg(theme.muted); match (app.hint.as_deref(), ambient) {
let line = match (app.hint.as_deref(), ambient) { (Some(set), _) => prose(set),
(Some(set), _) => Line::from(Span::styled(set.to_string(), muted)), (None, Some(crate::input_render::AmbientHint::Prose(text))) => prose(&text),
(None, Some(crate::input_render::AmbientHint::Prose(text))) => {
Line::from(Span::styled(text, muted))
}
(None, Some(crate::input_render::AmbientHint::Candidates { items, selected })) => { (None, Some(crate::input_render::AmbientHint::Candidates { items, selected })) => {
// ADR-0022 §7 + user's #2: render items with the vec![render_candidate_line(&items, selected, inner, theme)]
// 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)), (None, None) => prose(&crate::t!("panel.hint_empty")),
}; }
let paragraph = Paragraph::new(line) }
.block(block)
.wrap(Wrap { trim: false });
fn render_hint_panel(
theme: &Theme,
frame: &mut Frame<'_>,
area: Rect,
lines: Vec<Line<'static>>,
) {
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); frame.render_widget(paragraph, area);
} }
@@ -1371,6 +1429,93 @@ mod tests {
out 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] #[test]
fn dark_theme_default_view_snapshot() { fn dark_theme_default_view_snapshot() {
let mut app = App::new(); let mut app = App::new();