perf(tui): lock composer height while slash/mention menu is open

User feedback (Windows 10 PowerShell + WSL, Telegram thread): typing
through `/skill` feels visibly laggy because every keystroke shrinks
the matched-entry list, which shrinks the composer panel, which
forces the chat area above to repaint cells. On Unix terminals the
work is invisible; on the Windows console backend the per-cell write
cost makes it noticeable.

Fix: when the slash- or mention-menu is open, `desired_height`
reserves the panel's worst-case envelope (`composer_max_height`) for
the whole menu session instead of tracking the matched-entry count.
The chat-area Rect stays stable, so ratatui's diff renderer skips
the cells above the composer entirely. The menu itself still renders
only the entries that actually match — extra rows are panel padding
inside the same Rect.

`render()` and `cursor_pos` route through the same locked-budget
calculation so the input stays at the top of the panel and the
cursor lands on the row the input is drawn on. New unit test pins
the invariant: 5-match and 1-match menus produce the same composer
height; closing the menu releases the reserved rows.
This commit is contained in:
Hunter Bown
2026-05-03 08:02:23 -05:00
parent 7b7f939346
commit d9701c1dde
2 changed files with 84 additions and 3 deletions
+8
View File
@@ -354,6 +354,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
workflows (`crates-publish.yml`, `parity.yml`, `publish-npm.yml`);
`release.yml` `build` job now allows `parity` to be skipped on
manual `workflow_dispatch`; release-runbook reconciled.
- **Slash-menu layout jitter on Windows** — typing through a
`/foo` autocomplete used to shrink the matched-entry count,
which shrank the composer height every keystroke, which forced
the chat area above to repaint. On Windows 10 PowerShell + WSL
the per-cell write cost made the jitter visible. Composer now
reserves its panel-max envelope for the whole slash/mention
session so the chat-area Rect stays stable; the menu still
renders only the entries that actually match.
### Releases
- npm wrapper publish remains manual (npm 2FA OTP requirement).
+76 -3
View File
@@ -367,6 +367,31 @@ impl<'a> ComposerWidget<'a> {
}
}
/// Row reservation passed to `composer_height`. When the slash- or
/// mention-menu is active we lock the composer to its worst-case
/// envelope so the chat area above doesn't repaint every keystroke
/// as the matched-entry count shrinks. Pure cosmetic: the menu
/// itself still renders its actual entries — the extra rows are
/// just panel padding inside the same Rect.
///
/// Reported on Windows 10 PowerShell + WSL where the console
/// backend's per-cell write cost makes the layout jitter visible
/// even though the work is tiny on Unix terminals. See user
/// feedback in v0.8.8 polish thread.
fn active_menu_reserved_rows(&self) -> usize {
let actual = self.active_menu_row_count();
if actual == 0 {
return 0;
}
if self.app.is_history_search_active() {
return actual;
}
// Slash- and mention-menu are the cases that grow/shrink mid-typing.
// Reserve the composer's panel-max so the layout stays stable
// for the lifetime of the menu session.
actual.max(usize::from(self.max_height_cap()))
}
fn has_panel(&self, area: Rect) -> bool {
self.app.composer_border && area.height >= 3 && area.width >= 12
}
@@ -410,7 +435,14 @@ impl Renderable for ComposerWidget<'_> {
} else {
menu_entries.len()
};
let input_rows_budget = composer_input_rows_budget(inner_area.height, menu_lines);
// For the layout-budget calculation, treat the menu as if it were
// already at its locked, worst-case height (see
// `active_menu_reserved_rows`). Without this, when the matched-entry
// count drops mid-typing, `top_padding` grows and the input visually
// jumps down inside the panel even though the panel rect stayed put.
let menu_lines_for_budget = self.active_menu_reserved_rows().max(menu_lines);
let input_rows_budget =
composer_input_rows_budget(inner_area.height, menu_lines_for_budget);
let content_width = usize::from(inner_area.width.max(1));
let (visible_lines, _cursor_row, _cursor_col) =
layout_input(input_text, input_cursor, content_width, input_rows_budget);
@@ -697,7 +729,7 @@ impl Renderable for ComposerWidget<'_> {
self.app.composer_display_input(),
width,
self.max_height.min(self.max_height_cap()),
self.active_menu_row_count(),
self.active_menu_reserved_rows(),
self.app.composer_density,
self.app.composer_border,
)
@@ -708,8 +740,10 @@ impl Renderable for ComposerWidget<'_> {
let input_text = self.app.composer_display_input();
let input_cursor = self.app.composer_display_cursor();
let content_width = usize::from(inner_area.width.max(1));
// Match the render path's locked-budget calculation so the cursor
// lands on the same row the input is drawn on.
let input_rows_budget =
composer_input_rows_budget(inner_area.height, self.active_menu_row_count());
composer_input_rows_budget(inner_area.height, self.active_menu_reserved_rows());
let (visible_lines, cursor_row, cursor_col) =
layout_input(input_text, input_cursor, content_width, input_rows_budget);
@@ -2060,6 +2094,45 @@ mod tests {
assert_eq!(widget.cursor_pos(area), Some((1, 2)));
}
#[test]
fn slash_menu_open_locks_composer_height_against_match_count_changes() {
// Repro for the Windows 10 PowerShell + WSL feedback: typing
// through a slash command shrinks the matched-entry list, which
// used to shrink the composer height — and shrinking the
// composer forces the chat area above to repaint every
// keystroke. With the height lock, the desired height returned
// for a 5-match menu and a 1-match menu must be identical so
// the layout stays stable for the lifetime of the slash session.
let mut app = create_test_app();
app.composer_density = ComposerDensity::Comfortable;
app.input = "/skill".to_string();
let many_matches: Vec<String> = (0..5).map(|i| format!("/skill{i}")).collect();
let one_match = vec!["/skill".to_string()];
let no_matches = Vec::<String>::new();
let widget_many = ComposerWidget::new(&app, 9, &many_matches, &[]);
let widget_one = ComposerWidget::new(&app, 9, &one_match, &[]);
let widget_none = ComposerWidget::new(&app, 9, &no_matches, &[]);
// Fixed worst-case envelope while the slash menu is open.
let height_many = widget_many.desired_height(40);
let height_one = widget_one.desired_height(40);
assert_eq!(
height_many, height_one,
"slash menu height must not jitter as the matched-entry count changes"
);
// Sanity: closing the slash menu (no matches) lets the panel
// collapse back to a tight composer — we only want to lock
// height *while* the menu is open.
let height_none = widget_none.desired_height(40);
assert!(
height_none < height_many,
"with the menu closed the composer should release the reserved rows; got {height_none} vs locked {height_many}"
);
}
#[test]
fn empty_composer_cursor_uses_full_area_when_border_disabled() {
let mut app = create_test_app();