mirror of
https://github.com/openai/codex.git
synced 2026-11-15 21:50:46 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user