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 5d836be..fa8dbd9 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,6 +1,6 @@
---
source: src/ui.rs
-assertion_line: 1583
+assertion_line: 1976
expression: snapshot
---
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
@@ -16,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] ([, ...])│
+│ ││expected end of input — usage: insert into… │
╰──────────────────────────╯╰──────────────────────────────────────────────────╯
Project: Term Planner
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
diff --git a/src/snapshots/rdbms_playground__ui__tests__one_shot_advanced_dark.snap b/src/snapshots/rdbms_playground__ui__tests__one_shot_advanced_dark.snap
index cb62313..82e536b 100644
--- a/src/snapshots/rdbms_playground__ui__tests__one_shot_advanced_dark.snap
+++ b/src/snapshots/rdbms_playground__ui__tests__one_shot_advanced_dark.snap
@@ -1,5 +1,6 @@
---
source: src/ui.rs
+assertion_line: 1992
expression: snapshot
---
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
@@ -16,13 +17,13 @@ expression: snapshot
│ ││ │
│ ││ │
│ ││ │
-│ ││ │
│ │╰──────────────────────────────────────────────────╯
│ │╭ Advanced: ───────────────────────────────────────╮
│ ││: sel │
│ │╰──────────────────────────────────────────────────╯
│ │╭ Hint ────────────────────────────────────────────╮
│ ││select │
+│ ││ │
╰──────────────────────────╯╰──────────────────────────────────────────────────╯
Project: Term Planner
Enter submit · Backspace cancel one-shot · Ctrl-C quit
diff --git a/src/ui.rs b/src/ui.rs
index cd60b95..9057cef 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -493,12 +493,58 @@ 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.
+/// Absolute ceiling on Hint-panel content rows. Per ADR-0046 (DA1/DA2)
+/// the panel's height is no longer driven by the hint *content* — it is
+/// a pure function of terminal geometry (`hint_rows`), fixed between
+/// resizes, so it cannot resize mid-typing and shove the input/output
+/// panels (#20). This is the most rows `hint_rows` will ever allocate;
+/// a hint longer than the allocation is ellipsis-truncated (issue #12's
+/// overflow signalling is retained — only the *sizing* changed).
const MAX_HINT_ROWS: usize = 3;
+/// Terminal heights below this are "compact" (covers the ~25-row
+/// screencasts); at or above it the terminal is "comfortable" and can
+/// afford taller panels (ADR-0046 DA2). Tunable.
+const COMFORTABLE_MIN_HEIGHT: u16 = 40;
+
+/// A 3rd hint row is only ever needed when the hint column's inner
+/// width is narrow enough to wrap the longest catalog hint past two
+/// lines; at or above this width two rows always suffice (ADR-0046 DA2,
+/// measured against `src/friendly/strings/en-US.yaml`).
+const HINT_THIRD_ROW_MAX_INNER: u16 = 54;
+
+/// Hint-panel content-row count as a pure function of the right
+/// column's geometry (ADR-0046 DA1/DA2) — NOT of the hint text. That is
+/// the whole point of #20: a height fixed per terminal size cannot jump
+/// as the user types. Add 2 for the panel borders.
+///
+/// - Compact height (`< COMFORTABLE_MIN_HEIGHT`): 2 rows.
+/// - Comfortable height: 2 rows, or 3 when the column is narrow enough
+/// (`inner < HINT_THIRD_ROW_MAX_INNER`) that the longest hint needs a
+/// third line.
+/// - Degradation: on a terminal too short to honour the output panel's
+/// `Min(5)` plus the fixed input (3) and hint panels, the hint
+/// shrinks toward 1 so the output keeps its floor.
+///
+/// (DA4 will fold the input panel's height in here too; today the input
+/// is a fixed 3 rows, reflected in the degradation arithmetic.)
+const fn hint_rows(area: Rect) -> u16 {
+ let inner_w = area.width.saturating_sub(2);
+ let mut hint_c: u16 = if area.height < COMFORTABLE_MIN_HEIGHT {
+ 2
+ } else if inner_w < HINT_THIRD_ROW_MAX_INNER {
+ MAX_HINT_ROWS as u16
+ } else {
+ 2
+ };
+ // Honour the output panel's Min(5) first on a very short terminal:
+ // 5 (output) + 3 (input panel) + (hint_c + 2) must fit in the column.
+ while 5 + 3 + (hint_c + 2) > area.height && hint_c > 1 {
+ hint_c -= 1;
+ }
+ hint_c
+}
+
/// 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
@@ -551,21 +597,21 @@ 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);
+ // ADR-0046 DA1/DA2: the Hint panel's height is a pure function of
+ // the column geometry, fixed between resizes — it no longer tracks
+ // the hint content, so typing cannot make it resize and shove the
+ // input/output panels (#20). The hint is then clamped to that fixed
+ // row budget. The hint panel spans the full column width, so
+ // `area.width` is its width too.
+ let hint_c = hint_rows(area);
+ let hint_lines = resolve_hint_lines(app, theme, area.width, hint_c as usize);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
- Constraint::Min(5), // Output panel
- Constraint::Length(3), // Input panel
- Constraint::Length(hint_content), // Hint panel (dynamic)
+ Constraint::Min(5), // Output panel
+ Constraint::Length(3), // Input panel (DA4 will size this)
+ Constraint::Length(hint_c + 2), // Hint panel (geometry-fixed)
])
.split(area);
@@ -1041,11 +1087,11 @@ fn strip_one_shot_prefix(input: &str, cursor: usize) -> (&str, usize) {
}
/// 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.
+/// to the panel's inner width and clamped to `max_rows` with an
+/// ellipsis backstop (issue #12). `max_rows` is the geometry-fixed row
+/// budget chosen by `hint_rows` (ADR-0046 DA1/DA2); the panel does not
+/// resize to the hint, so a short hint simply leaves the spare rows
+/// blank and a long one is ellipsized at the budget.
///
/// Resolution order for the body:
/// 1. An explicit app-set hint (e.g. modal contexts) wins.
@@ -1064,11 +1110,16 @@ fn strip_one_shot_prefix(input: &str, cursor: usize) -> (&str, usize) {
/// 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> {
+fn resolve_hint_lines(
+ app: &App,
+ theme: &Theme,
+ area_width: u16,
+ max_rows: usize,
+) -> 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)
+ clamp_wrapped(text, inner, max_rows)
.into_iter()
.map(|l| Line::from(Span::styled(l, muted)))
.collect::>>()
@@ -1734,9 +1785,10 @@ mod tests {
#[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.
+ // A multi-row hint panel (here 2 rows at a compact 80×20) shows
+ // the hint's useful tail rather than clipping it to one row.
+ // (Pre-#12 the panel was a fixed 1 row; ADR-0046 keeps it
+ // multi-row but now sizes by geometry, not content.)
let mut app = App::new();
app.hint = Some(LONG_HINT.to_string());
let theme = Theme::dark();
@@ -1748,37 +1800,78 @@ mod tests {
}
#[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());
+ fn hint_panel_height_is_fixed_by_geometry_not_content() {
+ // ADR-0046 DA1/DA2 (#20): the panel no longer shrinks to a
+ // short hint (the issue #12 "reclaim" behaviour is deliberately
+ // reversed). At a compact (height < 40) terminal it is a fixed
+ // 2 content rows whether the hint is short or long, so it never
+ // resizes mid-typing and shoves the input/output panels.
let theme = Theme::dark();
- let out = render_to_string(&mut app, &theme, 80, 20);
+
+ let mut short = App::new();
+ short.hint = Some("Type a command".to_string());
+ let short_out = render_to_string(&mut short, &theme, 80, 20);
assert!(
- out.lines().any(|l| l.contains("Type a command")),
- "short hint visible:\n{out}"
+ short_out.lines().any(|l| l.contains("Type a command")),
+ "short hint visible:\n{short_out}"
+ );
+
+ let mut long = App::new();
+ long.hint = Some(LONG_HINT.to_string());
+ let long_out = render_to_string(&mut long, &theme, 80, 20);
+
+ assert_eq!(
+ hint_content_rows(&short_out),
+ 2,
+ "compact terminal fixes the hint at 2 rows:\n{short_out}"
);
assert_eq!(
- hint_content_rows(&out),
- 1,
- "short hint should occupy exactly one content row:\n{out}"
+ hint_content_rows(&short_out),
+ hint_content_rows(&long_out),
+ "the hint panel height must not differ between a short and a \
+ long hint at the same terminal size (#20 anti-jump):\n\
+ short:\n{short_out}\nlong:\n{long_out}"
);
}
#[test]
- fn long_hint_grows_panel_but_caps_at_max_rows() {
+ fn narrow_comfortable_terminal_allows_a_third_hint_row() {
+ // ADR-0046 DA2: a 3rd hint row appears only on a comfortable
+ // (height ≥ 40) terminal whose hint column is narrow enough
+ // (inner < 54) to wrap the longest hint past two lines; the
+ // ellipsis backstop still caps it at MAX_HINT_ROWS. (At a
+ // compact height the same hint is held to 2 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);
+ let out = render_to_string(&mut app, &theme, 44, 50);
assert_eq!(
hint_content_rows(&out),
MAX_HINT_ROWS,
- "long hint caps at MAX_HINT_ROWS content rows:\n{out}"
+ "narrow + tall terminal caps the long hint at MAX_HINT_ROWS \
+ content rows:\n{out}"
);
}
+ #[test]
+ fn hint_rows_is_geometry_driven() {
+ // ADR-0046 DA1/DA2: the pure helper that the renderer and these
+ // tests share. Height picks the bucket; width gates the 3rd row;
+ // a tiny terminal degrades toward 1 to protect output `Min(5)`.
+ let at = |w: u16, h: u16| hint_rows(Rect::new(0, 0, w, h));
+ // Compact height → always 2, regardless of width.
+ assert_eq!(at(90, 25), 2);
+ assert_eq!(at(40, 25), 2);
+ // Comfortable + wide (inner ≥ 54) → 2.
+ assert_eq!(at(90, 45), 2);
+ assert_eq!(at(56, 45), 2); // inner == 54 is "wide enough"
+ // Comfortable + narrow (inner < 54) → 3.
+ assert_eq!(at(55, 45), 3); // inner == 53
+ assert_eq!(at(50, 45), 3);
+ // Very short terminal degrades the hint to protect output Min(5).
+ assert_eq!(at(90, 11), 1);
+ }
+
/// Count the content rows inside the Hint panel of a rendered
/// screen: the rows between the `╭ Hint …` title border and the
/// next `╰…╯` bottom border.