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