feat(tui): render side conversation panes

This commit is contained in:
Felipe Coury
2026-07-04 17:13:59 -03:00
parent ecce09c53e
commit 4aa82bb77d
6 changed files with 728 additions and 28 deletions

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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));
}

View File

@@ -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;

View File

@@ -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 "

View File

@@ -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 "