mirror of
https://github.com/openai/codex.git
synced 2026-11-12 21:20:34 +00:00
## 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
320 lines
11 KiB
Rust
320 lines
11 KiB
Rust
//! Shared composer geometry keeps rendering, measurement, and the cursor aligned.
|
|
|
|
use super::*;
|
|
use crate::bottom_pane::voice_strip::VoiceStrip;
|
|
use crate::bottom_pane::voice_strip::VoiceStripState;
|
|
use crate::render::renderable::Renderable;
|
|
use crate::tui::FrameRequester;
|
|
use ratatui::buffer::Buffer;
|
|
use ratatui::layout::Rect;
|
|
|
|
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,
|
|
state: Option<VoiceStripState>,
|
|
frame_requester: FrameRequester,
|
|
) {
|
|
match (self.voice_strip.as_mut(), state) {
|
|
(Some(strip), Some(state)) => strip.update(state),
|
|
(_, Some(state)) => self.voice_strip = Some(VoiceStrip::new(state, frame_requester)),
|
|
(_, None) => self.voice_strip = None,
|
|
}
|
|
}
|
|
|
|
pub(super) fn render_voice_strip(&self, composer_rect: Rect, buf: &mut Buffer) {
|
|
if let Some(strip) = &self.voice_strip
|
|
&& composer_rect.height >= strip.desired_height(composer_rect.width) + 4
|
|
{
|
|
let voice_rect = Rect {
|
|
y: composer_rect.y.saturating_add(/*rhs*/ 1),
|
|
height: strip.desired_height(composer_rect.width),
|
|
..composer_rect
|
|
};
|
|
strip.render(voice_rect, buf);
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ChatComposer {
|
|
pub(super) fn layout_with_options(
|
|
&self,
|
|
area: Rect,
|
|
options: ComposerRenderOptions<'_>,
|
|
) -> ComposerLayout {
|
|
let ComposerRows {
|
|
above_input,
|
|
thumbnails: thumbnail_height,
|
|
footer,
|
|
..
|
|
} = self.composer_rows(area.width, area.height, options);
|
|
let status = footer.status_area(area);
|
|
let footer_hint_height = footer.hints;
|
|
let area = Rect {
|
|
height: area.height - status.height,
|
|
..area
|
|
};
|
|
let voice_rows = self
|
|
.voice_strip
|
|
.as_ref()
|
|
.map_or(0, |strip| strip.desired_height(area.width) + 1);
|
|
let shortcuts_above = self.shortcuts_above_composer(options);
|
|
let (composer_rect, popup_rect, mut footer_rect) = if shortcuts_above {
|
|
let [shortcuts, composer, footer] = Layout::vertical([
|
|
Constraint::Max(above_input),
|
|
Constraint::Min(3 + voice_rows),
|
|
Constraint::Length(footer_hint_height),
|
|
])
|
|
.areas(area);
|
|
(composer, shortcuts, footer)
|
|
} else if self.popups.active.is_above_composer() {
|
|
// Preserve the draft first, then its footer, and use remaining space for suggestions.
|
|
let [mut composer, mut popup, footer] = Layout::vertical([
|
|
Constraint::Min(3 + voice_rows),
|
|
Constraint::Max(above_input),
|
|
Constraint::Length(footer_hint_height),
|
|
])
|
|
.areas(area);
|
|
popup.y = area.y;
|
|
composer.y = popup.bottom();
|
|
(composer, popup, footer)
|
|
} else {
|
|
let [composer, popup] = Layout::vertical([
|
|
Constraint::Min(3 + voice_rows),
|
|
Constraint::Max(footer_hint_height),
|
|
])
|
|
.areas(area);
|
|
(composer, popup, popup)
|
|
};
|
|
footer_rect.y = footer_rect.y.saturating_add(status.height);
|
|
// Keep the draft visible when clipped.
|
|
let voice_rows = voice_rows * u16::from(composer_rect.height >= voice_rows + 3);
|
|
let mut textarea_rect = composer_rect.inset(Insets::tlbr(
|
|
/*top*/ 1 + voice_rows,
|
|
LIVE_PREFIX_COLS,
|
|
/*bottom*/ 1,
|
|
/*right*/ 1u16.saturating_add(options.textarea_right_reserve),
|
|
));
|
|
let remote_images_height = self
|
|
.attachments
|
|
.remote_image_lines()
|
|
.len()
|
|
.try_into()
|
|
.unwrap_or(u16::MAX)
|
|
.min(textarea_rect.height.saturating_sub(2));
|
|
let remote_images_separator = u16::from(remote_images_height > 0);
|
|
let consumed = remote_images_height.saturating_add(remote_images_separator);
|
|
let remote_images_rect = Rect {
|
|
x: textarea_rect.x,
|
|
y: textarea_rect.y,
|
|
width: textarea_rect.width,
|
|
height: remote_images_height,
|
|
};
|
|
let thumbnails = Rect {
|
|
y: textarea_rect.y.saturating_add(consumed),
|
|
height: thumbnail_height,
|
|
..textarea_rect
|
|
};
|
|
let available = textarea_rect.height.saturating_sub(consumed);
|
|
textarea_rect.y = thumbnails.bottom();
|
|
textarea_rect.height = available - thumbnails.height;
|
|
ComposerLayout {
|
|
thumbnails,
|
|
status,
|
|
composer: composer_rect,
|
|
remote_images: remote_images_rect,
|
|
textarea: textarea_rect,
|
|
popup: popup_rect,
|
|
footer: footer_rect,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn cursor_pos_with_options(
|
|
&self,
|
|
area: Rect,
|
|
options: ComposerRenderOptions<'_>,
|
|
) -> Option<(u16, u16)> {
|
|
let layout = self.layout_with_options(area, options);
|
|
if let Some(footer) = options.footer.filter(|footer| footer.is_interactive) {
|
|
let area = inset_footer_hint_area(layout.footer);
|
|
return footer
|
|
.cursor_column
|
|
.filter(|column| *column < area.width && area.height > 0)
|
|
.map(|column| (area.x + column, area.y));
|
|
}
|
|
if !self.draft.input_enabled || self.attachments.selected_remote_image_index.is_some() {
|
|
return None;
|
|
}
|
|
|
|
if let Some(pos) = self
|
|
.vim_search_cursor_pos(layout.footer)
|
|
.or_else(|| self.history_search_cursor_pos(layout.footer))
|
|
{
|
|
return Some(pos);
|
|
}
|
|
|
|
let state = *self.draft.textarea_state.borrow();
|
|
self.draft
|
|
.textarea
|
|
.cursor_pos_with_state(layout.textarea, state)
|
|
}
|
|
|
|
pub(crate) fn desired_height_with_options(
|
|
&self,
|
|
width: u16,
|
|
options: ComposerRenderOptions<'_>,
|
|
) -> u16 {
|
|
let rows = self.composer_rows(width, options.max_height.unwrap_or(u16::MAX), options);
|
|
[
|
|
rows.input,
|
|
rows.above_input,
|
|
rows.thumbnails,
|
|
rows.footer.height(),
|
|
]
|
|
.into_iter()
|
|
.fold(0, u16::saturating_add)
|
|
}
|
|
|
|
fn composer_rows(
|
|
&self,
|
|
width: u16,
|
|
height: u16,
|
|
options: ComposerRenderOptions<'_>,
|
|
) -> ComposerRows {
|
|
const COLS_WITH_MARGIN: u16 = LIVE_PREFIX_COLS + 1;
|
|
let inner_width =
|
|
width.saturating_sub(COLS_WITH_MARGIN.saturating_add(options.textarea_right_reserve));
|
|
let remote_images_height: u16 = self
|
|
.attachments
|
|
.remote_image_lines()
|
|
.len()
|
|
.try_into()
|
|
.unwrap_or(u16::MAX);
|
|
let remote_images_separator = u16::from(remote_images_height > 0);
|
|
let input_height = [
|
|
self.draft.textarea.desired_height(inner_width),
|
|
remote_images_height,
|
|
remote_images_separator,
|
|
2,
|
|
self.voice_strip.as_ref().map_or(/*default*/ 0, |strip| {
|
|
strip.desired_height(width).saturating_add(1)
|
|
}),
|
|
]
|
|
.into_iter()
|
|
.fold(0, u16::saturating_add);
|
|
let above_input = if self.shortcuts_above_composer(options) {
|
|
footer_height(&self.hint_footer_props(), width)
|
|
} else if options.command_popup_placement == CommandPopupPlacement::AboveComposer {
|
|
self.popups
|
|
.active
|
|
.required_height(width, /*footer_total_height*/ 0)
|
|
} else {
|
|
0
|
|
};
|
|
let editing_hint = options.footer.is_some_and(|footer| footer.is_interactive)
|
|
|| matches!(
|
|
self.footer_mode(),
|
|
FooterMode::HistorySearch
|
|
| FooterMode::ShortcutOverlay
|
|
| FooterMode::QuitShortcutReminder
|
|
| FooterMode::EscHint
|
|
);
|
|
// The draft can scroll when another interaction owns the bottom row.
|
|
let mut available = height
|
|
.saturating_sub(input_height)
|
|
.max(u16::from(editing_hint && height > 3));
|
|
// 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 footer = FooterLayout::new(
|
|
workspace_height,
|
|
self.footer_hint_height(width, options),
|
|
/*priority_hint_rows*/ if find_active { 2 } else { 1 },
|
|
);
|
|
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| {
|
|
previews.height(inner_width, self.attachments.local_images.len())
|
|
});
|
|
if thumbnails <= available.saturating_sub(basic) {
|
|
available -= thumbnails;
|
|
} else {
|
|
thumbnails = 0;
|
|
}
|
|
ComposerRows {
|
|
input: input_height,
|
|
above_input,
|
|
thumbnails,
|
|
footer: footer.allocate(available, wanted_status),
|
|
}
|
|
}
|
|
}
|