fix(tui): pin header to absolute top row with defensive two-pass layout

The header bar was reported to appear vertically centered on macOS
Terminal.app with large blank space above and below. While the existing
Constraint::Min(1) layout with default Flex::Start should place the
header at row 0, some ratatui/flex interactions can produce unexpected
centering on certain terminal sizes.

Restructure the render layout into a defensive two-pass system:
1. First pass: split the terminal into header (Length(1)) + body (Min(1))
   with explicit Flex::Start, pinning the header to the absolute top.
2. Second pass: split the body area for chat, preview, composer, footer.

This guarantees the header is never vertically centered regardless of
ratatui Flex defaults or terminal dimensions.

Fixes #1834
This commit is contained in:
Hunter Bown
2026-05-26 23:54:08 -05:00
parent 72798e5b69
commit 0c446bf4c5
+32 -17
View File
@@ -5863,7 +5863,6 @@ fn render(f: &mut Frame, app: &mut App) {
let header_height = 1;
let footer_height = 1;
let body_height = size.height.saturating_sub(header_height + footer_height);
let slash_menu_entries = visible_slash_menu_entries(app, SLASH_MENU_LIMIT);
let mention_menu_entries =
crate::tui::file_mention::visible_mention_menu_entries(app, MENTION_MENU_LIMIT);
@@ -5871,8 +5870,24 @@ fn render(f: &mut Frame, app: &mut App) {
app.mention_menu_selected = mention_menu_entries.len().saturating_sub(1);
}
let context_usage = context_usage_snapshot(app);
// Defensive two-pass layout: pin the header to the absolute top row,
// then split the remaining body area for chat / preview / composer /
// footer. This guarantees the header is never vertically centered
// regardless of ratatui Flex defaults or terminal size.
// Fixes #1834 — macOS terminal title centering.
let (header_area, body_area) = {
let split = Layout::default()
.direction(Direction::Vertical)
.flex(ratatui::layout::Flex::Start)
.constraints([Constraint::Length(header_height), Constraint::Min(1)])
.split(size);
(split[0], split[1])
};
let body_height = body_area.height;
let composer_max_height = body_height
.saturating_sub(MIN_CHAT_HEIGHT)
.saturating_sub(MIN_CHAT_HEIGHT + footer_height)
.max(MIN_COMPOSER_HEIGHT);
let composer_height = {
let composer_widget = ComposerWidget::new(
@@ -5891,16 +5906,16 @@ fn render(f: &mut Frame, app: &mut App) {
let pending_preview = build_pending_input_preview(app);
let preview_height = pending_preview.desired_height(size.width);
let chunks = Layout::default()
let body_chunks = Layout::default()
.direction(Direction::Vertical)
.flex(ratatui::layout::Flex::Start)
.constraints([
Constraint::Length(header_height), // Header
Constraint::Min(1), // Chat area
Constraint::Length(preview_height), // Pending input preview (0 if empty)
Constraint::Length(composer_height), // Composer
Constraint::Length(footer_height), // Footer
])
.split(size);
.split(body_area);
// Render header
{
@@ -5960,7 +5975,7 @@ fn render(f: &mut Frame, app: &mut App) {
));
let header_widget = HeaderWidget::new(header_data);
let buf = f.buffer_mut();
header_widget.render(chunks[0], buf);
header_widget.render(header_area, buf);
}
// Render chat + sidebar + optional file-tree pane
@@ -5971,19 +5986,19 @@ fn render(f: &mut Frame, app: &mut App) {
// resize) don't retain stale content from a previous frame.
Block::default()
.style(Style::default().bg(app.ui_theme.surface_bg))
.render(chunks[1], f.buffer_mut());
.render(body_chunks[0], f.buffer_mut());
let mut sidebar_area = None;
// When the file-tree pane is visible and the terminal is wide
// enough, reserve the left ~25% for the file tree.
let mut chat_area =
if app.file_tree.is_some() && chunks[1].width >= SIDEBAR_VISIBLE_MIN_WIDTH {
if app.file_tree.is_some() && body_chunks[0].width >= SIDEBAR_VISIBLE_MIN_WIDTH {
app.file_tree_visible = true;
let split = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(25), Constraint::Percentage(75)])
.split(chunks[1]);
.split(body_chunks[0]);
let tree_area = split[0];
let remaining = split[1];
@@ -5995,7 +6010,7 @@ fn render(f: &mut Frame, app: &mut App) {
remaining
} else {
app.file_tree_visible = false;
chunks[1]
body_chunks[0]
};
if let Some(sidebar_width) = sidebar_width_for_chat_area(app, chat_area.width) {
@@ -6047,7 +6062,7 @@ fn render(f: &mut Frame, app: &mut App) {
// Render pending-input preview (queued/steered messages, if any).
if preview_height > 0 {
let buf = f.buffer_mut();
pending_preview.render(chunks[2], buf);
pending_preview.render(body_chunks[1], buf);
}
// Render composer
@@ -6059,12 +6074,12 @@ fn render(f: &mut Frame, app: &mut App) {
&mention_menu_entries,
);
let buf = f.buffer_mut();
composer_widget.render(chunks[3], buf);
composer_widget.cursor_pos(chunks[3])
composer_widget.render(body_chunks[2], buf);
composer_widget.cursor_pos(body_chunks[2])
};
app.viewport.last_composer_area = Some(chunks[3]);
app.viewport.last_composer_area = Some(body_chunks[2]);
{
let area = chunks[3];
let area = body_chunks[2];
let has_panel = app.composer_border && area.height >= 3 && area.width >= 12;
let inner = if has_panel {
ratatui::widgets::Block::default()
@@ -6108,11 +6123,11 @@ fn render(f: &mut Frame, app: &mut App) {
}
// Render footer
render_footer(f, chunks[4], app);
render_footer(f, body_chunks[3], app);
// Toast stack overlay (#439): when multiple status toasts are queued,
// surface the older ones as a 1-2 line strip above the footer so a
// burst of events isn't collapsed to a single visible message.
render_toast_stack_overlay(f, size, chunks[3], chunks[4], app);
render_toast_stack_overlay(f, size, body_chunks[2], body_chunks[3], app);
// Decision card overlay (v0.8.43 truth-surface). When a decision card is
// active, render it centered on top of the transcript.