From 87887da6e5cda6bf6badf83dde2bc2808ce89108 Mon Sep 17 00:00:00 2001 From: zuohaisu Date: Sat, 17 Oct 2026 16:08:45 +0800 Subject: [PATCH] fix: keep TUI selections, overlays, and splits on screen Lists followed the index without moving the viewport, overlays were painted under the status bar, and wide splits saturated a u16 multiply. A refused split on a lone browser also left the replacement terminal behind. Co-authored-by: Cursor --- Cargo.lock | 1 + crates/viva/Cargo.toml | 1 + crates/viva/src/tui/chrome.rs | 44 ++- crates/viva/src/tui/conversations/mod.rs | 71 ++++- crates/viva/src/tui/layout.rs | 160 ++++------ crates/viva/src/tui/mod.rs | 159 ++++++++-- crates/viva/src/tui/text.rs | 85 ++++++ crates/viva/src/tui/workbench/mod.rs | 372 ++++++++++++++++++++--- 8 files changed, 695 insertions(+), 198 deletions(-) create mode 100644 crates/viva/src/tui/text.rs diff --git a/Cargo.lock b/Cargo.lock index 3562e50..75a6e07 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1758,6 +1758,7 @@ dependencies = [ "thiserror 2.0.21", "time", "unicode-normalization", + "unicode-width", "uuid", "vt100", "vte", diff --git a/crates/viva/Cargo.toml b/crates/viva/Cargo.toml index 9288e05..1182c6c 100644 --- a/crates/viva/Cargo.toml +++ b/crates/viva/Cargo.toml @@ -38,6 +38,7 @@ ratatui = "0.30.2" semver = "1.0.28" sha2 = "0.10.9" unicode-normalization = "0.1.24" +unicode-width = "0.2.2" tempfile = "3.27.0" flate2 = "1" tar = "0.4" diff --git a/crates/viva/src/tui/chrome.rs b/crates/viva/src/tui/chrome.rs index f0174c5..b1f7003 100644 --- a/crates/viva/src/tui/chrome.rs +++ b/crates/viva/src/tui/chrome.rs @@ -56,6 +56,17 @@ pub fn inner_rect(rect: Rect) -> Rect { } } +/// A border plus one content cell needs three rows and three columns. +/// Smaller rects stay full-bleed: drawing the frame would leave a content +/// height of zero. +pub fn content_rect(rect: Rect) -> Rect { + if rect.width >= 3 && rect.height >= 3 { + inner_rect(rect) + } else { + rect + } +} + /// Draw the merged border grid for all pane rects. `focused` is the index /// of the focused pane (its border cells get the accent color); titles are /// stamped onto the named panes' top border rows. @@ -72,8 +83,9 @@ pub fn render_pane_borders( } let mut cells: HashMap<(u16, u16), CellFlags> = HashMap::new(); for (index, rect) in rects.iter().enumerate() { - // Too small to hold a box: skip (the content still renders). - if rect.width < 2 || rect.height < 2 { + // Too small to hold a border plus one content cell: skip. The + // content pass draws the full rect instead of an empty frame. + if rect.width < 3 || rect.height < 3 { continue; } let focused = focused == Some(index); @@ -135,30 +147,16 @@ pub fn render_pane_borders( } else { palette.overlay1 }); - let budget = rect.width.saturating_sub(4) as usize; // corners + padding - // Truncate with an ellipsis instead of cutting mid-word: the - // reported/screen labels in titles are an honesty boundary (QA P3) - // and must not silently lose their closing paren. - let full: String = format!(" {} ", title.text); - let text: String = if full.chars().count() > budget { - format!( - "{}…", - full.chars() - .take(budget.saturating_sub(1)) - .collect::() - ) + let budget = usize::from(rect.width.saturating_sub(4)); // corners + padding + // Truncate by display width. A CJK title counted in scalars + // overruns the border and collides with the next pane. + let full = format!(" {} ", title.text); + let text = if crate::tui::text::display_width(&full) > budget { + crate::tui::text::truncate_end(&full, budget) } else { full }; - for (offset, ch) in text.chars().enumerate() { - let x = rect.x + 1 + offset as u16; - if x >= rect.x + rect.width - 1 { - break; - } - if let Some(cell) = buffer.cell_mut(Position::new(x, rect.y)) { - cell.set_char(ch).set_style(style); - } - } + buffer.set_stringn(rect.x + 1, rect.y, text, budget, style); } } diff --git a/crates/viva/src/tui/conversations/mod.rs b/crates/viva/src/tui/conversations/mod.rs index 05132c6..09319a3 100644 --- a/crates/viva/src/tui/conversations/mod.rs +++ b/crates/viva/src/tui/conversations/mod.rs @@ -86,10 +86,19 @@ impl ConversationTreeApp { pub fn draw(&self, frame: &mut Frame) { let chunks = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(frame.area()); + let framed = chunks[0].width >= 3 && chunks[0].height >= 3; + let slots = if framed { + usize::from(chunks[0].height.saturating_sub(2)) + } else { + usize::from(chunks[0].height) + }; + let start = super::text::scroll_to_reveal(self.rows.len(), self.selected, slots); let items: Vec = self .rows .iter() .enumerate() + .skip(start) + .take(slots) .map(|(i, row)| { let marker = if self.selected == i { "▶ " } else { " " }; let indent = " ".repeat(row.depth); @@ -119,14 +128,14 @@ impl ConversationTreeApp { ])) }) .collect(); - frame.render_widget( - List::new(items).block( - Block::new() - .title(" conversations — display name · harness · native ref · task ") - .borders(Borders::ALL), - ), - chunks[0], - ); + let block = if framed { + Block::new() + .title(" conversations — display name · harness · native ref · task ") + .borders(Borders::ALL) + } else { + Block::new() + }; + frame.render_widget(List::new(items).block(block), chunks[0]); frame.render_widget( Paragraph::new(Line::from(self.status_line.clone()).gray()), chunks[1], @@ -212,6 +221,52 @@ mod tests { ); } + #[test] + fn tree_scrolls_the_selected_row_into_view() { + let mut rows = vec![ConversationRow { + node_id: "root".into(), + display_name: "CNODE".into(), + depth: 0, + harness: "pi".into(), + native_ref: None, + task_id: None, + archived: false, + }]; + for i in 1..40 { + rows.push(ConversationRow { + node_id: format!("n{i}"), + display_name: if i == 1 { + "CROW01".into() + } else if i == 39 { + "DEEPNAME".into() + } else { + format!("row{i:02}") + }, + depth: 1, + harness: "pi".into(), + native_ref: None, + task_id: None, + archived: false, + }); + } + let mut app = ConversationTreeApp::new(); + app.set_rows(rows); + for _ in 0..39 { + app.on_key(KeyEvent::from(KeyCode::Char('j'))); + } + let mut terminal = Terminal::new(TestBackend::new(80, 24)).expect("terminal"); + terminal.draw(|frame| app.draw(frame)).expect("draw"); + let view = terminal.backend().to_string(); + assert!( + view.contains("DEEPNAME"), + "the selected leaf must be on screen: {view}" + ); + assert!( + !view.contains("CNODE"), + "the window must leave the first page: {view}" + ); + } + #[test] fn navigation_stays_within_the_tree() { let mut app = ConversationTreeApp::new(); diff --git a/crates/viva/src/tui/layout.rs b/crates/viva/src/tui/layout.rs index 923c2ca..b83b495 100644 --- a/crates/viva/src/tui/layout.rs +++ b/crates/viva/src/tui/layout.rs @@ -76,6 +76,47 @@ pub enum Direction { Down, } +/// Divide `area` by `ratio` (first-child percent, clamped 10..=90). +/// +/// The product is computed in `u32`. `u16` multiplication saturates at +/// 65535, so a 1374-column pane at 50% used to draw 655 columns while the +/// mouse divider, which already used `u32`, sat on column 687. +fn split_pair(area: Rect, axis: SplitAxis, ratio: u16) -> (Rect, Rect) { + let ratio = u32::from(ratio.clamp(10, 90)); + match axis { + SplitAxis::Horizontal => { + let first_width = ((u32::from(area.width) * ratio) / 100) as u16; + let second_width = area.width.saturating_sub(first_width); + ( + Rect { + width: first_width, + ..area + }, + Rect { + x: area.x.saturating_add(first_width), + width: second_width, + ..area + }, + ) + } + SplitAxis::Vertical => { + let first_height = ((u32::from(area.height) * ratio) / 100) as u16; + let second_height = area.height.saturating_sub(first_height); + ( + Rect { + height: first_height, + ..area + }, + Rect { + y: area.y.saturating_add(first_height), + height: second_height, + ..area + }, + ) + } + } +} + /// First/second split ratio as a percentage for the FIRST child /// (10..=90), so a user can never squeeze a pane to zero. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -200,39 +241,7 @@ impl PaneNode { second, ratio, } => { - let ratio = (*ratio).clamp(10, 90); - let (first_rect, second_rect) = match axis { - SplitAxis::Horizontal => { - let first_width = area.width.saturating_mul(ratio) / 100; - let second_width = area.width.saturating_sub(first_width); - ( - Rect { - width: first_width, - ..area - }, - Rect { - x: area.x + first_width, - width: second_width, - ..area - }, - ) - } - SplitAxis::Vertical => { - let first_height = area.height.saturating_mul(ratio) / 100; - let second_height = area.height.saturating_sub(first_height); - ( - Rect { - height: first_height, - ..area - }, - Rect { - y: area.y + first_height, - height: second_height, - ..area - }, - ) - } - }; + let (first_rect, second_rect) = split_pair(area, *axis, *ratio); let mut all = first.render_layout(first_rect); all.extend(second.render_layout(second_rect)); all @@ -255,39 +264,7 @@ impl PaneNode { second, ratio, } => { - let ratio = (*ratio).clamp(10, 90); - let (first_rect, second_rect) = match axis { - SplitAxis::Horizontal => { - let first_width = area.width.saturating_mul(ratio) / 100; - let second_width = area.width.saturating_sub(first_width); - ( - Rect { - width: first_width, - ..area - }, - Rect { - x: area.x + first_width, - width: second_width, - ..area - }, - ) - } - SplitAxis::Vertical => { - let first_height = area.height.saturating_mul(ratio) / 100; - let second_height = area.height.saturating_sub(first_height); - ( - Rect { - height: first_height, - ..area - }, - Rect { - y: area.y + first_height, - height: second_height, - ..area - }, - ) - } - }; + let (first_rect, second_rect) = split_pair(area, *axis, *ratio); let mut all = first.render_layout_chrome(first_rect); let mut second_leaves = second.render_layout_chrome(second_rect); // Steal the shared divider cell from the first side's edge @@ -357,16 +334,10 @@ impl PaneNode { else { return; }; - let ratio = (*ratio).clamp(10, 90) as u32; + let (first_rect, second_rect) = split_pair(area, *axis, *ratio); let (first_len, second_len) = match axis { - SplitAxis::Horizontal => { - let first_len = (area.width as u32 * ratio / 100) as u16; - (first_len, area.width.saturating_sub(first_len)) - } - SplitAxis::Vertical => { - let first_len = (area.height as u32 * ratio / 100) as u16; - (first_len, area.height.saturating_sub(first_len)) - } + SplitAxis::Horizontal => (first_rect.width, second_rect.width), + SplitAxis::Vertical => (first_rect.height, second_rect.height), }; // A degenerate side has no divider cell between the siblings — // nothing to grab (the chrome skips these rects too). @@ -410,30 +381,6 @@ impl PaneNode { hit_rect, path: path.clone(), }); - let (first_rect, second_rect) = match axis { - SplitAxis::Horizontal => ( - Rect { - width: first_len, - ..area - }, - Rect { - x: area.x + first_len, - width: second_len, - ..area - }, - ), - SplitAxis::Vertical => ( - Rect { - height: first_len, - ..area - }, - Rect { - y: area.y + first_len, - height: second_len, - ..area - }, - ), - }; let mut first_path = path.clone(); first_path.push(false); let mut second_path = path; @@ -584,6 +531,21 @@ mod tests { } } + #[test] + fn wide_half_split_matches_the_mouse_divider() { + // 1400 columns with a 26-column sidebar leaves a 1374-column pane + // area. 50% is 687. u16 multiplication saturates at 65535 and used + // to draw 655 while the divider sat at 687. + let tree = PaneNode::split_new(PaneContent::Browser, SplitAxis::Horizontal, term("t")); + let area = Rect::new(26, 1, 1374, 40); + let drawn = tree.render_layout(area); + assert_eq!(drawn[0].1.width, 687); + assert_eq!(drawn[1].1.width, 1374 - 687); + let chrome = tree.render_layout_chrome(area); + assert_eq!(chrome[0].1.width, 687, "chrome uses the same split"); + assert_eq!(tree.splits(area)[0].pos, 26 + 687 - 1); + } + #[test] fn render_pass_assigns_disjoint_adjacent_rects() { let tree = sample_tree(); diff --git a/crates/viva/src/tui/mod.rs b/crates/viva/src/tui/mod.rs index 070aca2..b5bdaef 100644 --- a/crates/viva/src/tui/mod.rs +++ b/crates/viva/src/tui/mod.rs @@ -32,6 +32,7 @@ use crate::foundation::error::Result; pub mod chrome; pub mod conversations; pub mod layout; +mod text; pub mod theme; pub mod workbench; @@ -255,20 +256,30 @@ impl TuiApp { ]) .split(frame.area()); - // Header: the product name + which pane is focused. + // The focus tag stays on screen when the product name is wider than + // the terminal. It occupies its own columns on the right. + let tag = match self.focus { + Focus::Overview => "[overview]", + Focus::Tasks => "[tasks]", + Focus::Terminals => "[terminals]", + }; + let tag_cols = text::display_width(tag).min(chunks[0].width as usize) as u16; + let head = + Layout::horizontal([Constraint::Min(0), Constraint::Length(tag_cols)]).split(chunks[0]); + let brand = " viva"; + let rest_budget = (head[0].width as usize).saturating_sub(text::display_width(brand)); let header = Line::from(vec![ - Span::styled(" viva", ratatui::style::Style::new().bold()), - Span::raw(" — personal ai collaboration system "), - Span::styled( - match self.focus { - Focus::Overview => "[overview]", - Focus::Tasks => "[tasks]", - Focus::Terminals => "[terminals]", - }, - ratatui::style::Style::new().bold(), - ), + Span::styled(brand, ratatui::style::Style::new().bold()), + Span::raw(text::truncate_end( + " — personal ai collaboration system", + rest_budget, + )), ]); - frame.render_widget(Paragraph::new(header), chunks[0]); + frame.render_widget(Paragraph::new(header), head[0]); + frame.render_widget( + Paragraph::new(Span::styled(tag, ratatui::style::Style::new().bold())), + head[1], + ); match self.focus { Focus::Overview => self.draw_overview(frame, chunks[1]), @@ -313,12 +324,8 @@ impl TuiApp { ListItem::new(Line::from(format!("{marker}project: {name}"))) }) .collect(); - let members_list = - List::new(members).block(Block::new().title(" members ").borders(Borders::ALL)); - frame.render_widget(members_list, rows[0]); - let projects_list = - List::new(projects).block(Block::new().title(" projects ").borders(Borders::ALL)); - frame.render_widget(projects_list, rows[1]); + paint_list(frame, rows[0], " members ", members, self.selected); + paint_list(frame, rows[1], " projects ", projects, self.selected); } fn draw_tasks(&self, frame: &mut Frame, area: Rect) { @@ -336,12 +343,7 @@ impl TuiApp { ])) }) .collect(); - let list = List::new(items).block( - Block::new() - .title(" tasks — goal · status ") - .borders(Borders::ALL), - ); - frame.render_widget(list, area); + paint_list(frame, area, " tasks — goal · status ", items, self.selected); } fn draw_terminals(&self, frame: &mut Frame, area: Rect) { @@ -366,12 +368,13 @@ impl TuiApp { ))) }) .collect(); - let list = List::new(items).block( - Block::new() - .title(" terminals — purpose · owner · state · worktree ") - .borders(Borders::ALL), + paint_list( + frame, + area, + " terminals — purpose · owner · state · worktree ", + items, + self.selected, ); - frame.render_widget(list, area); } fn draw_help(&self, frame: &mut Frame, area: Rect) { @@ -398,7 +401,8 @@ impl TuiApp { } fn centered_rect(area: Rect, percent_x: u16, height: u16) -> Rect { - let width = area.width * percent_x / 100; + // u32: 60% of a 1600-column terminal overflows u16 in debug builds. + let width = (u32::from(area.width) * u32::from(percent_x) / 100) as u16; let x = area.x + (area.width.saturating_sub(width)) / 2; let y = area.y + area.height.saturating_sub(height) / 2; Rect { @@ -409,6 +413,26 @@ fn centered_rect(area: Rect, percent_x: u16, height: u16) -> Rect { } } +/// Draw `items` with the selected row inside the viewport. A rect shorter +/// than a framed list (border + one content row + border) drops the frame +/// so the row still lands in the buffer. +fn paint_list(frame: &mut Frame, area: Rect, title: &str, items: Vec, selected: usize) { + let framed = area.width >= 3 && area.height >= 3; + let slots = if framed { + usize::from(area.height.saturating_sub(2)) + } else { + usize::from(area.height) + }; + let start = text::scroll_to_reveal(items.len(), selected, slots); + let visible: Vec = items.into_iter().skip(start).take(slots).collect(); + let block = if framed { + Block::new().title(title).borders(Borders::ALL) + } else { + Block::new() + }; + frame.render_widget(List::new(visible).block(block), area); +} + // --------------------------------------------------------------------------- // Run loop pieces (composition-friendly; TestBackend-driven in tests) // --------------------------------------------------------------------------- @@ -719,4 +743,79 @@ mod tests { "typing text is not shell navigation; agent keys belong to terminals" ); } + + #[test] + fn task_list_scrolls_the_selected_row_into_view() { + let mut fixture = Fixture::sample(); + fixture.tasks = (0..40) + .map(|i| TaskRow { + task_id: format!("t{i}"), + goal: format!("OGOAL{i:02}"), + status: "open".into(), + }) + .collect(); + let mut app = TuiApp::new(fixture); + app.refresh(); + app.on_key(KeyEvent::from(KeyCode::Char('2'))); + for _ in 0..39 { + app.on_key(KeyEvent::from(KeyCode::Char('j'))); + } + let frame = render(&app, 80, 24); + assert!( + frame.contains("OGOAL39"), + "the selected task must be on screen: {frame}" + ); + assert!( + !frame.contains("OGOAL00"), + "the window must leave the first page: {frame}" + ); + let short = render(&app, 40, 10); + assert!(short.contains("OGOAL39"), "{short}"); + } + + #[test] + fn narrow_header_keeps_the_focus_tag_and_a_short_list_keeps_a_body_row() { + let mut app = TuiApp::new(Fixture::sample()); + app.refresh(); + let frame = render(&app, 20, 5); + assert!( + frame.contains("[overview]"), + "the focus tag survives a narrow title: {frame}" + ); + assert!( + frame.contains("project:"), + "a 5-row overview still draws the project list: {frame}" + ); + app.on_key(KeyEvent::from(KeyCode::Char('2'))); + let tasks = render(&app, 40, 10); + assert!(tasks.contains("[tasks]"), "{tasks}"); + } + + #[test] + fn help_box_width_does_not_overflow_on_a_wide_terminal() { + let mut app = TuiApp::new(Fixture::sample()); + app.refresh(); + app.on_key(KeyEvent::from(KeyCode::Char('?'))); + let backend = TestBackend::new(1600, 40); + let mut terminal = Terminal::new(backend).expect("terminal"); + terminal.draw(|frame| app.draw(frame)).expect("draw"); + let buffer = terminal.backend().buffer().clone(); + let mut column = None; + for y in 0..40 { + let mut row = String::new(); + for x in 0..1600 { + row.push_str(buffer[(x, y)].symbol()); + } + if let Some(index) = row.find("viva shell") { + column = Some(index); + break; + } + } + let column = column.expect("help text"); + // 60% of 1600 is 960, centered at x = 320. The title sits just inside. + assert!( + (300..400).contains(&column), + "help box column {column} is not the 60% width" + ); + } } diff --git a/crates/viva/src/tui/text.rs b/crates/viva/src/tui/text.rs new file mode 100644 index 0000000..2d56d8f --- /dev/null +++ b/crates/viva/src/tui/text.rs @@ -0,0 +1,85 @@ +//! Display-width text and list windows shared by the shell surfaces. +//! +//! Truncation and scrolling count terminal columns, not Unicode scalars. +//! A CJK character or emoji is two columns; cutting by `chars().take` +//! overflows the cell it was supposed to stay inside. + +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + +pub(crate) fn display_width(text: &str) -> usize { + UnicodeWidthStr::width(text) +} + +/// Keep a prefix whose display width is at most `max_width`. When the text +/// does not fit, the last column is an ellipsis. +pub(crate) fn truncate_end(text: &str, max_width: usize) -> String { + if display_width(text) <= max_width { + return text.to_string(); + } + if max_width == 0 { + return String::new(); + } + let ellipsis = "…"; + let ellipsis_width = display_width(ellipsis); + if max_width <= ellipsis_width { + return ellipsis.to_string(); + } + let prefix = take_prefix_width(text, max_width - ellipsis_width); + format!("{prefix}{ellipsis}") +} + +pub(crate) fn take_prefix_width(text: &str, max_width: usize) -> String { + let mut output = String::new(); + let mut width = 0usize; + for ch in text.chars() { + let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); + if ch_width == 0 { + output.push(ch); + continue; + } + if width + ch_width > max_width { + break; + } + output.push(ch); + width += ch_width; + } + output +} + +/// First row index so `selected` sits inside a window of `height` rows. +/// The window follows the selection: `j` to the last item brings that item +/// on screen instead of leaving the viewport on the first page. +pub(crate) fn scroll_to_reveal(len: usize, selected: usize, height: usize) -> usize { + if height == 0 || len == 0 { + return 0; + } + let selected = selected.min(len - 1); + let max_start = len.saturating_sub(height); + selected + .saturating_sub(height.saturating_sub(1)) + .min(max_start) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn truncate_end_counts_cjk_and_emoji_columns() { + let text = truncate_end("提交 herdr 的反馈", 16); + assert_eq!(text, "提交 herdr 的反…"); + assert!(display_width(&text) <= 16); + + let marked = truncate_end("BRSEL项目🎉项目🎉项目", 10); + assert!(display_width(&marked) <= 10, "{marked}"); + assert!(marked.ends_with('…')); + } + + #[test] + fn scroll_to_reveal_keeps_the_selected_row_in_the_window() { + assert_eq!(scroll_to_reveal(60, 0, 20), 0); + assert_eq!(scroll_to_reveal(60, 59, 20), 40); + assert_eq!(scroll_to_reveal(40, 39, 40), 0); + assert_eq!(scroll_to_reveal(10, 9, 0), 0); + } +} diff --git a/crates/viva/src/tui/workbench/mod.rs b/crates/viva/src/tui/workbench/mod.rs index 6ad5789..9e13490 100644 --- a/crates/viva/src/tui/workbench/mod.rs +++ b/crates/viva/src/tui/workbench/mod.rs @@ -70,10 +70,77 @@ const DOUBLE_CLICK_WINDOW: std::time::Duration = std::time::Duration::from_milli const MIN_PANE_WIDTH: u16 = 8; const MIN_PANE_HEIGHT: u16 = 3; -/// Clip `text` to `max` characters (display width is approximated by char -/// count; the sidebar rows are ASCII in practice). +/// Clip `text` to `max` terminal columns. Scalars are not columns: a CJK +/// character or emoji is wider than one cell. fn fit(text: &str, max: usize) -> String { - text.chars().take(max).collect() + crate::tui::text::truncate_end(text, max) +} + +/// Workspaces, agents, and tasks. Each band is at least three rows when the +/// sidebar is tall enough; a short sidebar splits the rows it has. +fn sidebar_bands(area: Rect) -> [Rect; 3] { + let heights = sidebar_section_heights(area.height); + let mut y = area.y; + let mut bands = [Rect::default(); 3]; + for (band, height) in bands.iter_mut().zip(heights) { + *band = Rect::new(area.x, y, area.width, height); + y = y.saturating_add(height); + } + bands +} + +fn sidebar_section_heights(total: u16) -> [u16; 3] { + if total == 0 { + return [0, 0, 0]; + } + // Spaces keep the upper half, which is what the two-band sidebar used. + // Agents and tasks share the lower half so the task list is on screen + // without scrolling the worktree list off the top. + if total >= 6 { + let top = total / 2; + let rest = total - top; + let agents = rest / 2; + let tasks = rest - agents; + return [top, agents, tasks]; + } + let base = total / 3; + let extra = total % 3; + [ + base + u16::from(extra > 0), + base + u16::from(extra > 1), + base, + ] +} + +/// Scroll so the selected entity's first line is in the window. A two-line +/// block that is taller than the viewport keeps the name, not the detail +/// line that follows it. +fn sidebar_reveal_offset( + section: &[(Line<'static>, Option<(Focus, usize)>)], + selected: Option<(Focus, usize)>, + height: usize, +) -> usize { + if height == 0 || section.is_empty() { + return 0; + } + let Some(selected) = selected else { + return 0; + }; + let Some(first) = section.iter().position(|(_, key)| *key == Some(selected)) else { + return 0; + }; + let last = section + .iter() + .rposition(|(_, key)| *key == Some(selected)) + .unwrap_or(first); + let block = last - first + 1; + let max_start = section.len().saturating_sub(height); + let start = if block >= height { + first + } else { + (last + 1).saturating_sub(height).min(first) + }; + start.min(max_start) } // --------------------------------------------------------------------------- @@ -1337,6 +1404,7 @@ impl WorkbenchApp { pub fn split_pane(&mut self, axis: SplitAxis, terminal_id: String) -> bool { self.layout_dirty = true; let content = PaneContent::Terminal(terminal_id); + let replaced_browser = self.grid == PaneNode::leaf(PaneContent::Browser); if self.grid.split(&self.pane_focus, axis, content.clone()) { if let Some(area) = self.last_pane_area { // Every leaf must stay usable — not just the new one: the @@ -1348,8 +1416,15 @@ impl WorkbenchApp { .iter() .any(|(_, r)| r.width < MIN_PANE_WIDTH || r.height < MIN_PANE_HEIGHT); if too_small { - // Undo: the new leaf collapses back into its sibling. - self.grid.close(&content); + // Undo. A lone browser is replaced in place rather than + // split, and close() cannot remove the root leaf — put + // the browser back, or the refusal leaves a terminal. + if replaced_browser { + self.grid = PaneNode::leaf(PaneContent::Browser); + self.pane_focus = PaneContent::Browser; + } else { + self.grid.close(&content); + } self.set_status(format!( "pane too small to split at this terminal size (min {MIN_PANE_WIDTH}×{MIN_PANE_HEIGHT})" )); @@ -3302,7 +3377,7 @@ impl WorkbenchApp { self.pane_geometry.clear(); for (index, (content, rect)) in layout.iter().enumerate() { let content_area = if layout.len() >= 2 { - crate::tui::chrome::inner_rect(*rect) + crate::tui::chrome::content_rect(*rect) } else { rects[index] }; @@ -3354,12 +3429,6 @@ impl WorkbenchApp { } } - if self.file_panel.is_some() { - self.draw_file_panel(frame); - } - if self.handoff_picker.is_some() { - self.draw_handoff_picker(frame); - } if self.show_key_help { self.draw_key_help(frame, main, &palette); } @@ -3376,6 +3445,14 @@ impl WorkbenchApp { self.draw_pane_prompt(frame, &palette); } self.draw_status_bar(frame, rows[2], &palette); + // File and handoff overlays cover the status bar. Drawn earlier, + // the bar replaces their bottom border on a short terminal. + if self.file_panel.is_some() { + self.draw_file_panel(frame); + } + if self.handoff_picker.is_some() { + self.draw_handoff_picker(frame); + } if let Some(selected) = self.terminal_picker { let area = centered(frame.area(), 90, frame.area().height.saturating_sub(4)); frame.render_widget(Clear, area); @@ -4073,7 +4150,7 @@ impl WorkbenchApp { ratatui::style::Style::new().fg(dot_color), ), Span::styled( - format!(" {}", fit(&worktree.branch, width.saturating_sub(5))), + format!(" {}", fit(&worktree.branch, width.saturating_sub(6))), ratatui::style::Style::new().fg(palette.mauve), ), ], @@ -4238,30 +4315,29 @@ impl WorkbenchApp { )); } - // Two stable regions; Tasks are an explicit alternate lower view. - let regions = - Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)]).split(area); + // Three bands, all drawn. Tasks used to replace agents, and nothing + // could focus that hidden list, so the goals never appeared. + let regions = sidebar_bands(area); self.sidebar_hits.clear(); for (range, rect) in [ (0..agent_start, regions[0]), - ( - if self.focus == Focus::Tasks { - task_start..lines.len() - } else { - agent_start..task_start - }, - regions[1], - ), + (agent_start..task_start, regions[1]), + (task_start..lines.len(), regions[2]), ] { + if rect.height == 0 || rect.width == 0 { + continue; + } let section = &lines[range]; let height = rect.height as usize; let selected_key = Some((self.focus, self.selected)); - let last = section - .iter() - .rposition(|(_, key)| *key == selected_key) - .unwrap_or(0); - let offset = (last + 1).saturating_sub(height); + let offset = sidebar_reveal_offset(section, selected_key, height); let mut visible = Vec::new(); + // The last column is the separator. Text stays in the columns + // before it, or a wide character overwrites the line. + let text_rect = Rect { + width: rect.width.saturating_sub(1), + ..rect + }; for (y, (line, key)) in section.iter().skip(offset).take(height).enumerate() { visible.push(line.clone()); if let Some((focus, index)) = key { @@ -4272,7 +4348,7 @@ impl WorkbenchApp { )); } } - frame.render_widget(Paragraph::new(visible), rect); + frame.render_widget(Paragraph::new(visible), text_rect); } // herdr's sidebar separator: one dim vertical line on the right. let buffer = frame.buffer_mut(); @@ -4439,27 +4515,46 @@ impl WorkbenchApp { } /// One sidebar row: pad with the selection background when selected so - /// the highlight fills the row. + /// the highlight fills the row. The last column is reserved for the + /// separator, and the pad counts display columns. fn row( - mut spans: Vec>, + spans: Vec>, selected: bool, width: usize, palette: &Palette, ) -> Line<'static> { + let limit = width.saturating_sub(1); + let mut clipped = Vec::new(); + let mut used = 0usize; + for span in spans { + if used >= limit { + break; + } + let raw = span.content.as_ref(); + let room = limit - used; + let text = if crate::tui::text::display_width(raw) <= room { + raw.to_string() + } else { + fit(raw, room) + }; + used += crate::tui::text::display_width(&text); + if !text.is_empty() { + clipped.push(Span::styled(text, span.style)); + } + } if selected { - for span in spans.iter_mut() { + for span in clipped.iter_mut() { span.style = span.style.bg(palette.selection_bg); } - let used: usize = spans.iter().map(|s| s.content.chars().count()).sum(); - let pad = width.saturating_sub(used); + let pad = limit.saturating_sub(used); if pad > 0 { - spans.push(Span::styled( + clipped.push(Span::styled( " ".repeat(pad), ratatui::style::Style::new().bg(palette.selection_bg), )); } } - Line::from(spans) + Line::from(clipped) } /// Task status is a free string from the registry; known words map to @@ -4540,6 +4635,11 @@ impl WorkbenchApp { ); } _ => { + let body = usize::from(area.height.saturating_sub(2)); + let banner = usize::from(panel.truncated); + let slots = body.saturating_sub(banner); + let start = + crate::tui::text::scroll_to_reveal(panel.rows.len(), panel.selected, slots); let mut items: Vec = Vec::new(); if panel.truncated { items.push(ListItem::new(Line::from(Span::styled( @@ -4551,7 +4651,7 @@ impl WorkbenchApp { ratatui::style::Style::new().gray(), )))); } - for (i, (path, status)) in panel.rows.iter().enumerate() { + for (i, (path, status)) in panel.rows.iter().enumerate().skip(start).take(slots) { let marker = if panel.selected == i { "▶ " } else { " " }; items.push(ListItem::new(Line::from(format!( "{marker}{status:<6} {path}" @@ -4579,10 +4679,14 @@ impl WorkbenchApp { }; let area = centered(frame.area(), 50, (picker.agents.len() as u16 + 2).max(5)); frame.render_widget(Clear, area); + let slots = usize::from(area.height.saturating_sub(2)); + let start = crate::tui::text::scroll_to_reveal(picker.agents.len(), picker.selected, slots); let items: Vec = picker .agents .iter() .enumerate() + .skip(start) + .take(slots) .map(|(i, agent)| { let marker = if picker.selected == i { "▶ " } else { " " }; ListItem::new(Line::from(format!("{marker}{agent}"))) @@ -6655,6 +6759,198 @@ mod tests { ); } + #[test] + fn tasks_are_drawn_and_a_click_focuses_them() { + let mut model = sample(); + model.tasks[0].goal = "TASKGOAL".into(); + let mut app = WorkbenchApp::new(); + app.set_model(model); + let frame = render(&mut app, 100, 24); + assert!( + frame.contains("TASKGOAL"), + "the task band is on the default sidebar: {frame}" + ); + assert_eq!(app.focus(), Focus::Worktrees); + let (_, _, rect) = app + .sidebar_hits + .iter() + .find(|(focus, _, _)| *focus == Focus::Tasks) + .cloned() + .expect("a task row is a click target"); + app.on_mouse(ratatui::crossterm::event::MouseEvent { + kind: ratatui::crossterm::event::MouseEventKind::Down( + ratatui::crossterm::event::MouseButton::Left, + ), + column: rect.x, + row: rect.y, + modifiers: ratatui::crossterm::event::KeyModifiers::empty(), + }); + assert_eq!(app.focus(), Focus::Tasks); + } + + #[test] + fn file_and_handoff_lists_follow_the_selection() { + let mut app = WorkbenchApp::new(); + app.set_model(sample()); + app.file_panel = Some(FilePanel { + worktree_id: "wt1".into(), + rows: (0..60).map(|i| (format!("f{i:02}"), "M".into())).collect(), + truncated: false, + total: 60, + selected: 59, + viewing: None, + full_path: None, + scroll: 0, + }); + let files = render(&mut app, 100, 24); + assert!(files.contains("f59"), "{files}"); + assert!(!files.contains("f00"), "{files}"); + let wide = render(&mut app, 200, 50); + assert!(wide.contains("f59"), "{wide}"); + + app.file_panel = None; + app.handoff_picker = Some(HandoffPicker { + worktree_id: "wt1".into(), + agents: (0..50).map(|i| format!("ag{i:02}")).collect(), + selected: 49, + }); + let handoff = render(&mut app, 100, 24); + assert!(handoff.contains("ag49"), "{handoff}"); + assert!(!handoff.contains("ag00"), "{handoff}"); + } + + #[test] + fn overlay_bottom_border_is_not_replaced_by_the_status_bar() { + let mut app = WorkbenchApp::new(); + app.file_panel = Some(FilePanel { + worktree_id: "wt1".into(), + rows: vec![("DIAGFILE".into(), "M".into())], + truncated: false, + total: 1, + selected: 0, + viewing: None, + full_path: None, + scroll: 0, + }); + for (width, height) in [(20, 5), (60, 5)] { + let frame = render(&mut app, width, height); + assert!(frame.contains("files"), "{width}×{height}: {frame}"); + let last = frame.lines().next_back().unwrap_or(""); + assert!( + !last.contains("WORKBENCH") && last.contains('─'), + "{width}×{height} status bar covers the overlay: {frame}" + ); + } + app.file_panel = None; + app.handoff_picker = Some(HandoffPicker { + worktree_id: "wt1".into(), + agents: (0..50).map(|i| format!("ag{i:02}")).collect(), + selected: 49, + }); + let frame = render(&mut app, 40, 10); + let last = frame.lines().next_back().unwrap_or(""); + assert!(last.contains('─'), "handoff bottom border: {frame}"); + assert!(frame.contains("ag49"), "{frame}"); + } + + #[test] + fn refused_split_on_a_lone_browser_restores_it() { + let mut app = WorkbenchApp::new(); + app.set_model(sample()); + for (width, height) in [(20, 5), (60, 5)] { + render(&mut app, width, height); + assert!( + !app.split_pane(SplitAxis::Horizontal, "term-new".into()), + "{width}×{height}" + ); + assert_eq!( + app.grid.leaves(), + vec![PaneContent::Browser], + "{width}×{height} kept the replacement terminal" + ); + assert!( + app.status_line.contains("too small"), + "{width}×{height}: {}", + app.status_line + ); + } + } + + #[test] + fn shrunk_layout_keeps_pane_content() { + let mut app = WorkbenchApp::new(); + app.set_model(sample()); + app.grid = PaneNode::split_new( + PaneContent::Terminal("term-1".into()), + SplitAxis::Horizontal, + PaneContent::Terminal("term-2".into()), + ); + app.set_snapshot("term-1", snapshot_with(&["LEFTPANE"])); + app.set_snapshot("term-2", snapshot_with(&["RIGHTPANE"])); + let frame = render(&mut app, 20, 5); + assert!(frame.contains("LEFTPANE"), "{frame}"); + assert!(frame.contains("RIGHTPANE"), "{frame}"); + + app.grid = PaneNode::split_new( + PaneContent::Terminal("term-1".into()), + SplitAxis::Vertical, + PaneContent::Terminal("term-2".into()), + ); + assert!(app.grid.set_ratio_at_path(&[], 10)); + let frame = render(&mut app, 80, 24); + assert!( + frame.contains("LEFTPANE"), + "a 2-row pane still draws its text: {frame}" + ); + } + + #[test] + fn short_sidebar_shows_the_selected_agent_name() { + use crate::agents::AgentSessionRow; + let mut model = sample(); + model.attention.clear(); + model.agents.push(AgentSessionRow { + terminal_id: "term-1".into(), + tool: "pi".into(), + name: "AGENTMARK".into(), + worktree_id: Some("wt1".into()), + location: "work".into(), + live: Some(true), + records: vec![], + stale: false, + in_workspace: true, + report_capability: "report:unve".into(), + }); + let mut app = WorkbenchApp::new(); + app.set_model(model); + app.focus = Focus::Terminals; + let frame = render(&mut app, 60, 5); + assert!( + frame.contains("AGENTMARK"), + "the name line stays when only one row fits: {frame}" + ); + } + + #[test] + fn wide_branch_name_does_not_erase_the_sidebar_separator() { + let mut model = sample(); + model.attention.clear(); + model.worktrees[0].branch = "BRSEL项目🎉项目🎉项目🎉项目🎉".into(); + let mut app = WorkbenchApp::new(); + app.set_model(model); + let backend = TestBackend::new(60, 5); + let mut terminal = Terminal::new(backend).expect("terminal"); + terminal.draw(|frame| app.draw(frame)).expect("draw"); + let buffer = terminal.backend().buffer(); + for y in 1..4 { + assert_eq!( + buffer[(25, y)].symbol(), + "│", + "row {y} lost the sidebar separator" + ); + } + } + /// F4: the diff overlay closes with Esc (it used to be unclosable) /// and scrolls, so a bounded/truncated diff stays readable. #[test]