From 0c446bf4c52a31308193173970f2c239e0f595bf Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 23:54:08 -0500 Subject: [PATCH] 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 --- crates/tui/src/tui/ui.rs | 49 ++++++++++++++++++++++++++-------------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 9cb5b5a9..c5d03744 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -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.