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:
@@ -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).
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user