mirror of
https://github.com/openai/codex.git
synced 2026-09-04 15:08:45 +00:00
feat(tui): render side conversation panes
This commit is contained in:
@@ -10,6 +10,7 @@ use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
use codex_protocol::ThreadId;
|
||||
use crossterm::event::KeyCode;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use super::InitialHistoryReplayBuffer;
|
||||
@@ -20,8 +21,18 @@ use crate::app_event::PaneSlot;
|
||||
use crate::chatwidget::ChatWidget;
|
||||
use crate::file_search::FileSearchManager;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::key_hint;
|
||||
use crate::key_hint::KeyBinding;
|
||||
use crate::transcript_reflow::TranscriptReflowState;
|
||||
|
||||
pub(super) fn parent_pane_shortcut() -> KeyBinding {
|
||||
key_hint::alt(KeyCode::Char('1'))
|
||||
}
|
||||
|
||||
pub(super) fn side_pane_shortcut() -> KeyBinding {
|
||||
key_hint::alt(KeyCode::Char('2'))
|
||||
}
|
||||
|
||||
pub(super) struct ConversationPaneInit {
|
||||
pub(super) chat_widget: ChatWidget,
|
||||
pub(super) file_search: FileSearchManager,
|
||||
@@ -131,6 +142,10 @@ impl ConversationPanes {
|
||||
self.focused
|
||||
}
|
||||
|
||||
pub(super) fn has_side(&self) -> bool {
|
||||
self.side.is_some()
|
||||
}
|
||||
|
||||
pub(super) fn focus(&mut self, slot: PaneSlot) -> bool {
|
||||
if self.by_slot(slot).is_none() {
|
||||
return false;
|
||||
|
||||
@@ -100,6 +100,14 @@ impl App {
|
||||
app_server: &mut AppServerSession,
|
||||
key_event: KeyEvent,
|
||||
) {
|
||||
if self.handle_conversation_pane_focus_key(key_event) {
|
||||
if self.backtrack.primed {
|
||||
self.reset_backtrack_state();
|
||||
}
|
||||
tui.frame_requester().schedule_frame();
|
||||
return;
|
||||
}
|
||||
|
||||
// Some terminals, especially on macOS, encode Option+Left/Right as Option+b/f unless
|
||||
// enhanced keyboard reporting is available. We only treat those word-motion fallbacks as
|
||||
// agent-switch shortcuts when the composer is empty so we never steal the expected
|
||||
@@ -267,6 +275,32 @@ impl App {
|
||||
};
|
||||
}
|
||||
|
||||
fn handle_conversation_pane_focus_key(&mut self, key_event: KeyEvent) -> bool {
|
||||
if self.overlay.is_some()
|
||||
|| self.chat_widget.by_slot(PaneSlot::Side).is_none()
|
||||
|| !self.chat_widget.no_modal_or_popup_active()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
let composer_is_empty = self.chat_widget.composer_text_with_pending().is_empty();
|
||||
let allow_word_motion_fallback = !self.enhanced_keys_supported && composer_is_empty;
|
||||
let target = if conversation_panes::parent_pane_shortcut().is_press(key_event)
|
||||
|| (composer_is_empty
|
||||
&& previous_agent_shortcut_matches(key_event, allow_word_motion_fallback))
|
||||
{
|
||||
PaneSlot::Parent
|
||||
} else if conversation_panes::side_pane_shortcut().is_press(key_event)
|
||||
|| (composer_is_empty
|
||||
&& next_agent_shortcut_matches(key_event, allow_word_motion_fallback))
|
||||
{
|
||||
PaneSlot::Side
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
self.chat_widget.focus(target)
|
||||
}
|
||||
|
||||
pub(super) fn should_handle_backtrack_esc(&self, key_event: KeyEvent) -> bool {
|
||||
!self.chat_widget.side_conversation_active()
|
||||
&& self.chat_widget.is_normal_backtrack_mode()
|
||||
@@ -299,6 +333,25 @@ impl App {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::test_support::make_test_app;
|
||||
use super::*;
|
||||
use crate::chatwidget::tests::constructor::make_chatwidget_for_pane;
|
||||
|
||||
async fn install_side_pane(app: &mut App) {
|
||||
let (chat_widget, _rx) = make_chatwidget_for_pane(PaneSlot::Side).await;
|
||||
let file_search = FileSearchManager::new(
|
||||
chat_widget.config_ref().cwd.to_path_buf(),
|
||||
chat_widget.conversation_event_sender(),
|
||||
);
|
||||
assert!(
|
||||
app.chat_widget
|
||||
.install_side(ConversationPaneInit {
|
||||
chat_widget,
|
||||
file_search,
|
||||
owned_screen: None,
|
||||
})
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn app_keymap_shortcuts_are_disabled_while_keymap_view_is_active() {
|
||||
@@ -310,4 +363,82 @@ mod tests {
|
||||
|
||||
assert!(!app.app_keymap_shortcuts_available());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn canonical_pane_focus_keys_preserve_independent_drafts() {
|
||||
let mut app = make_test_app().await;
|
||||
install_side_pane(&mut app).await;
|
||||
app.chat_widget
|
||||
.set_composer_text("parent draft".to_string(), Vec::new(), Vec::new());
|
||||
|
||||
assert!(app.handle_conversation_pane_focus_key(KeyEvent::new(
|
||||
KeyCode::Char('2'),
|
||||
KeyModifiers::ALT,
|
||||
)));
|
||||
assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Side);
|
||||
app.chat_widget
|
||||
.set_composer_text("side draft".to_string(), Vec::new(), Vec::new());
|
||||
|
||||
assert!(app.handle_conversation_pane_focus_key(KeyEvent::new(
|
||||
KeyCode::Char('1'),
|
||||
KeyModifiers::ALT,
|
||||
)));
|
||||
assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Parent);
|
||||
assert_eq!(app.chat_widget.composer_text_with_pending(), "parent draft");
|
||||
assert_eq!(
|
||||
app.chat_widget
|
||||
.by_slot(PaneSlot::Side)
|
||||
.expect("side pane")
|
||||
.composer_text_with_pending(),
|
||||
"side draft"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tokio::test]
|
||||
async fn pane_focus_does_not_steal_word_motion_from_nonempty_draft() {
|
||||
let mut app = make_test_app().await;
|
||||
install_side_pane(&mut app).await;
|
||||
app.chat_widget
|
||||
.set_composer_text("draft".to_string(), Vec::new(), Vec::new());
|
||||
|
||||
assert!(
|
||||
!app.handle_conversation_pane_focus_key(KeyEvent::new(
|
||||
KeyCode::Right,
|
||||
KeyModifiers::ALT,
|
||||
))
|
||||
);
|
||||
assert!(!app.handle_conversation_pane_focus_key(KeyEvent::new(
|
||||
KeyCode::Char('f'),
|
||||
KeyModifiers::ALT,
|
||||
)));
|
||||
assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Parent);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_composer_allows_arrow_pane_focus_shortcut() {
|
||||
let mut app = make_test_app().await;
|
||||
install_side_pane(&mut app).await;
|
||||
|
||||
assert!(
|
||||
app.handle_conversation_pane_focus_key(KeyEvent::new(
|
||||
KeyCode::Right,
|
||||
KeyModifiers::ALT,
|
||||
))
|
||||
);
|
||||
assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Side);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pane_focus_key_is_available_to_agent_navigation_without_side() {
|
||||
let mut app = make_test_app().await;
|
||||
|
||||
assert!(
|
||||
!app.handle_conversation_pane_focus_key(KeyEvent::new(
|
||||
KeyCode::Right,
|
||||
KeyModifiers::ALT,
|
||||
))
|
||||
);
|
||||
assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Parent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,13 +10,23 @@ use crossterm::event::KeyEventKind;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Position;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::Clear;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Widget;
|
||||
|
||||
use super::*;
|
||||
use crate::AltScreenBehavior;
|
||||
use crate::tui::MouseScrollEvent;
|
||||
|
||||
const MIN_SPLIT_PANE_WIDTH: u16 = 41;
|
||||
const SPLIT_DIVIDER_WIDTH: u16 = 1;
|
||||
const MIN_SPLIT_WIDTH: u16 = MIN_SPLIT_PANE_WIDTH * 2 + SPLIT_DIVIDER_WIDTH;
|
||||
const PANE_HEADER_HEIGHT: u16 = 1;
|
||||
|
||||
pub(super) struct OwnedScreen {
|
||||
viewport: ConversationViewport,
|
||||
replay_in_progress: bool,
|
||||
@@ -28,6 +38,59 @@ struct RenderedOwnedScreen {
|
||||
cursor_style: SetCursorStyle,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum OwnedScreenLayout {
|
||||
Single {
|
||||
slot: PaneSlot,
|
||||
area: Rect,
|
||||
show_header: bool,
|
||||
},
|
||||
Split {
|
||||
area: Rect,
|
||||
parent: Rect,
|
||||
divider: Rect,
|
||||
side: Rect,
|
||||
},
|
||||
}
|
||||
|
||||
impl OwnedScreenLayout {
|
||||
fn new(area: Rect, has_side: bool, focused: PaneSlot) -> Self {
|
||||
if !has_side {
|
||||
return Self::Single {
|
||||
slot: PaneSlot::Parent,
|
||||
area,
|
||||
show_header: false,
|
||||
};
|
||||
}
|
||||
if area.width < MIN_SPLIT_WIDTH {
|
||||
return Self::Single {
|
||||
slot: focused,
|
||||
area,
|
||||
show_header: true,
|
||||
};
|
||||
}
|
||||
|
||||
let pane_width = area.width.saturating_sub(SPLIT_DIVIDER_WIDTH);
|
||||
let parent_width = (pane_width + 1) / 2;
|
||||
let side_width = pane_width.saturating_sub(parent_width);
|
||||
let parent = Rect::new(area.x, area.y, parent_width, area.height);
|
||||
let divider = Rect::new(parent.right(), area.y, SPLIT_DIVIDER_WIDTH, area.height);
|
||||
let side = Rect::new(divider.right(), area.y, side_width, area.height);
|
||||
Self::Split {
|
||||
area,
|
||||
parent,
|
||||
divider,
|
||||
side,
|
||||
}
|
||||
}
|
||||
|
||||
fn area(self) -> Rect {
|
||||
match self {
|
||||
Self::Single { area, .. } | Self::Split { area, .. } => area,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OwnedScreen {
|
||||
fn new(chat_widget: &ChatWidget, keymap: crate::keymap::PagerKeymap) -> Self {
|
||||
Self {
|
||||
@@ -105,6 +168,135 @@ impl OwnedScreen {
|
||||
self.viewport.handle_mouse_scroll(event.direction);
|
||||
true
|
||||
}
|
||||
|
||||
fn clear_last_conversation_area(&mut self) {
|
||||
self.last_conversation_area = Rect::default();
|
||||
}
|
||||
}
|
||||
|
||||
fn pane_body_area(area: Rect, show_header: bool) -> Rect {
|
||||
if !show_header {
|
||||
return area;
|
||||
}
|
||||
let header_height = PANE_HEADER_HEIGHT.min(area.height);
|
||||
Rect::new(
|
||||
area.x,
|
||||
area.y.saturating_add(header_height),
|
||||
area.width,
|
||||
area.height.saturating_sub(header_height),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_pane_header(slot: PaneSlot, focused: bool, area: Rect, buffer: &mut Buffer) {
|
||||
if area.height == 0 {
|
||||
return;
|
||||
}
|
||||
let label = match slot {
|
||||
PaneSlot::Parent => "Parent",
|
||||
PaneSlot::Side => "Side",
|
||||
};
|
||||
let line: Line<'static> = if focused {
|
||||
let parent: Span<'static> = conversation_panes::parent_pane_shortcut().into();
|
||||
let side: Span<'static> = conversation_panes::side_pane_shortcut().into();
|
||||
vec![
|
||||
" ".into(),
|
||||
label.cyan().bold(),
|
||||
" ".into(),
|
||||
format!("{} / {} focus", parent.content, side.content).dim(),
|
||||
]
|
||||
.into()
|
||||
} else {
|
||||
vec![" ".into(), label.dim()].into()
|
||||
};
|
||||
Paragraph::new(line).render(
|
||||
Rect::new(
|
||||
area.x,
|
||||
area.y,
|
||||
area.width,
|
||||
PANE_HEADER_HEIGHT.min(area.height),
|
||||
),
|
||||
buffer,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_divider(area: Rect, buffer: &mut Buffer) {
|
||||
if area.width == 0 {
|
||||
return;
|
||||
}
|
||||
for y in area.y..area.bottom() {
|
||||
buffer[(area.x, y)]
|
||||
.set_symbol("│")
|
||||
.set_style(Style::default().dim());
|
||||
}
|
||||
}
|
||||
|
||||
fn render_pane(
|
||||
panes: &mut ConversationPanes,
|
||||
slot: PaneSlot,
|
||||
area: Rect,
|
||||
show_header: bool,
|
||||
focused: PaneSlot,
|
||||
buffer: &mut Buffer,
|
||||
) -> Option<RenderedOwnedScreen> {
|
||||
render_pane_header(slot, slot == focused, area, buffer);
|
||||
let body_area = pane_body_area(area, show_header);
|
||||
let pane = panes.by_slot_mut(slot)?;
|
||||
pane.chat_widget.update_owned_screen_width(body_area.width);
|
||||
let screen = pane.owned_screen.as_mut()?;
|
||||
Some(screen.render(&pane.chat_widget, body_area, buffer))
|
||||
}
|
||||
|
||||
fn render_layout(
|
||||
panes: &mut ConversationPanes,
|
||||
layout: OwnedScreenLayout,
|
||||
focused: PaneSlot,
|
||||
buffer: &mut Buffer,
|
||||
) -> Option<RenderedOwnedScreen> {
|
||||
Clear.render(layout.area(), buffer);
|
||||
for slot in [PaneSlot::Parent, PaneSlot::Side] {
|
||||
if let Some(screen) = panes
|
||||
.by_slot_mut(slot)
|
||||
.and_then(|pane| pane.owned_screen.as_mut())
|
||||
{
|
||||
screen.clear_last_conversation_area();
|
||||
}
|
||||
}
|
||||
|
||||
match layout {
|
||||
OwnedScreenLayout::Single {
|
||||
slot,
|
||||
area,
|
||||
show_header,
|
||||
} => render_pane(panes, slot, area, show_header, focused, buffer),
|
||||
OwnedScreenLayout::Split {
|
||||
parent,
|
||||
divider,
|
||||
side,
|
||||
..
|
||||
} => {
|
||||
let parent_rendered = render_pane(
|
||||
panes,
|
||||
PaneSlot::Parent,
|
||||
parent,
|
||||
/*show_header*/ true,
|
||||
focused,
|
||||
buffer,
|
||||
);
|
||||
let side_rendered = render_pane(
|
||||
panes,
|
||||
PaneSlot::Side,
|
||||
side,
|
||||
/*show_header*/ true,
|
||||
focused,
|
||||
buffer,
|
||||
);
|
||||
render_divider(divider, buffer);
|
||||
match focused {
|
||||
PaneSlot::Parent => parent_rendered,
|
||||
PaneSlot::Side => side_rendered,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
@@ -120,7 +312,9 @@ impl App {
|
||||
}
|
||||
|
||||
pub(super) fn has_owned_screen(&self) -> bool {
|
||||
self.chat_widget.owned_screen.is_some()
|
||||
self.chat_widget
|
||||
.by_slot(PaneSlot::Parent)
|
||||
.is_some_and(|pane| pane.owned_screen.is_some())
|
||||
}
|
||||
|
||||
pub(super) fn owned_screen_push_cell(&mut self, cell: Arc<dyn HistoryCell>) {
|
||||
@@ -173,19 +367,21 @@ impl App {
|
||||
tui: &mut tui::Tui,
|
||||
event: MouseScrollEvent,
|
||||
) -> bool {
|
||||
if !self.chat_widget.no_modal_or_popup_active() {
|
||||
return false;
|
||||
for slot in [PaneSlot::Parent, PaneSlot::Side] {
|
||||
let handled = self.chat_widget.by_slot_mut(slot).is_some_and(|pane| {
|
||||
pane.chat_widget.no_modal_or_popup_active()
|
||||
&& pane
|
||||
.owned_screen
|
||||
.as_mut()
|
||||
.is_some_and(|screen| screen.handle_mouse_scroll(event))
|
||||
});
|
||||
if handled {
|
||||
tui.frame_requester()
|
||||
.schedule_frame_in(crate::tui::TARGET_FRAME_INTERVAL);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
let handled = self
|
||||
.chat_widget
|
||||
.owned_screen
|
||||
.as_mut()
|
||||
.is_some_and(|screen| screen.handle_mouse_scroll(event));
|
||||
if handled {
|
||||
tui.frame_requester()
|
||||
.schedule_frame_in(crate::tui::TARGET_FRAME_INTERVAL);
|
||||
}
|
||||
handled
|
||||
false
|
||||
}
|
||||
|
||||
pub(crate) fn sync_owned_screen_cells(&mut self) {
|
||||
@@ -203,34 +399,37 @@ impl App {
|
||||
}
|
||||
|
||||
pub(super) fn handle_owned_draw_pre_render(&mut self, tui: &mut tui::Tui) -> Result<bool> {
|
||||
if self.chat_widget.owned_screen.is_none() {
|
||||
if !self.has_owned_screen() {
|
||||
return Ok(false);
|
||||
}
|
||||
let size = tui.terminal.size()?;
|
||||
if size.width != tui.terminal.last_known_screen_size.width {
|
||||
self.chat_widget.on_terminal_resize(size.width);
|
||||
let size_changed = size != tui.terminal.last_known_screen_size;
|
||||
for slot in [PaneSlot::Parent, PaneSlot::Side] {
|
||||
if let Some(pane) = self.chat_widget.by_slot_mut(slot) {
|
||||
if size_changed {
|
||||
pane.chat_widget.refresh_status_line();
|
||||
}
|
||||
pane.transcript_reflow.clear();
|
||||
}
|
||||
}
|
||||
if size != tui.terminal.last_known_screen_size {
|
||||
self.refresh_status_line();
|
||||
}
|
||||
self.chat_widget.transcript_reflow.clear();
|
||||
tui.clear_pending_history_lines();
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub(super) fn render_owned_screen_frame(&mut self, tui: &mut tui::Tui) -> Result<Option<Rect>> {
|
||||
self.chat_widget
|
||||
.update_owned_screen_width(tui.terminal.size()?.width);
|
||||
let pane = self.chat_widget.selected_mut();
|
||||
let Some(screen) = &mut pane.owned_screen else {
|
||||
if !self.has_owned_screen() {
|
||||
return Ok(None);
|
||||
};
|
||||
let chat_widget = &pane.chat_widget;
|
||||
}
|
||||
let focused = self.chat_widget.focused_slot();
|
||||
let has_side = self.chat_widget.has_side();
|
||||
let mut rendered_area = Rect::default();
|
||||
tui.draw(/*height*/ u16::MAX, |frame| {
|
||||
rendered_area = frame.area();
|
||||
let rendered = screen.render(chat_widget, rendered_area, frame.buffer);
|
||||
if let Some((x, y)) = rendered.cursor {
|
||||
let layout = OwnedScreenLayout::new(rendered_area, has_side, focused);
|
||||
if let Some(rendered) =
|
||||
render_layout(&mut self.chat_widget, layout, focused, frame.buffer)
|
||||
&& let Some((x, y)) = rendered.cursor
|
||||
{
|
||||
frame.set_cursor_style(rendered.cursor_style);
|
||||
frame.set_cursor_position((x, y));
|
||||
}
|
||||
|
||||
@@ -2,14 +2,20 @@ use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use insta::assert_snapshot;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::text::Line;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::broadcast::error::TryRecvError;
|
||||
|
||||
use super::super::conversation_panes::ConversationPaneInit;
|
||||
use super::*;
|
||||
use crate::app_event::PaneSlot;
|
||||
use crate::chatwidget::tests::constructor::make_chatwidget_for_pane;
|
||||
use crate::chatwidget::tests::make_chatwidget_manual_with_sender;
|
||||
use crate::file_search::FileSearchManager;
|
||||
use crate::tui::MouseScrollDirection;
|
||||
use crate::tui::MouseScrollEvent;
|
||||
|
||||
@@ -26,6 +32,317 @@ impl HistoryCell for TestCell {
|
||||
}
|
||||
}
|
||||
|
||||
async fn app_with_owned_parent() -> App {
|
||||
let mut app = super::super::test_support::make_test_app().await;
|
||||
app.chat_widget.owned_screen = App::owned_screen_for_behavior(
|
||||
AltScreenBehavior::Owned,
|
||||
&app.chat_widget,
|
||||
app.keymap.pager.clone(),
|
||||
);
|
||||
app
|
||||
}
|
||||
|
||||
async fn app_with_owned_side() -> App {
|
||||
let mut app = app_with_owned_parent().await;
|
||||
let (side_widget, _side_rx) = make_chatwidget_for_pane(PaneSlot::Side).await;
|
||||
let file_search = FileSearchManager::new(
|
||||
side_widget.config_ref().cwd.to_path_buf(),
|
||||
side_widget.conversation_event_sender(),
|
||||
);
|
||||
let owned_screen = App::owned_screen_for_behavior(
|
||||
AltScreenBehavior::Owned,
|
||||
&side_widget,
|
||||
app.keymap.pager.clone(),
|
||||
);
|
||||
let result = app.chat_widget.install_side(ConversationPaneInit {
|
||||
chat_widget: side_widget,
|
||||
file_search,
|
||||
owned_screen,
|
||||
});
|
||||
assert!(result.is_ok(), "side pane should install");
|
||||
app
|
||||
}
|
||||
|
||||
fn seed_pane(app: &mut App, slot: PaneSlot, draft: &str, cells: &[&'static str]) {
|
||||
let pane = app.chat_widget.by_slot_mut(slot).expect("installed pane");
|
||||
pane.chat_widget
|
||||
.set_composer_text(draft.to_string(), Vec::new(), Vec::new());
|
||||
let screen = pane.owned_screen.as_mut().expect("owned screen");
|
||||
for text in cells {
|
||||
screen.viewport.push_cell(Arc::new(TestCell(text)));
|
||||
}
|
||||
}
|
||||
|
||||
fn render_app(app: &mut App, width: u16, height: u16) -> Terminal<TestBackend> {
|
||||
let focused = app.chat_widget.focused_slot();
|
||||
let has_side = app.chat_widget.has_side();
|
||||
let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("create terminal");
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
let layout = OwnedScreenLayout::new(frame.area(), has_side, focused);
|
||||
if let Some(rendered) =
|
||||
render_layout(&mut app.chat_widget, layout, focused, frame.buffer_mut())
|
||||
&& let Some((x, y)) = rendered.cursor
|
||||
{
|
||||
frame.set_cursor_position((x, y));
|
||||
}
|
||||
})
|
||||
.expect("render owned panes");
|
||||
terminal
|
||||
}
|
||||
|
||||
fn is_following_bottom(app: &App, slot: PaneSlot) -> bool {
|
||||
app.chat_widget
|
||||
.by_slot(slot)
|
||||
.and_then(|pane| pane.owned_screen.as_ref())
|
||||
.expect("owned screen")
|
||||
.viewport
|
||||
.is_following_bottom()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responsive_layout_uses_expected_threshold_and_parent_bias() {
|
||||
let area_narrow = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 82, /*height*/ 20,
|
||||
);
|
||||
let narrow = OwnedScreenLayout::new(area_narrow, /*has_side*/ true, PaneSlot::Side);
|
||||
assert!(matches!(
|
||||
narrow,
|
||||
OwnedScreenLayout::Single {
|
||||
slot: PaneSlot::Side,
|
||||
show_header: true,
|
||||
..
|
||||
}
|
||||
));
|
||||
assert_eq!(
|
||||
OwnedScreenLayout::new(area_narrow, /*has_side*/ false, PaneSlot::Side),
|
||||
OwnedScreenLayout::Single {
|
||||
slot: PaneSlot::Parent,
|
||||
area: area_narrow,
|
||||
show_header: false,
|
||||
}
|
||||
);
|
||||
for (width, expected_parent_width) in [(83, 41), (84, 42)] {
|
||||
let area = Rect::new(/*x*/ 0, /*y*/ 0, width, /*height*/ 20);
|
||||
let OwnedScreenLayout::Split {
|
||||
parent,
|
||||
divider,
|
||||
side,
|
||||
..
|
||||
} = OwnedScreenLayout::new(area, /*has_side*/ true, PaneSlot::Parent)
|
||||
else {
|
||||
panic!("width {width} should split");
|
||||
};
|
||||
assert_eq!(parent.width, expected_parent_width);
|
||||
assert_eq!(
|
||||
divider,
|
||||
Rect::new(
|
||||
parent.right(),
|
||||
/*y*/ 0,
|
||||
/*width*/ 1,
|
||||
/*height*/ 20
|
||||
)
|
||||
);
|
||||
assert_eq!(side.width, 41);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn single_pane_app_layout_preserves_existing_owned_render() {
|
||||
let mut app = app_with_owned_parent().await;
|
||||
seed_pane(
|
||||
&mut app,
|
||||
PaneSlot::Parent,
|
||||
"draft sentinel",
|
||||
&["committed response"],
|
||||
);
|
||||
|
||||
let terminal = render_app(&mut app, /*width*/ 50, /*height*/ 10);
|
||||
|
||||
assert_snapshot!(terminal.backend(), @r###"
|
||||
"committed response "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
"› draft sentinel "
|
||||
" "
|
||||
" gpt-5.5 default · /tmp/project "
|
||||
"###);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn renders_wide_parent_left_and_side_right() {
|
||||
let mut app = app_with_owned_side().await;
|
||||
seed_pane(
|
||||
&mut app,
|
||||
PaneSlot::Parent,
|
||||
"parent draft",
|
||||
&["parent transcript"],
|
||||
);
|
||||
seed_pane(&mut app, PaneSlot::Side, "side draft", &["side transcript"]);
|
||||
|
||||
let terminal = render_app(&mut app, /*width*/ 83, /*height*/ 16);
|
||||
|
||||
assert_snapshot!("owned_screen_wide_split_parent_focused", terminal.backend());
|
||||
let buffer = terminal.backend().buffer();
|
||||
assert!(
|
||||
buffer[(1, 0)]
|
||||
.style()
|
||||
.add_modifier
|
||||
.contains(ratatui::style::Modifier::BOLD)
|
||||
);
|
||||
assert!(
|
||||
buffer[(43, 0)]
|
||||
.style()
|
||||
.add_modifier
|
||||
.contains(ratatui::style::Modifier::DIM)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn narrow_layout_renders_only_the_focused_side() {
|
||||
let mut app = app_with_owned_side().await;
|
||||
seed_pane(
|
||||
&mut app,
|
||||
PaneSlot::Parent,
|
||||
"parent draft",
|
||||
&["PARENT MUST BE HIDDEN"],
|
||||
);
|
||||
seed_pane(&mut app, PaneSlot::Side, "side draft", &["side transcript"]);
|
||||
assert!(app.chat_widget.focus(PaneSlot::Side));
|
||||
|
||||
let terminal = render_app(&mut app, /*width*/ 82, /*height*/ 14);
|
||||
|
||||
assert_snapshot!("owned_screen_narrow_side_focused", terminal.backend());
|
||||
assert_eq!(
|
||||
app.chat_widget
|
||||
.by_slot(PaneSlot::Parent)
|
||||
.and_then(|pane| pane.owned_screen.as_ref())
|
||||
.map(|screen| screen.last_conversation_area),
|
||||
Some(Rect::default())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminal_cursor_tracks_only_the_focused_pane() {
|
||||
let mut app = app_with_owned_side().await;
|
||||
seed_pane(&mut app, PaneSlot::Parent, "parent", &[]);
|
||||
seed_pane(&mut app, PaneSlot::Side, "side", &[]);
|
||||
|
||||
let mut terminal = render_app(&mut app, /*width*/ 83, /*height*/ 8);
|
||||
let parent_cursor = terminal.get_cursor_position().expect("parent cursor");
|
||||
assert!(parent_cursor.x < 41);
|
||||
|
||||
assert!(app.chat_widget.focus(PaneSlot::Side));
|
||||
terminal = render_app(&mut app, /*width*/ 83, /*height*/ 8);
|
||||
let side_cursor = terminal.get_cursor_position().expect("side cursor");
|
||||
assert!(side_cursor.x > 41);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mouse_wheel_routes_by_pointer_without_changing_focus() {
|
||||
let mut app = app_with_owned_side().await;
|
||||
let cells = [
|
||||
"one", "two", "three", "four", "five", "six", "seven", "eight",
|
||||
];
|
||||
seed_pane(&mut app, PaneSlot::Parent, "", &cells);
|
||||
seed_pane(&mut app, PaneSlot::Side, "", &cells);
|
||||
let _terminal = render_app(&mut app, /*width*/ 83, /*height*/ 8);
|
||||
let mut tui = crate::tui::test_support::make_test_tui().expect("create test TUI");
|
||||
|
||||
assert!(app.handle_owned_screen_mouse_scroll(
|
||||
&mut tui,
|
||||
MouseScrollEvent {
|
||||
direction: MouseScrollDirection::Up,
|
||||
column: 2,
|
||||
row: 2,
|
||||
},
|
||||
));
|
||||
assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Parent);
|
||||
assert!(!is_following_bottom(&app, PaneSlot::Parent));
|
||||
assert!(is_following_bottom(&app, PaneSlot::Side));
|
||||
|
||||
assert!(app.handle_owned_screen_mouse_scroll(
|
||||
&mut tui,
|
||||
MouseScrollEvent {
|
||||
direction: MouseScrollDirection::Up,
|
||||
column: 44,
|
||||
row: 2,
|
||||
},
|
||||
));
|
||||
assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Parent);
|
||||
assert!(!is_following_bottom(&app, PaneSlot::Side));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn narrow_side_clears_parent_hit_area_before_wheel_routing() {
|
||||
let mut app = app_with_owned_side().await;
|
||||
let cells = ["one", "two", "three", "four", "five", "six"];
|
||||
seed_pane(&mut app, PaneSlot::Parent, "", &cells);
|
||||
seed_pane(&mut app, PaneSlot::Side, "", &cells);
|
||||
let _wide = render_app(&mut app, /*width*/ 83, /*height*/ 7);
|
||||
assert!(app.chat_widget.focus(PaneSlot::Side));
|
||||
let _narrow = render_app(&mut app, /*width*/ 82, /*height*/ 7);
|
||||
let mut tui = crate::tui::test_support::make_test_tui().expect("create test TUI");
|
||||
let side_area = app
|
||||
.chat_widget
|
||||
.by_slot(PaneSlot::Side)
|
||||
.and_then(|pane| pane.owned_screen.as_ref())
|
||||
.expect("side screen")
|
||||
.last_conversation_area;
|
||||
assert!(side_area.height > 0);
|
||||
|
||||
assert!(app.handle_owned_screen_mouse_scroll(
|
||||
&mut tui,
|
||||
MouseScrollEvent {
|
||||
direction: MouseScrollDirection::Up,
|
||||
column: side_area.x,
|
||||
row: side_area.y,
|
||||
},
|
||||
));
|
||||
assert!(is_following_bottom(&app, PaneSlot::Parent));
|
||||
assert!(!is_following_bottom(&app, PaneSlot::Side));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resizing_between_split_and_focused_only_preserves_pane_state() {
|
||||
let mut app = app_with_owned_side().await;
|
||||
let cells = [
|
||||
"one", "two", "three", "four", "five", "six", "seven", "eight",
|
||||
];
|
||||
seed_pane(&mut app, PaneSlot::Parent, "parent draft", &cells);
|
||||
seed_pane(&mut app, PaneSlot::Side, "side draft", &cells);
|
||||
let _wide = render_app(&mut app, /*width*/ 83, /*height*/ 8);
|
||||
let parent_screen = app
|
||||
.chat_widget
|
||||
.by_slot_mut(PaneSlot::Parent)
|
||||
.and_then(|pane| pane.owned_screen.as_mut())
|
||||
.expect("parent screen");
|
||||
assert!(parent_screen.handle_mouse_scroll(MouseScrollEvent {
|
||||
direction: MouseScrollDirection::Up,
|
||||
column: 2,
|
||||
row: 2,
|
||||
}));
|
||||
assert!(app.chat_widget.focus(PaneSlot::Side));
|
||||
|
||||
let _narrow = render_app(&mut app, /*width*/ 82, /*height*/ 8);
|
||||
let _wide_again = render_app(&mut app, /*width*/ 83, /*height*/ 8);
|
||||
|
||||
assert_eq!(
|
||||
app.chat_widget
|
||||
.by_slot(PaneSlot::Parent)
|
||||
.expect("parent pane")
|
||||
.composer_text_with_pending(),
|
||||
"parent draft"
|
||||
);
|
||||
assert_eq!(app.chat_widget.composer_text_with_pending(), "side draft");
|
||||
assert!(!is_following_bottom(&app, PaneSlot::Parent));
|
||||
assert!(is_following_bottom(&app, PaneSlot::Side));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn renders_committed_conversation_above_fixed_composer() {
|
||||
let (mut chat_widget, _app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
source: tui/src/app/owned_screen_tests.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" Side ⌥ + 1 / ⌥ + 2 focus "
|
||||
"side transcript "
|
||||
" "
|
||||
"╭───────────────────────────────────────╮ "
|
||||
"│ >_ OpenAI Codex (v0.0.0) │ "
|
||||
"│ │ "
|
||||
"│ model: loading /model to change │ "
|
||||
"│ directory: /tmp/project │ "
|
||||
"╰───────────────────────────────────────╯ "
|
||||
" "
|
||||
" "
|
||||
"› side draft "
|
||||
" "
|
||||
" gpt-5.5 default · /tmp/project "
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
source: tui/src/app/owned_screen_tests.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" Parent ⌥ + 1 / ⌥ + 2 focus │ Side "
|
||||
"parent transcript │side transcript "
|
||||
" │ "
|
||||
" │╭───────────────────────────────────────╮"
|
||||
" ││ >_ OpenAI Codex (v0.0.0) │"
|
||||
" ││ │"
|
||||
" ││ model: loading /model to change │"
|
||||
" ││ directory: /tmp/project │"
|
||||
" │╰───────────────────────────────────────╯"
|
||||
" │ "
|
||||
" │ "
|
||||
" │ "
|
||||
" │ "
|
||||
"› parent draft │› side draft "
|
||||
" │ "
|
||||
" gpt-5.5 default · /tmp/project │ gpt-5.5 default · /tmp/project "
|
||||
Reference in New Issue
Block a user