Preserve multiline status lines in unfocused chat panes (#61141)

## Why

Unfocused chat panes reduced status content to a single dim line, hiding rows that were visible while focused.

## What changed

- Share status measurement, wrapping, rendering, and footer row allocation between the main conversation and side chats.
- Retain structured status content in inactive side chats so native and extension status lines can use the available rows and reflow when resized.
- Keep unfocused status content dimmed with hyperlinks suppressed, while respecting space reserved for the draft and footer hints.

## Testing

Add regression coverage for status reflow across split-pane focus switches and resizing, and update extension status coverage to verify multiple dimmed rows when unfocused.

GitOrigin-RevId: f4def37259fe106d6a3fac9fe4c6cc2d4972f244
This commit is contained in:
Felipe Coury
2026-11-12 15:09:13 +00:00
committed by copyberry
parent 6aa83e5eee
commit d0edb0b1fa
23 changed files with 572 additions and 339 deletions
+1 -4
View File
@@ -581,10 +581,7 @@ impl App {
// Painting those rows tells us whether newer activity is still hidden,
// which can change the footer hint and the composer's measured height.
let footer = view.footer_with_navigation(footer_width, motion, latest_navigation);
// Allow up to two-thirds of the pane, or eight rows on short panes when they fit.
let max_composer_height = ((u32::from(area.height) * 2 / 3) as u16)
.max(8)
.min(area.height);
let max_composer_height = side_pane::composer_height_limit(area.height);
let unfocused = matches!(
self.conversation_frame.sidebar,
Some(sidebar::SidebarLayout::Chooser {
+1 -1
View File
@@ -57,7 +57,7 @@ impl SideCompanion {
active: None,
auxiliary: Vec::new(),
draft: Box::default(),
status: None,
status_line: None,
work: None,
last_completed_agent_message: None,
cwd,
+29 -17
View File
@@ -7,7 +7,9 @@ use super::sidebar;
use super::sidebar::PaneFocus;
use super::sidebar::SidebarContent;
use super::sidebar::SidebarLayout;
use crate::bottom_pane::render_unfocused_status;
use crate::bottom_pane::FooterLayout;
use crate::bottom_pane::StatusLine;
use crate::bottom_pane::footer_content::StatusLineFocus;
use crate::custom_terminal::Frame;
use crate::legacy_core::config::edit::ConfigEdit;
use crate::legacy_core::config::edit::ConfigEditsBuilder;
@@ -428,6 +430,13 @@ impl App {
}
}
/// Allow up to two-thirds of the pane, or eight rows on short panes when they fit.
pub(super) fn composer_height_limit(pane_height: u16) -> u16 {
((u32::from(pane_height) * 2 / 3) as u16)
.max(8)
.min(pane_height)
}
impl SidePaneLayout {
/// Paint the divider, retained transcript and draft without claiming the terminal cursor.
pub(super) fn paint_companion(
@@ -440,11 +449,19 @@ impl SidePaneLayout {
companion.flush_answer();
let area = self.passive;
// Keep the live composer's bottom hint row blank on the inactive side.
let available = area.height.saturating_sub(1);
let composer_height = available.min(3);
let status_height =
u16::from(companion.display.status.is_some() && available > composer_height);
let above = available - composer_height - status_height;
let hint_rows = 1;
let composer_height = area.height.saturating_sub(hint_rows).min(3);
let available = area.height - composer_height;
let footer = FooterLayout::new(
Some(composer_height_limit(area.height)),
hint_rows,
/*priority_hint_rows*/ hint_rows,
);
let status = companion.display.status_line.as_ref();
let status_line = StatusLine::default();
let wanted_status = status_line.height(status, area.width, footer.status_budget(available));
let footer = footer.allocate(available, wanted_status);
let above = available - footer.height();
let mut transcript = Rect {
height: above.saturating_sub(1),
..area
@@ -495,17 +512,12 @@ impl SidePaneLayout {
&mut Default::default(),
);
}
if let Some(status) = &companion.display.status {
render_unfocused_status(
status.clone(),
Rect {
y: composer.bottom(),
height: status_height,
..area
},
frame.buffer,
);
}
status_line.render(
status,
footer.status_area(area),
frame.buffer,
StatusLineFocus::Unfocused,
);
}
}
+113
View File
@@ -5654,6 +5654,119 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<()
Ok(())
}
#[tokio::test]
async fn split_status_lines_reflow_across_focus_switches() -> Result<()> {
use crate::bottom_pane::StatusLineItem;
use ratatui::style::Modifier;
use ratatui::text::Line;
let (mut app, mut events, _ops) = make_test_app_with_channels().await;
let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
let primary = server.start_thread(&app.config).await?;
let main = primary.session.thread_id;
app.enqueue_primary_thread_session(primary.session, primary.turns)
.await?;
let side = ThreadId::new();
let channel = ThreadEventChannel::new(/*capacity*/ 1);
channel.store.lock().await.set_session(
test_thread_session(side, app.config.cwd.to_path_buf()),
Vec::new(),
);
app.thread_event_channels.insert(side, channel);
app.side_threads.insert(side, SideThreadState::new(main));
app.sidebar_mut().show(
sidebar::SidebarContent::Chat(side),
sidebar::PaneFocus::Main,
);
let mut tui = crate::tui::test_support::make_test_tui()?;
tui.set_owned_screen(/*owned*/ true)?;
drain_app_events(&mut app, &mut tui, &mut server, &mut events).await?;
let main_status = "MainStatusSummaryForThePrimaryWorkspaceContext MainStatusDetailsForThePrimaryWorkspaceContext";
let side_status = "SideStatusSummaryText SideStatusDetails";
let mut frames = Vec::new();
for (index, (focused, width, height)) in [
(main, 100, 24),
(side, 100, 24),
(main, 100, 24),
(main, 130, 24),
]
.into_iter()
.enumerate()
{
if app.current_displayed_thread_id() != Some(focused) {
app.select_agent_thread(&mut tui, &mut server, focused)
.await?;
drain_app_events(&mut app, &mut tui, &mut server, &mut events).await?;
}
assert_eq!(app.current_displayed_thread_id(), Some(focused));
enable_side_retention(&mut app)?;
app.local_settings.tui.animations = false;
let size = Size::new(width, height);
// Resize refreshes the active native status, so install the fixture after reflow.
side_frame(&mut app, &mut tui, size)?;
let (name, draft, status) = if focused == main {
("Main", "main draft", main_status)
} else {
("Side", "side draft", side_status)
};
app.chat_widget.setup_status_line(
vec![StatusLineItem::ModelName],
/*use_theme_colors*/ false,
);
app.chat_widget
.set_status_line(Some(Line::from(status).into()));
app.chat_widget.apply_external_edit(draft.into());
if index == 0 {
// Seed the main conversation before focusing the side chat.
continue;
}
let frame = side_frame(&mut app, &mut tui, size)?;
assert!(
frame.contains("main draft") && frame.contains("side draft"),
"{frame}"
);
let divider = app.conversation_frame.divider_area().expect("split pane");
let buffer = tui.terminal.previous_buffer();
let mut rows = vec![format!("{name} focused at {width}x{height}")];
for (prefix, start, end) in [
("MainStatus", 0, divider.x),
("SideStatus", divider.right(), width),
] {
for y in 0..height {
let text: String = (start..end).map(|x| buffer[(x, y)].symbol()).collect();
if text.trim_start().starts_with(prefix) {
let muted = buffer[(start + 2, y)].modifier.contains(Modifier::DIM);
let style = if muted { "muted" } else { "normal" };
rows.push(format!("{style} {}", text.trim()));
}
}
}
frames.push(rows.join("\n"));
}
insta::assert_snapshot!(frames.join("\n\n"), @"
Side focused at 100x24
muted MainStatusSummaryForThePrimaryWorkspaceContext
muted MainStatusDetailsForThePrimaryWorkspaceContext
normal SideStatusSummaryText
normal SideStatusDetails
Main focused at 100x24
normal MainStatusSummaryForThePrimaryWorkspaceContext
normal MainStatusDetailsForThePrimaryWorkspaceContext
muted SideStatusSummaryText
muted SideStatusDetails
Main focused at 130x24
normal MainStatusSummaryForThePrimaryWorkspaceContext
normal MainStatusDetailsForThePrimaryWorkspaceContext
muted SideStatusSummaryText SideStatusDetails
");
server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn side_close_key_cannot_accept_a_replacing_approval() -> Result<()> {
let mut app = make_test_app().await;