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;
+1 -1
View File
@@ -317,7 +317,7 @@ pub(crate) struct CapturedSideDisplay {
pub(crate) active: Option<Box<dyn HistoryCell>>,
pub(crate) auxiliary: Vec<crate::terminal_hyperlinks::HyperlinkLine>,
pub(crate) draft: Box<crate::bottom_pane::TextArea>,
pub(crate) status: Option<String>,
pub(crate) status_line: Option<crate::bottom_pane::footer_content::FooterContent>,
pub(crate) work: Option<CapturedSideWork>,
pub(crate) last_completed_agent_message: Option<(String, String)>,
pub(crate) cwd: AbsolutePathBuf,
@@ -380,6 +380,7 @@ use super::slash_commands::ServiceTierCommand;
use super::slash_commands::SlashCommandItem;
use super::voice_strip::VoiceStrip;
use crate::bottom_pane::footer_content::FooterContent;
use crate::bottom_pane::footer_content::StatusLineFocus;
use crate::history_cell::sanitize_user_text;
use crate::key_hint::KeyBindingListExt;
use crate::keymap::EditorKeymap;
@@ -401,7 +402,7 @@ use codex_protocol::user_input::TextElement;
mod agents_navigation;
mod attachment_state;
mod completion_target;
mod composer_layout;
pub(super) mod composer_layout;
mod draft_state;
mod footer_state;
mod history_search;
@@ -413,7 +414,6 @@ mod paste_input;
mod popup_state;
mod reconnect;
pub(crate) use reconnect::RestrictedInputMode;
mod native_status;
mod slash_input;
mod sparkle;
mod status_surface;
@@ -795,8 +795,7 @@ impl ChatComposer {
goal_status_indicator: None,
ide_context_active: false,
status_line_value: None,
measured_status_line: Default::default(),
wrapped_native_status: Default::default(),
status_layout: Default::default(),
status_line_enabled: false,
side_conversation_context_label: None,
active_agent_label: None,
@@ -909,7 +908,7 @@ impl ChatComposer {
let style = IgnitionStyle::random(self.effort_animation_style);
self.effort_ignition = Some(EffortIgnition::new(tier, style));
self.effort_animation_style = Some(style);
if let Some(previous) = self.status_surface_content()
if let Some(previous) = self.status_surface_content(StatusLineFocus::Focused)
&& !matches!(previous.as_ref(), FooterContent::Extension { .. })
{
self.effort_status_line_transition = Some(EffortStatusLineTransition::new(
@@ -8,16 +8,67 @@ use crate::tui::FrameRequester;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
pub(super) const MAX_FOOTER_ROWS: u16 = 4;
struct ComposerRows {
input: u16,
above_input: u16,
thumbnails: u16,
footer: FooterRows,
}
/// Allocates status and hint rows from the space left by the composer.
pub(crate) struct FooterLayout {
cap: u16,
wanted_hints: u16,
reserved_hints: u16,
}
pub(crate) struct FooterRows {
status: u16,
hints: u16,
}
impl FooterLayout {
pub(crate) fn new(
workspace_height: Option<u16>,
wanted_hints: u16,
priority_hint_rows: u16,
) -> Self {
Self {
cap: workspace_height.map_or(2, |height| (height.saturating_add(2) / 4).clamp(2, 4)),
wanted_hints,
reserved_hints: wanted_hints.min(priority_hint_rows),
}
}
pub(crate) fn status_budget(&self, available: u16) -> u16 {
self.cap.min(available).saturating_sub(self.reserved_hints)
}
pub(crate) fn allocate(&self, available: u16, wanted_status: u16) -> FooterRows {
let limit = self.cap.min(available);
let reserved_status = wanted_status.min(1).min(self.status_budget(available));
let hints = self.wanted_hints.min(limit - reserved_status);
FooterRows {
status: wanted_status.min(limit - hints),
hints,
}
}
}
impl FooterRows {
pub(crate) fn height(&self) -> u16 {
self.status + self.hints
}
pub(crate) fn status_area(&self, area: Rect) -> Rect {
Rect {
y: area.bottom().saturating_sub(self.height()).max(area.y),
height: self.status,
..area
}
}
}
impl ChatComposer {
pub(crate) fn set_voice_strip(
&mut self,
@@ -54,21 +105,13 @@ impl ChatComposer {
let ComposerRows {
above_input,
thumbnails: thumbnail_height,
status: status_height,
hints: footer_hint_height,
footer,
..
} = self.composer_rows(area.width, area.height, options);
let status = Rect {
y: area
.bottom()
.saturating_sub(footer_hint_height)
.saturating_sub(status_height)
.max(area.y),
height: status_height,
..area
};
let status = footer.status_area(area);
let footer_hint_height = footer.hints;
let area = Rect {
height: area.height - status_height,
height: area.height - status.height,
..area
};
let voice_rows = self
@@ -186,8 +229,7 @@ impl ChatComposer {
rows.input,
rows.above_input,
rows.thumbnails,
rows.status,
rows.hints,
rows.footer.height(),
]
.into_iter()
.fold(0, u16::saturating_add)
@@ -241,25 +283,22 @@ impl ChatComposer {
let mut available = height
.saturating_sub(input_height)
.max(u16::from(editing_hint && height > 3));
let cap = if options.separate_status_line {
// Use the frame's budget so measurement and painting choose the same cap.
let budget = options.max_height.unwrap_or(u16::MAX);
(budget.saturating_add(2) / 4).clamp(2, MAX_FOOTER_ROWS)
} else {
2
};
let wanted_hints = self.footer_hint_height(width, options);
// Use the frame's budget so measurement and painting choose the same cap.
let workspace_height = options
.separate_status_line
.then_some(options.max_height.unwrap_or(u16::MAX));
// Find needs its query and result before the status line can use a row.
let find_active = options
.footer
.is_some_and(|footer| footer.cursor_column.is_some());
let reserved_hints = wanted_hints.min(if find_active { 2 } else { 1 });
let wanted_status = self.status_surface_height(
width,
available.min(cap).saturating_sub(reserved_hints),
options,
let footer = FooterLayout::new(
workspace_height,
self.footer_hint_height(width, options),
/*priority_hint_rows*/ if find_active { 2 } else { 1 },
);
let basic = (reserved_hints + wanted_status.min(1)).min(2);
let wanted_status =
self.status_surface_height(width, footer.status_budget(available), options);
let basic = (footer.reserved_hints + wanted_status.min(1)).min(2);
let above_input = above_input.min(available.saturating_sub(basic));
available -= above_input;
let mut thumbnails = options.image_previews.map_or(0, |previews| {
@@ -270,17 +309,11 @@ impl ChatComposer {
} else {
thumbnails = 0;
}
let limit = cap.min(available);
let reserved_status = wanted_status
.min(1)
.min(limit.saturating_sub(reserved_hints));
let hints = wanted_hints.min(limit - reserved_status);
ComposerRows {
input: input_height,
above_input,
thumbnails,
status: wanted_status.min(limit - hints),
hints,
footer: footer.allocate(available, wanted_status),
}
}
}
@@ -384,10 +384,7 @@ pub(super) struct FooterState {
pub(super) goal_status_indicator: Option<GoalStatusIndicator>,
pub(super) ide_context_active: bool,
pub(super) status_line_value: Option<FooterContent>,
// Measurement bounds and occupied height, shared by layout and cursor placement.
pub(super) measured_status_line: std::cell::Cell<Option<(ratatui::layout::Size, u16)>>,
pub(super) wrapped_native_status:
std::cell::RefCell<Option<super::native_status::WrappedNativeStatus>>,
pub(super) status_layout: crate::bottom_pane::status_line::StatusLine,
pub(super) status_line_enabled: bool,
pub(super) side_conversation_context_label: Option<String>,
pub(super) active_agent_label: Option<String>,
@@ -1,133 +0,0 @@
//! Wrap the built-in status line using its painted grapheme widths and native hyperlink ranges.
//! Measurement and paint share the visible rows at the current width.
use super::ChatComposer;
use super::FooterContent;
use super::composer_layout::MAX_FOOTER_ROWS;
use crate::render::line_utils::line_to_static;
use crate::terminal_hyperlinks::HyperlinkLine;
use crate::terminal_hyperlinks::TerminalHyperlink;
use crate::terminal_hyperlinks::project_hyperlink_columns;
use crate::width::display_width;
use crate::wrapping::adaptive_wrap_line_to_width;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::text::Line;
use ratatui::text::Span;
use std::cell::Ref;
use unicode_segmentation::UnicodeSegmentation;
pub(super) struct WrappedNativeStatus {
width: u16,
suffix: Vec<Span<'static>>,
rows: Vec<HyperlinkLine>,
}
impl WrappedNativeStatus {
pub(super) fn height(&self) -> u16 {
self.rows.len().max(1) as u16
}
pub(super) fn render(&self, area: Rect, buf: &mut Buffer) {
let height = usize::from(area.height);
for (index, line) in self.rows.iter().take(height).enumerate() {
let row = Rect::new(area.x, area.y + index as u16, area.width, 1);
if (index + 1 == height && self.rows.len() > height)
|| line.width() > usize::from(area.width)
{
line.clone().render_with_ellipsis(row, buf);
} else {
line.render_fitted(row, buf);
}
}
}
}
impl ChatComposer {
pub(super) fn wrapped_native_status(
&self,
content: &FooterContent,
width: u16,
) -> Option<Ref<'_, WrappedNativeStatus>> {
let (line, links) = content.native_parts()?;
if width == 0 {
return None;
}
// The setter invalidates configured spans. The styled agent suffix can change independently.
let configured = self
.footer
.status_line_value
.as_ref()
.and_then(FooterContent::native_parts)
.map_or(0, |(line, _)| line.spans.len());
let suffix = &line.spans[configured..];
let cache = &self.footer.wrapped_native_status;
if cache
.borrow()
.as_ref()
.is_none_or(|entry| entry.width != width || entry.suffix != suffix)
{
if content.width() <= width {
return None;
}
cache.replace(Some(WrappedNativeStatus {
width,
suffix: suffix.to_vec(),
rows: wrap_rows(line, links, width),
}));
}
Ref::filter_map(cache.borrow(), Option::as_ref).ok()
}
}
fn wrap_rows(line: &Line<'_>, links: &[TerminalHyperlink], width: u16) -> Vec<HyperlinkLine> {
// One row of lookahead settles the last visible break. Whitespace discarded by
// wrapping cannot count toward that budget or it could hide a later word.
let limit = usize::from(MAX_FOOTER_ROWS) + 1;
let mut remaining = usize::from(width) * limit;
let mut prefix = Line::default().style(line.style);
for span in &line.spans {
let mut end = 0;
for grapheme in span.content.graphemes(true) {
end += grapheme.len();
if !grapheme.chars().all(char::is_whitespace) {
remaining =
remaining.saturating_sub(display_width(grapheme).min(usize::from(width)));
if remaining == 0 {
break;
}
}
}
prefix.push_span(Span::styled(&span.content[..end], span.style));
if remaining == 0 {
break;
}
}
// Native link columns are counted per grapheme within each painted span.
let mut graphemes = prefix
.spans
.iter()
.flat_map(|span| span.content.graphemes(true));
let (mut byte, mut column) = (0, 0);
let mut column_at = |target| {
while byte < target
&& let Some(grapheme) = graphemes.next()
{
byte += grapheme.len();
column += display_width(grapheme);
}
column
};
adaptive_wrap_line_to_width(&prefix, usize::from(width).into())
.into_iter()
.take(limit)
.map(|row| {
let source = column_at(row.range.start)..column_at(row.range.end);
HyperlinkLine {
line: line_to_static(&row.line),
hyperlinks: project_hyperlink_columns(links, source, /*prefix*/ 0),
source: None,
}
})
.collect()
}
@@ -3,6 +3,7 @@
//! Shortcut help grows above the input, preserving status and close rows below it even when clipped.
use super::*;
use crate::bottom_pane::footer_content::StatusLineFocus;
use std::borrow::Cow;
@@ -14,8 +15,7 @@ impl ChatComposer {
if matches!(status_line, Some(FooterContent::Extension { .. })) {
self.effort_status_line_transition = None;
}
self.footer.measured_status_line.set(None);
self.footer.wrapped_native_status.take();
self.footer.status_layout = Default::default();
self.footer.status_line_value = status_line;
true
}
@@ -42,40 +42,18 @@ impl ChatComposer {
available: u16,
options: ComposerRenderOptions<'_>,
) -> u16 {
// The unfocused composer paints only the raw status, as one dim line.
let (available, status) = if options.unfocused {
let raw = self
.status_line_content()
.filter(|_| self.footer.status_line_enabled);
(available.min(1), raw.map(Cow::Borrowed))
} else {
(available, self.status_surface_content())
};
let bounds = inset_footer_hint_area(Rect::new(0, 0, width, available)).as_size();
if bounds.width == 0 || bounds.height == 0 {
return 0;
}
match status.as_deref() {
Some(FooterContent::Extension { content }) => {
let cache = &self.footer.measured_status_line;
if let Some((previous, height)) = cache.get()
&& previous == bounds
{
return height;
}
let height = content.measure(bounds).height;
cache.set(Some((bounds, height)));
height
}
Some(native) if bounds.height > 1 && self.active_status_transition().is_none() => self
.wrapped_native_status(native, bounds.width)
.map_or_else(
|| u16::from(native.width() > 0),
|rows| rows.height().min(bounds.height),
),
Some(line) => u16::from(line.width() > 0),
None => 0,
}
let focus = options.status_line_focus();
let available =
if focus == StatusLineFocus::Focused && self.active_status_transition().is_some() {
available.min(1)
} else {
available
};
self.footer.status_layout.height(
self.status_surface_content(focus).as_deref(),
width,
available,
)
}
pub(super) fn hint_footer_props(&self) -> FooterProps {
@@ -88,13 +66,17 @@ impl ChatComposer {
props
}
pub(super) fn status_surface_content(&self) -> Option<Cow<'_, FooterContent>> {
pub(super) fn status_surface_content(
&self,
focus: StatusLineFocus,
) -> Option<Cow<'_, FooterContent>> {
if !self.footer.status_line_enabled {
return None;
}
let status = self.footer.status_line_value.as_ref();
if matches!(status, Some(FooterContent::Extension { .. }))
|| self.footer.active_agent_label.is_none()
|| focus == StatusLineFocus::Unfocused
{
return status.map(Cow::Borrowed);
}
@@ -120,36 +102,33 @@ impl ChatComposer {
if area.is_empty() {
return;
}
if options.unfocused {
if let Some(content) = self.status_line_content() {
crate::bottom_pane::footer::render_unfocused_status(
content.clone().into_hyperlink_line().line.to_string(),
area,
buf,
);
}
return;
}
let area = inset_footer_hint_area(area);
let line = self.status_surface_content();
if let Some(transition) = self.active_status_transition() {
if let Some(frame) = transition.render_line(
line.map(|line| line.into_owned().into_hyperlink_line()),
area.width,
) {
FooterContent::HyperlinkLine(frame).render(area, buf);
}
let focus = options.status_line_focus();
let mut content = self.status_surface_content(focus);
if focus == StatusLineFocus::Focused
&& let Some(transition) = self.active_status_transition()
{
content = transition
.render_line(
content.map(|line| line.into_owned().into_hyperlink_line()),
inset_footer_hint_area(area).width,
)
.map(|frame| Cow::Owned(frame.into()));
if let Some(requester) = &self.frame_requester {
requester.schedule_frame_in(EFFORT_STATUS_LINE_FRAME_TICK);
}
} else if let Some(line) = line {
if area.height > 1
&& let Some(wrapped) = self.wrapped_native_status(&line, area.width)
{
wrapped.render(area, buf);
} else {
line.into_owned().render(area, buf);
}
}
self.footer
.status_layout
.render(content.as_deref(), area, buf, focus);
}
}
impl ComposerRenderOptions<'_> {
fn status_line_focus(self) -> StatusLineFocus {
if self.unfocused {
StatusLineFocus::Unfocused
} else {
StatusLineFocus::Focused
}
}
}
@@ -266,7 +266,7 @@ fn status_rows_respect_host_budget_and_yield_to_the_draft() {
}
#[test]
fn sidebar_focus_collapses_extension_status_to_its_dim_line() {
fn sidebar_focus_keeps_dim_extension_status_rows() {
let mut composer = composer();
composer.insert_str("draft");
let tree: crate::ui_tree::UiTree = r#""one\ntwo\nthree\nfour""#.parse().unwrap();
@@ -283,6 +283,15 @@ fn sidebar_focus_collapses_extension_status_to_its_dim_line() {
if unfocused {
assert_eq!(composer.footer.warning_notice_area.get(), None);
assert!(!buffer.content.iter().any(|cell| cell.symbol() == "⚠"));
let status =
inset_footer_hint_area(composer.layout_with_options(buffer.area, options).status);
for y in status.y..status.bottom() {
assert!(
buffer[(status.x, y)]
.modifier
.contains(ratatui::style::Modifier::DIM)
);
}
}
rows.join("\n")
});
@@ -292,7 +301,9 @@ fn sidebar_focus_collapses_extension_status_to_its_dim_line() {
three
four
---
one…
one
two
three…
---
one
two
+2 -12
View File
@@ -712,15 +712,6 @@ pub(crate) fn render_context_right(area: Rect, buf: &mut Buffer, line: &Line<'st
}
}
pub(crate) fn render_unfocused_status(text: String, area: Rect, buf: &mut Buffer) {
let area = inset_footer_hint_area(area);
crate::line_truncation::truncate_line_with_ellipsis_if_overflow(
text.dim().into(),
usize::from(area.width),
)
.render(area, buf);
}
pub(crate) fn inset_footer_hint_area(mut area: Rect) -> Rect {
if area.width > 2 {
area.x += 2;
@@ -814,8 +805,7 @@ pub(crate) fn passive_footer_status_line(props: &FooterProps) -> Option<FooterCo
.as_ref()
.filter(|_| props.status_line_enabled);
let mut line = match status {
Some(FooterContent::Line(line)) => HyperlinkLine::from(line.clone()),
Some(FooterContent::HyperlinkLine(line)) => line.clone(),
Some(FooterContent::HyperlinkLine(line)) => line.as_ref().clone(),
Some(content @ FooterContent::Extension { .. }) => return Some(content.clone()),
None => HyperlinkLine::default(),
};
@@ -828,7 +818,7 @@ pub(crate) fn passive_footer_status_line(props: &FooterProps) -> Option<FooterCo
.push_span(STATUS_LINE_SEPARATOR.set_style(secondary_text_style()));
}
line.line.spans.extend(suffix.spans);
Some(FooterContent::HyperlinkLine(line))
Some(line.into())
}
fn passive_footer_suffix(props: &FooterProps, has_status: bool) -> Line<'static> {
@@ -1,22 +1,28 @@
//! Native and extension status content measured and painted inside the status rectangle.
use crate::line_truncation::line_width;
use crate::line_truncation::truncate_line_with_ellipsis;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::Line;
use std::borrow::Cow;
use std::sync::Arc;
use crate::render::highlight::with_syntax_theme;
use crate::terminal_hyperlinks::HyperlinkLine;
use crate::terminal_hyperlinks::TerminalHyperlink;
use crate::ui_tree::PreparedUiTree;
use crate::ui_tree::UiTree;
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum StatusLineFocus {
Focused,
Unfocused,
}
/// Owned status content displayed below the composer.
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum FooterContent {
Line(Line<'static>),
HyperlinkLine(HyperlinkLine),
HyperlinkLine(Box<HyperlinkLine>),
Extension { content: Arc<PreparedUiTree> },
}
@@ -29,50 +35,48 @@ impl From<UiTree> for FooterContent {
}
impl FooterContent {
pub(crate) fn native_parts(&self) -> Option<(&Line<'static>, &[TerminalHyperlink])> {
match self {
Self::Line(line) => Some((line, &[])),
Self::HyperlinkLine(line) => Some((&line.line, &line.hyperlinks)),
Self::Extension { .. } => None,
}
}
/// Natural display width in terminal cells.
pub(crate) fn width(&self) -> u16 {
let width = match self {
Self::Line(line) => line_width(line),
Self::HyperlinkLine(line) => line.width(),
Self::Extension { content } => return content.measure_natural().width,
};
u16::try_from(width).unwrap_or(u16::MAX)
}
/// Paint only inside the final area supplied by the parent.
pub(crate) fn render(self, area: Rect, buf: &mut Buffer) {
/// Paint a focused preview in its allotted rectangle.
pub(crate) fn render(&self, area: Rect, buf: &mut Buffer) {
self.render_with_focus(area, buf, StatusLineFocus::Focused);
}
pub(in crate::bottom_pane) fn render_with_focus(
&self,
area: Rect,
buf: &mut Buffer,
focus: StatusLineFocus,
) {
match self {
Self::Extension { content } => {
with_syntax_theme(|theme| content.render(area, theme, buf));
let plain_style =
(focus == StatusLineFocus::Unfocused).then(|| Style::reset().dim());
with_syntax_theme(|theme| content.render(area, theme, buf, plain_style));
}
native => {
let line = native.into_hyperlink_line();
let area = Rect {
Self::HyperlinkLine(line) => render_status_row(
line,
Rect {
height: area.height.min(1),
..area
};
let width = usize::from(area.width);
if line.width() > width {
line.render_with_ellipsis(area, buf);
} else {
line.render_fitted(area, buf);
}
}
},
buf,
focus,
/*more_rows*/ false,
),
}
}
pub(crate) fn into_hyperlink_line(self) -> HyperlinkLine {
match self {
Self::Line(line) => line.into(),
Self::HyperlinkLine(line) => line,
Self::HyperlinkLine(line) => *line,
Self::Extension { content } => {
with_syntax_theme(|theme| content.render_line(u16::MAX, theme))
}
@@ -80,21 +84,49 @@ impl FooterContent {
}
}
pub(in crate::bottom_pane) fn render_status_row(
line: &HyperlinkLine,
area: Rect,
buf: &mut Buffer,
focus: StatusLineFocus,
more_rows: bool,
) {
if area.is_empty() {
return;
}
let mut line = Cow::Borrowed(line);
let width = usize::from(area.width);
if more_rows || line.width() > width {
let line = line.to_mut();
line.line = truncate_line_with_ellipsis(std::mem::take(&mut line.line), width);
line.clip_hyperlinks_to_width(line.width().saturating_sub(1));
}
if focus == StatusLineFocus::Unfocused {
let line = line.to_mut();
line.hyperlinks.clear();
line.line.style = Style::reset().dim();
for span in &mut line.line.spans {
span.style = Style::default();
}
}
line.render_fitted(area, buf);
}
impl From<HyperlinkLine> for FooterContent {
fn from(line: HyperlinkLine) -> Self {
Self::HyperlinkLine(line)
Self::HyperlinkLine(Box::new(line))
}
}
impl From<Line<'static>> for FooterContent {
fn from(line: Line<'static>) -> Self {
Self::Line(line)
Self::from(HyperlinkLine::from(line))
}
}
impl From<&'static str> for FooterContent {
fn from(text: &'static str) -> Self {
Self::Line(Line::from(text))
Self::from(Line::from(text))
}
}
@@ -20,7 +20,7 @@ fn clipped_links_keep_their_cells_after_a_halfwidth_katakana_grapheme() {
/*x*/ 0, /*y*/ 0, /*width*/ 10, /*height*/ 1,
);
let mut buf = Buffer::empty(area);
FooterContent::HyperlinkLine(line).render(area, &mut buf);
FooterContent::from(line).render(area, &mut buf);
assert_eq!(buf[(0, 0)].symbol(), osc8_hyperlink(project_url, "ガ"));
for (x, ch) in (2..5).zip(" · ".chars()) {
assert_eq!(buf[(x, 0)].symbol(), ch.to_string());
@@ -40,11 +40,12 @@ fn arabic_prefix_keeps_link_on_the_painted_cells_at_exact_fit_and_overflow() {
let mut line = HyperlinkLine::from("لا · ");
line.push_span("PR #42".into(), Some(url));
assert_eq!(line.width(), 11);
let content = FooterContent::from(line);
for (width, visible_link) in [(11, "PR #42"), (10, "PR #")] {
let area = Rect::new(/*x*/ 0, /*y*/ 0, width, /*height*/ 1);
let mut buf = Buffer::empty(area);
FooterContent::HyperlinkLine(line.clone()).render(area, &mut buf);
content.render(area, &mut buf);
assert_eq!(buf[(4, 0)].symbol(), " ");
for (x, ch) in (5..width).zip(visible_link.chars()) {
assert_eq!(buf[(x, 0)].symbol(), osc8_hyperlink(url, &ch.to_string()));
+3 -1
View File
@@ -23,10 +23,11 @@
pub(crate) use chat_composer::CommandPopupPlacement;
pub(crate) use chat_composer::ComposerRenderOptions;
pub(crate) use chat_composer::TranscriptFooter;
pub(crate) use chat_composer::composer_layout::FooterLayout;
pub(crate) use composer_gap::ComposerGap;
pub(crate) use footer::footer_hint_items_line;
pub(crate) use footer::inset_footer_hint_area;
pub(crate) use footer::render_unfocused_status;
pub(crate) use status_line::StatusLine;
use std::collections::VecDeque;
use std::path::PathBuf;
@@ -93,6 +94,7 @@ pub(crate) use multi_select_picker::MultiSelectPicker;
mod question_tests;
mod questions;
mod request_user_input;
mod status_line;
mod status_line_setup;
mod status_line_style;
mod status_surface_preview;
@@ -621,7 +621,7 @@ impl Renderable for MultiSelectPicker {
Layout::vertical([Constraint::Length(1), Constraint::Length(hint_height)])
.areas(footer_area);
let preview_area = preview_area.inset(Insets::vh(/*v*/ 0, /*h*/ 2));
preview_line.clone().render(preview_area, buf);
preview_line.render(preview_area, buf);
hint_area
} else {
footer_area
@@ -711,9 +711,7 @@ impl MultiSelectPickerBuilder {
where
F: Fn(&[MultiSelectItem]) -> Option<Line<'static>> + Send + Sync + 'static,
{
self.on_preview_content(move |items| {
callback(items).map(|line| FooterContent::HyperlinkLine(line.into()))
})
self.on_preview_content(move |items| callback(items).map(FooterContent::from))
}
pub(crate) fn on_preview_content(
+187
View File
@@ -0,0 +1,187 @@
//! Measure and paint status lines for the main conversation and side chats.
use super::footer::inset_footer_hint_area;
use super::footer_content::FooterContent;
use super::footer_content::StatusLineFocus;
use super::footer_content::render_status_row;
use crate::render::line_utils::line_to_static;
use crate::terminal_hyperlinks::HyperlinkLine;
use crate::terminal_hyperlinks::project_hyperlink_columns;
use crate::width::display_width;
use crate::wrapping::adaptive_wrap_line_to_width;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::layout::Size;
use ratatui::text::Line;
use ratatui::text::Span;
use std::cell::Cell;
use std::cell::Ref;
use std::cell::RefCell;
use unicode_segmentation::UnicodeSegmentation;
/// The owning composer resets this when its configured status changes.
#[derive(Default)]
pub(crate) struct StatusLine {
measured_extension: Cell<Option<(Size, u16)>>,
wrapped_native: RefCell<Option<WrappedNativeStatus>>,
}
struct WrappedNativeStatus {
bounds: Size,
content: HyperlinkLine,
rows: Vec<HyperlinkLine>,
}
impl StatusLine {
pub(crate) fn height(
&self,
content: Option<&FooterContent>,
width: u16,
available: u16,
) -> u16 {
let bounds = inset_footer_hint_area(Rect::new(0, 0, width, available)).as_size();
if bounds.width == 0 || bounds.height == 0 {
return 0;
}
match content {
Some(FooterContent::Extension { content }) => {
if let Some((previous, height)) = self.measured_extension.get()
&& previous == bounds
{
return height;
}
let height = content.measure(bounds).height;
self.measured_extension.set(Some((bounds, height)));
height
}
Some(native) => self.wrapped_rows(native, bounds).map_or_else(
|| u16::from(native.width() > 0),
|rows| rows.len().max(1).min(usize::from(bounds.height)) as u16,
),
None => 0,
}
}
pub(crate) fn render(
&self,
content: Option<&FooterContent>,
area: Rect,
buf: &mut Buffer,
focus: StatusLineFocus,
) {
let area = inset_footer_hint_area(area);
let Some(content) = content.filter(|_| !area.is_empty()) else {
return;
};
if let Some(rows) = self.wrapped_rows(content, area.as_size()) {
let height = usize::from(area.height);
for (index, line) in rows.iter().take(height).enumerate() {
let row = Rect::new(area.x, area.y + index as u16, area.width, 1);
render_status_row(
line,
row,
buf,
focus,
index + 1 == height && rows.len() > height,
);
}
} else {
content.render_with_focus(area, buf, focus);
}
}
fn wrapped_rows(
&self,
content: &FooterContent,
bounds: Size,
) -> Option<Ref<'_, [HyperlinkLine]>> {
let FooterContent::HyperlinkLine(line) = content else {
return None;
};
if bounds.height <= 1 {
return None;
}
// Reuse the same wrapped prefix across small height changes.
let bounds = Size {
height: bounds.height.max(4),
..bounds
};
let line = line.as_ref();
let cache = &self.wrapped_native;
if cache
.borrow()
.as_ref()
.is_none_or(|entry| entry.bounds != bounds || &entry.content != line)
{
if line.width() <= usize::from(bounds.width) {
return None;
}
cache.replace(Some(WrappedNativeStatus {
bounds,
content: line.clone(),
rows: wrap_rows(line, bounds),
}));
}
Ref::filter_map(cache.borrow(), |entry| {
entry.as_ref().map(|entry| entry.rows.as_slice())
})
.ok()
}
}
fn wrap_rows(status: &HyperlinkLine, bounds: Size) -> Vec<HyperlinkLine> {
let line = &status.line;
let width = usize::from(bounds.width);
// One row of lookahead settles the last visible break. Whitespace discarded by
// wrapping cannot count toward that budget or it could hide a later word.
let limit = usize::from(bounds.height) + 1;
let mut remaining = width * limit;
let mut prefix = Line::default().style(line.style);
for span in &line.spans {
let mut end = 0;
for grapheme in span.content.graphemes(true) {
end += grapheme.len();
if !grapheme.chars().all(char::is_whitespace) {
remaining = remaining.saturating_sub(display_width(grapheme).min(width));
if remaining == 0 {
break;
}
}
}
prefix.push_span(Span::styled(&span.content[..end], span.style));
if remaining == 0 {
break;
}
}
// Native link columns are counted per grapheme within each painted span.
let mut graphemes = prefix
.spans
.iter()
.flat_map(|span| span.content.graphemes(true));
let (mut byte, mut column) = (0, 0);
let mut column_at = |target| {
while byte < target
&& let Some(grapheme) = graphemes.next()
{
byte += grapheme.len();
column += display_width(grapheme);
}
column
};
adaptive_wrap_line_to_width(&prefix, width.into())
.into_iter()
.take(limit)
.map(|row| {
let source = column_at(row.range.start)..column_at(row.range.end);
HyperlinkLine {
line: line_to_static(&row.line),
hyperlinks: project_hyperlink_columns(
&status.hyperlinks,
source,
/*prefix*/ 0,
),
source: None,
}
})
.collect()
}
@@ -263,7 +263,7 @@ impl StatusSurfacePreviewData {
self.live_value_for(StatusSurfacePreviewItem::PullRequestNumber)
.and(self.pull_request_url.as_deref()),
)
.map(FooterContent::HyperlinkLine)
.map(FooterContent::from)
}
}
+1
View File
@@ -2115,6 +2115,7 @@ impl ChatWidget {
self.bottom_pane.new_status_indicator()
}
#[cfg(test)]
pub(crate) fn status_line_text(&self) -> Option<String> {
self.status_line_content()
.cloned()
+1 -1
View File
@@ -269,7 +269,7 @@ impl ChatWidget {
draft.set_text_with_elements(&snapshot.text, &snapshot.text_elements);
draft
},
status: self.status_line_text(),
status_line: self.status_line_content().cloned(),
work: self
.last_turn_id()
.filter(|_| self.is_agent_turn_running())
@@ -113,17 +113,6 @@ impl HyperlinkLine {
self.mark_cells(area, buf);
}
/// Reserve the last visible grapheme for an unlinked overflow marker.
pub(crate) fn render_with_ellipsis(mut self, area: Rect, buf: &mut Buffer) {
if area.is_empty() {
return;
}
self.line =
crate::line_truncation::truncate_line_with_ellipsis(self.line, usize::from(area.width));
self.clip_hyperlinks_to_width(self.width().saturating_sub(1));
self.render_fitted(area, buf);
}
fn mark_cells(&self, area: Rect, buf: &mut Buffer) {
if area.is_empty() {
return;
+18 -3
View File
@@ -21,6 +21,7 @@ use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::layout::Size;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::text::Span;
use std::ops::Range;
use syntect::highlighting::Theme;
@@ -52,7 +53,14 @@ impl PreparedUiTree {
layout(self.0.as_ref(), bounds, /*draw*/ None)
}
pub(crate) fn render(&self, area: Rect, theme: &Theme, buf: &mut Buffer) {
/// A plain style paints the same layout with theme styling and hyperlinks suppressed.
pub(crate) fn render(
&self,
area: Rect,
theme: &Theme,
buf: &mut Buffer,
plain_style: Option<Style>,
) {
let area = area.intersection(buf.area);
let mut theme = RenderTheme::new(theme);
layout(
@@ -61,8 +69,15 @@ impl PreparedUiTree {
Some(&mut |row, col, text, cells, paint| {
let x = area.x + col as u16;
let y = area.y + row as u16;
buf.set_string(x, y, text, paint.style(&mut theme));
if let Some(href) = &paint.href {
buf.set_string(
x,
y,
text,
plain_style.unwrap_or_else(|| paint.style(&mut theme)),
);
if plain_style.is_none()
&& let Some(href) = &paint.href
{
mark_hyperlink_cells(buf, Rect::new(x, y, cells as u16, 1), href);
}
}),
+14 -4
View File
@@ -195,7 +195,12 @@ fn zero_width_fragments_do_not_style_the_ellipsis_or_add_row_gaps() {
let node = tree(json!({"type":"text","children":children}));
let expected = Buffer::with_lines([expected]);
let mut buf = Buffer::empty(expected.area);
node.render(buf.area, &Theme::default(), &mut buf);
node.render(
buf.area,
&Theme::default(),
&mut buf,
/*plain_style*/ None,
);
assert_eq!(buf, expected);
}
}
@@ -421,7 +426,7 @@ fn paint_semantic_samples() -> String {
.fg(TerminalColor::Yellow)
.bg(TerminalColor::Blue),
);
node.render(area, &theme, &mut buffer);
node.render(area, &theme, &mut buffer, /*plain_style*/ None);
samples.push(format!("{buffer:?}"));
}
samples.join("\n")
@@ -504,7 +509,7 @@ fn collapsed_space_keeps_the_first_whitespace_owners_link() {
fn painted_rows(node: &PreparedUiTree, bounds: Size) -> Vec<String> {
let area = Rect::from((ratatui::layout::Position::ORIGIN, bounds));
let mut buf = Buffer::empty(area);
node.render(area, &Theme::default(), &mut buf);
node.render(area, &Theme::default(), &mut buf, /*plain_style*/ None);
(0..node.measure(bounds).height)
.map(|y| {
let mut text = String::new();
@@ -663,7 +668,12 @@ fn authored_line_breaks_and_collapsed_whitespace_preserve_styles_and_links() {
/*x*/ 0, /*y*/ 0, /*width*/ 8, /*height*/ 4,
);
let mut buffer = Buffer::empty(area);
node.render(area, &Theme::default(), &mut buffer);
node.render(
area,
&Theme::default(),
&mut buffer,
/*plain_style*/ None,
);
for (x, g) in "PR #42".chars().enumerate() {
assert_eq!(
buffer[(x as u16, 3)].symbol(),