feat(tui): resize owned conversation panes

This commit is contained in:
Felipe Coury
2026-07-04 22:30:36 -03:00
parent 3cb7cef03d
commit c899573f99
16 changed files with 773 additions and 213 deletions
+22 -2
View File
@@ -215,6 +215,7 @@ mod history_ui;
mod input;
mod loaded_threads;
mod owned_screen;
mod owned_screen_resize;
mod pending_interactive_replay;
mod pets;
mod platform_actions;
@@ -1269,11 +1270,29 @@ See the Codex keymap documentation for supported actions and examples."
app_server: &mut AppServerSession,
event: TuiEvent,
) -> Result<AppRunControl> {
match &event {
TuiEvent::Resize => {
self.chat_widget.cancel_owned_screen_split_drag();
}
TuiEvent::FocusLost => {
if self.chat_widget.cancel_owned_screen_split_drag() {
tui.frame_requester().schedule_frame();
}
}
TuiEvent::Key(_)
| TuiEvent::Paste(_)
| TuiEvent::MouseScroll(_)
| TuiEvent::MousePrimary(_)
| TuiEvent::Draw => {}
}
if matches!(event, TuiEvent::Draw | TuiEvent::Resize) {
self.handle_draw_pre_render(tui)?;
}
if self.overlay.is_some() {
if self.chat_widget.cancel_owned_screen_split_drag() {
tui.frame_requester().schedule_frame();
}
let _ = self.handle_backtrack_overlay_event(tui, event).await?;
} else {
match event {
@@ -1303,9 +1322,10 @@ See the Codex keymap documentation for supported actions and examples."
.await;
}
}
TuiEvent::MousePrimaryPress(event) => {
self.handle_owned_screen_mouse_primary_press(tui, event);
TuiEvent::MousePrimary(event) => {
self.handle_owned_screen_mouse_primary(tui, event);
}
TuiEvent::FocusLost => {}
TuiEvent::Draw | TuiEvent::Resize => {
if self.backtrack_render_pending {
self.rebuild_transcript_after_backtrack(tui)?;
@@ -16,6 +16,9 @@ use tokio::sync::mpsc;
use super::InitialHistoryReplayBuffer;
use super::owned_screen::OwnedScreen;
use super::owned_screen_resize::OwnedScreenLayout;
use super::owned_screen_resize::OwnedScreenSplitState;
use super::owned_screen_resize::PaneSplitPreference;
use super::thread_events::ThreadBufferedEvent;
use crate::app_event::ConversationOrigin;
use crate::app_event::PaneSlot;
@@ -25,6 +28,7 @@ use crate::history_cell::HistoryCell;
use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::transcript_reflow::TranscriptReflowState;
use crate::tui::MousePrimaryEvent;
pub(super) fn parent_pane_shortcut() -> KeyBinding {
key_hint::alt(KeyCode::Char('1'))
@@ -125,6 +129,7 @@ pub(crate) struct ConversationPanes {
side: Option<ConversationPane>,
focused: PaneSlot,
dispatch: Option<PaneSlot>,
owned_screen_split: OwnedScreenSplitState,
}
// Rendering and `/side` lifecycle consume the remaining APIs in the next stack layers.
@@ -136,6 +141,7 @@ impl ConversationPanes {
side: None,
focused: PaneSlot::Parent,
dispatch: None,
owned_screen_split: OwnedScreenSplitState::default(),
})
}
@@ -173,6 +179,7 @@ impl ConversationPanes {
}
pub(super) fn take_side(&mut self) -> Option<ConversationPane> {
self.owned_screen_split.clear_rendered_layout();
if self.focused == PaneSlot::Side {
self.focus(PaneSlot::Parent);
}
@@ -186,6 +193,26 @@ impl ConversationPanes {
self.side.take()
}
pub(super) fn owned_screen_split_preference(&self) -> PaneSplitPreference {
self.owned_screen_split.preference()
}
pub(super) fn record_owned_screen_layout(&mut self, layout: OwnedScreenLayout) {
self.owned_screen_split.record_layout(layout);
}
pub(super) fn handle_owned_screen_split_mouse(&mut self, event: MousePrimaryEvent) -> bool {
self.owned_screen_split.handle_mouse(event)
}
pub(super) fn owned_screen_split_is_dragging(&self) -> bool {
self.owned_screen_split.is_dragging()
}
pub(super) fn cancel_owned_screen_split_drag(&mut self) -> bool {
self.owned_screen_split.cancel_drag()
}
pub(super) fn by_slot(&self, slot: PaneSlot) -> Option<&ConversationPane> {
match slot {
PaneSlot::Parent => Some(&self.parent),
+46 -65
View File
@@ -18,15 +18,14 @@ use ratatui::widgets::Clear;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use super::owned_screen_resize::OwnedScreenLayout;
use super::*;
use crate::AltScreenBehavior;
use crate::key_hint::is_plain_text_key_event;
use crate::tui::MousePrimaryPressEvent;
use crate::tui::MousePrimaryEvent;
use crate::tui::MousePrimaryEventKind;
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 {
@@ -41,59 +40,6 @@ 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 {
@@ -231,14 +177,19 @@ fn render_pane_header(slot: PaneSlot, focused: bool, area: Rect, buffer: &mut Bu
);
}
fn render_divider(area: Rect, buffer: &mut Buffer) {
fn render_divider(area: Rect, active: bool, buffer: &mut Buffer) {
if area.width == 0 {
return;
}
let style = if active {
Style::default().cyan().bold()
} else {
Style::default().dim()
};
for y in area.y..area.bottom() {
buffer[(area.x, y)]
.set_symbol("│")
.set_style(Style::default().dim());
.set_symbol(if active { "┃" } else { "│" })
.set_style(style);
}
}
@@ -265,6 +216,8 @@ fn render_layout(
focused: PaneSlot,
buffer: &mut Buffer,
) -> Option<RenderedOwnedScreen> {
panes.record_owned_screen_layout(layout);
let divider_active = panes.owned_screen_split_is_dragging();
Clear.render(layout.area(), buffer);
for slot in [PaneSlot::Parent, PaneSlot::Side] {
if let Some(screen) = panes
@@ -303,7 +256,7 @@ fn render_layout(
focused,
buffer,
);
render_divider(divider, buffer);
render_divider(divider, divider_active, buffer);
match focused {
PaneSlot::Parent => parent_rendered,
PaneSlot::Side => side_rendered,
@@ -380,6 +333,9 @@ impl App {
tui: &mut tui::Tui,
event: MouseScrollEvent,
) -> bool {
if self.chat_widget.owned_screen_split_is_dragging() {
return true;
}
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()
@@ -397,12 +353,33 @@ impl App {
false
}
pub(super) fn handle_owned_screen_mouse_primary_press(
pub(super) fn handle_owned_screen_mouse_primary(
&mut self,
tui: &mut tui::Tui,
event: MousePrimaryPressEvent,
event: MousePrimaryEvent,
) -> bool {
if !self.chat_widget.has_side() {
if self.overlay.is_some()
|| !self.chat_widget.has_side()
|| !self.chat_widget.no_modal_or_popup_active()
{
if self.chat_widget.cancel_owned_screen_split_drag() {
tui.frame_requester().schedule_frame();
}
return false;
}
if self.chat_widget.handle_owned_screen_split_mouse(event) {
match event.kind {
MousePrimaryEventKind::Drag => tui
.frame_requester()
.schedule_frame_in(crate::tui::TARGET_FRAME_INTERVAL),
MousePrimaryEventKind::Press | MousePrimaryEventKind::Release => {
tui.frame_requester().schedule_frame();
}
}
return true;
}
if event.kind != MousePrimaryEventKind::Press {
return false;
}
@@ -474,12 +451,16 @@ impl App {
if !self.has_owned_screen() {
return Ok(None);
}
if !self.chat_widget.no_modal_or_popup_active() {
self.chat_widget.cancel_owned_screen_split_drag();
}
let focused = self.chat_widget.focused_slot();
let has_side = self.chat_widget.has_side();
let split_preference = self.chat_widget.owned_screen_split_preference();
let mut rendered_area = Rect::default();
tui.draw(/*height*/ u16::MAX, |frame| {
rendered_area = frame.area();
let layout = OwnedScreenLayout::new(rendered_area, has_side, focused);
let layout = OwnedScreenLayout::new(rendered_area, has_side, focused, split_preference);
if let Some(rendered) =
render_layout(&mut self.chat_widget, layout, focused, frame.buffer)
&& let Some((x, y)) = rendered.cursor
+219
View File
@@ -0,0 +1,219 @@
//! Layout and pointer interaction state for application-owned conversation panes.
use ratatui::layout::Position;
use ratatui::layout::Rect;
use crate::app_event::PaneSlot;
use crate::tui::MousePrimaryEvent;
use crate::tui::MousePrimaryEventKind;
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;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) struct PaneSplitPreference {
parent_width: u16,
available_width: u16,
}
impl Default for PaneSplitPreference {
fn default() -> Self {
Self {
parent_width: 1,
available_width: 2,
}
}
}
impl PaneSplitPreference {
fn parent_width(self, available_width: u16) -> u16 {
let denominator = u32::from(self.available_width.max(/*other*/ 1));
let numerator = u32::from(self.parent_width) * u32::from(available_width);
((numerator + denominator / 2) / denominator) as u16
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum OwnedScreenLayout {
Single {
slot: PaneSlot,
area: Rect,
show_header: bool,
},
Split {
area: Rect,
parent: Rect,
divider: Rect,
side: Rect,
},
}
impl OwnedScreenLayout {
pub(super) fn new(
area: Rect,
has_side: bool,
focused: PaneSlot,
preference: PaneSplitPreference,
) -> 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 available_width = area.width.saturating_sub(SPLIT_DIVIDER_WIDTH);
let max_parent_width = available_width.saturating_sub(MIN_SPLIT_PANE_WIDTH);
let parent_width = preference
.parent_width(available_width)
.clamp(MIN_SPLIT_PANE_WIDTH, max_parent_width);
let side_width = available_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,
}
}
pub(super) fn area(self) -> Rect {
match self {
Self::Single { area, .. } | Self::Split { area, .. } => area,
}
}
fn split_geometry(self) -> Option<SplitGeometry> {
match self {
Self::Single { .. } => None,
Self::Split { area, divider, .. } => Some(SplitGeometry { area, divider }),
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct SplitGeometry {
area: Rect,
divider: Rect,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
enum SplitInteraction {
#[default]
Idle,
Dragging {
origin_column: u16,
initial_preference: PaneSplitPreference,
},
}
#[derive(Debug, Default)]
pub(super) struct OwnedScreenSplitState {
preference: PaneSplitPreference,
interaction: SplitInteraction,
geometry: Option<SplitGeometry>,
}
impl OwnedScreenSplitState {
pub(super) fn preference(&self) -> PaneSplitPreference {
self.preference
}
pub(super) fn record_layout(&mut self, layout: OwnedScreenLayout) {
let next_geometry = layout.split_geometry();
let area_changed = self
.geometry
.zip(next_geometry)
.is_some_and(|(current, next)| current.area != next.area);
if next_geometry.is_none() || area_changed {
self.interaction = SplitInteraction::Idle;
}
self.geometry = next_geometry;
}
pub(super) fn clear_rendered_layout(&mut self) {
self.interaction = SplitInteraction::Idle;
self.geometry = None;
}
pub(super) fn is_dragging(&self) -> bool {
matches!(self.interaction, SplitInteraction::Dragging { .. })
}
pub(super) fn cancel_drag(&mut self) -> bool {
let was_dragging = self.is_dragging();
self.interaction = SplitInteraction::Idle;
was_dragging
}
pub(super) fn handle_mouse(&mut self, event: MousePrimaryEvent) -> bool {
match event.kind {
MousePrimaryEventKind::Press => {
self.interaction = SplitInteraction::Idle;
let position = Position::new(event.column, event.row);
if self.geometry.is_some_and(|geometry| {
geometry.area.width > MIN_SPLIT_WIDTH && geometry.divider.contains(position)
}) {
self.interaction = SplitInteraction::Dragging {
origin_column: event.column,
initial_preference: self.preference,
};
true
} else {
false
}
}
MousePrimaryEventKind::Drag if self.is_dragging() => {
self.update_preference(event.column);
true
}
MousePrimaryEventKind::Release if self.is_dragging() => {
let SplitInteraction::Dragging {
origin_column,
initial_preference,
} = self.interaction
else {
return false;
};
if event.column == origin_column {
self.preference = initial_preference;
} else {
self.update_preference(event.column);
}
self.interaction = SplitInteraction::Idle;
true
}
MousePrimaryEventKind::Drag | MousePrimaryEventKind::Release => false,
}
}
fn update_preference(&mut self, column: u16) {
let Some(geometry) = self.geometry else {
return;
};
let available_width = geometry.area.width.saturating_sub(SPLIT_DIVIDER_WIDTH);
let max_parent_width = available_width.saturating_sub(MIN_SPLIT_PANE_WIDTH);
let parent_width = column
.saturating_sub(geometry.area.x)
.clamp(MIN_SPLIT_PANE_WIDTH, max_parent_width);
self.preference = PaneSplitPreference {
parent_width,
available_width,
};
}
}
#[cfg(test)]
#[path = "owned_screen_resize_tests.rs"]
mod tests;
@@ -0,0 +1,194 @@
use pretty_assertions::assert_eq;
use super::*;
fn split_widths(layout: OwnedScreenLayout) -> (u16, u16, u16) {
let OwnedScreenLayout::Split {
parent,
divider,
side,
..
} = layout
else {
panic!("expected split layout");
};
(parent.width, divider.x, side.width)
}
#[test]
fn layout_uses_threshold_parent_bias_and_nonzero_origin() {
let preference = PaneSplitPreference::default();
let narrow = Rect::new(
/*x*/ 7, /*y*/ 3, /*width*/ 82, /*height*/ 20,
);
assert_eq!(
OwnedScreenLayout::new(narrow, /*has_side*/ true, PaneSlot::Side, preference),
OwnedScreenLayout::Single {
slot: PaneSlot::Side,
area: narrow,
show_header: true,
}
);
for (width, expected) in [(83, (41, 48, 41)), (84, (42, 49, 41))] {
let area = Rect::new(/*x*/ 7, /*y*/ 3, width, /*height*/ 20);
assert_eq!(
split_widths(OwnedScreenLayout::new(
area,
/*has_side*/ true,
PaneSlot::Parent,
preference,
)),
expected
);
}
}
#[test]
fn drag_lifecycle_clamps_and_preserves_the_preferred_ratio() {
let mut state = OwnedScreenSplitState::default();
let initial_preference = state.preference();
let wide = Rect::new(
/*x*/ 10, /*y*/ 2, /*width*/ 120, /*height*/ 20,
);
state.record_layout(OwnedScreenLayout::new(
wide,
/*has_side*/ true,
PaneSlot::Parent,
state.preference(),
));
assert!(!state.handle_mouse(MousePrimaryEvent {
kind: MousePrimaryEventKind::Drag,
column: 80,
row: 4,
}));
assert!(!state.handle_mouse(MousePrimaryEvent {
kind: MousePrimaryEventKind::Press,
column: 20,
row: 4,
}));
assert!(state.handle_mouse(MousePrimaryEvent {
kind: MousePrimaryEventKind::Press,
column: 70,
row: 4,
}));
assert!(state.handle_mouse(MousePrimaryEvent {
kind: MousePrimaryEventKind::Drag,
column: 10,
row: 4,
}));
assert_eq!(
split_widths(OwnedScreenLayout::new(
wide,
/*has_side*/ true,
PaneSlot::Parent,
state.preference(),
)),
(41, 51, 78)
);
assert!(state.handle_mouse(MousePrimaryEvent {
kind: MousePrimaryEventKind::Release,
column: 70,
row: 4,
}));
assert_eq!(state.preference(), initial_preference);
assert!(state.handle_mouse(MousePrimaryEvent {
kind: MousePrimaryEventKind::Press,
column: 70,
row: 4,
}));
assert!(state.handle_mouse(MousePrimaryEvent {
kind: MousePrimaryEventKind::Drag,
column: 80,
row: 4,
}));
assert!(state.handle_mouse(MousePrimaryEvent {
kind: MousePrimaryEventKind::Release,
column: 80,
row: u16::MAX,
}));
assert_eq!(
split_widths(OwnedScreenLayout::new(
wide,
/*has_side*/ true,
PaneSlot::Parent,
state.preference(),
)),
(70, 80, 49)
);
assert!(!state.handle_mouse(MousePrimaryEvent {
kind: MousePrimaryEventKind::Drag,
column: 70,
row: 4,
}));
let preference = state.preference();
let constrained = Rect::new(
/*x*/ 10, /*y*/ 2, /*width*/ 90, /*height*/ 20,
);
let constrained_layout = OwnedScreenLayout::new(
constrained,
/*has_side*/ true,
PaneSlot::Parent,
preference,
);
assert_eq!(split_widths(constrained_layout), (48, 58, 41));
state.record_layout(constrained_layout);
assert!(state.handle_mouse(MousePrimaryEvent {
kind: MousePrimaryEventKind::Press,
column: 58,
row: 4,
}));
assert!(state.handle_mouse(MousePrimaryEvent {
kind: MousePrimaryEventKind::Release,
column: 58,
row: 4,
}));
assert_eq!(state.preference(), preference);
assert!(state.handle_mouse(MousePrimaryEvent {
kind: MousePrimaryEventKind::Press,
column: 58,
row: 4,
}));
let narrow = Rect::new(
/*x*/ 10, /*y*/ 2, /*width*/ 82, /*height*/ 20,
);
state.record_layout(OwnedScreenLayout::new(
narrow,
/*has_side*/ true,
PaneSlot::Parent,
state.preference(),
));
assert!(!state.is_dragging());
assert_eq!(state.preference(), preference);
assert_eq!(
split_widths(OwnedScreenLayout::new(
wide,
/*has_side*/ true,
PaneSlot::Parent,
state.preference(),
)),
(70, 80, 49)
);
let fixed = Rect::new(
/*x*/ 10,
/*y*/ 2,
MIN_SPLIT_WIDTH,
/*height*/ 20,
);
state.record_layout(OwnedScreenLayout::new(
fixed,
/*has_side*/ true,
PaneSlot::Parent,
state.preference(),
));
assert!(!state.handle_mouse(MousePrimaryEvent {
kind: MousePrimaryEventKind::Press,
column: 51,
row: 4,
}));
}
+145 -115
View File
@@ -17,7 +17,8 @@ use crate::chatwidget::tests::constructor::make_chatwidget_for_pane;
use crate::chatwidget::tests::constructor::make_chatwidget_for_pane_with_sender;
use crate::chatwidget::tests::make_chatwidget_manual_with_sender;
use crate::file_search::FileSearchManager;
use crate::tui::MousePrimaryPressEvent;
use crate::tui::MousePrimaryEvent;
use crate::tui::MousePrimaryEventKind;
use crate::tui::MouseScrollDirection;
use crate::tui::MouseScrollEvent;
use codex_app_server_protocol::ConfigWarningNotification;
@@ -95,10 +96,11 @@ fn seed_pane(app: &mut App, slot: PaneSlot, draft: &str, cells: &[&'static str])
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 split_preference = app.chat_widget.owned_screen_split_preference();
let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("create terminal");
terminal
.draw(|frame| {
let layout = OwnedScreenLayout::new(frame.area(), has_side, focused);
let layout = OwnedScreenLayout::new(frame.area(), has_side, focused, split_preference);
if let Some(rendered) =
render_layout(&mut app.chat_widget, layout, focused, frame.buffer_mut())
&& let Some((x, y)) = rendered.cursor
@@ -119,51 +121,12 @@ fn is_following_bottom(app: &App, slot: PaneSlot) -> bool {
.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);
}
fn primary_event(kind: MousePrimaryEventKind, column: u16, row: u16) -> MousePrimaryEvent {
MousePrimaryEvent { kind, column, row }
}
fn primary_press(column: u16, row: u16) -> MousePrimaryEvent {
primary_event(MousePrimaryEventKind::Press, column, row)
}
#[tokio::test]
@@ -382,14 +345,14 @@ async fn primary_press_focuses_closed_parent_without_enabling_input() {
.expect("parent screen")
.last_pane_area;
let mut tui = crate::tui::test_support::make_test_tui().expect("create test TUI");
assert!(app.handle_owned_screen_mouse_primary_press(
&mut tui,
MousePrimaryPressEvent {
column: parent_area.x,
row: parent_area.y,
},
));
assert!(
app.handle_owned_screen_mouse_primary(
&mut tui,
primary_press(parent_area.x, parent_area.y),
)
);
assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Parent);
let split_preference = app.chat_widget.owned_screen_split_preference();
let mut rendered_cursor = None;
let mut terminal =
Terminal::new(TestBackend::new(/*width*/ 83, /*height*/ 12)).expect("create terminal");
@@ -398,7 +361,12 @@ async fn primary_press_focuses_closed_parent_without_enabling_input() {
rendered_cursor = Some(
render_layout(
&mut app.chat_widget,
OwnedScreenLayout::new(frame.area(), /*has_side*/ true, PaneSlot::Parent),
OwnedScreenLayout::new(
frame.area(),
/*has_side*/ true,
PaneSlot::Parent,
split_preference,
),
PaneSlot::Parent,
frame.buffer_mut(),
)
@@ -442,10 +410,9 @@ async fn narrow_layout_renders_only_the_focused_side() {
);
let mut tui = crate::tui::test_support::make_test_tui().expect("create test TUI");
assert!(app.handle_owned_screen_mouse_primary_press(
&mut tui,
MousePrimaryPressEvent { column: 2, row: 2 },
));
assert!(
app.handle_owned_screen_mouse_primary(&mut tui, primary_press(/*column*/ 2, /*row*/ 2),)
);
assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Side);
}
@@ -513,47 +480,34 @@ async fn primary_press_focuses_visible_pane_regions() {
for (parent_position, side_position) in region_pairs {
app.backtrack.primed = true;
assert!(app.handle_owned_screen_mouse_primary_press(
assert!(app.handle_owned_screen_mouse_primary(
&mut tui,
MousePrimaryPressEvent {
column: side_position.0,
row: side_position.1,
},
primary_press(side_position.0, side_position.1),
));
assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Side);
assert!(!app.backtrack.primed);
assert!(app.handle_owned_screen_mouse_primary_press(
assert!(app.handle_owned_screen_mouse_primary(
&mut tui,
MousePrimaryPressEvent {
column: parent_position.0,
row: parent_position.1,
},
primary_press(parent_position.0, parent_position.1),
));
assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Parent);
}
app.backtrack.primed = true;
assert!(app.handle_owned_screen_mouse_primary_press(
&mut tui,
MousePrimaryPressEvent {
column: parent_area.x,
row: parent_area.y,
},
));
assert!(
app.handle_owned_screen_mouse_primary(
&mut tui,
primary_press(parent_area.x, parent_area.y),
)
);
assert!(!app.backtrack.primed);
assert!(!app.handle_owned_screen_mouse_primary_press(
assert!(!app.handle_owned_screen_mouse_primary(
&mut tui,
MousePrimaryPressEvent {
column: parent_area.right(),
row: parent_area.y,
},
primary_press(parent_area.right(), parent_area.y),
));
assert!(!app.handle_owned_screen_mouse_primary_press(
assert!(!app.handle_owned_screen_mouse_primary(
&mut tui,
MousePrimaryPressEvent {
column: side_area.right(),
row: side_area.y,
},
primary_press(side_area.right(), side_area.y),
));
assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Parent);
}
@@ -609,12 +563,9 @@ async fn primary_press_preserves_pane_drafts_and_cursors() {
let _parent_terminal = render_app(&mut app, /*width*/ 83, /*height*/ 16);
let mut tui = crate::tui::test_support::make_test_tui().expect("create test TUI");
assert!(app.handle_owned_screen_mouse_primary_press(
assert!(app.handle_owned_screen_mouse_primary(
&mut tui,
MousePrimaryPressEvent {
column: side_area.x,
row: side_conversation_area.bottom(),
},
primary_press(side_area.x, side_conversation_area.bottom()),
));
let mut side_terminal = render_app(&mut app, /*width*/ 83, /*height*/ 16);
let side_cursor = side_terminal.get_cursor_position().expect("side cursor");
@@ -650,12 +601,9 @@ async fn primary_press_preserves_pane_drafts_and_cursors() {
("parent draft".to_string(), "side draft".to_string())
);
assert!(app.handle_owned_screen_mouse_primary_press(
assert!(app.handle_owned_screen_mouse_primary(
&mut tui,
MousePrimaryPressEvent {
column: parent_area.x,
row: parent_conversation_area.bottom(),
},
primary_press(parent_area.x, parent_conversation_area.bottom()),
));
let mut parent_terminal = render_app(&mut app, /*width*/ 83, /*height*/ 16);
assert_eq!(
@@ -664,12 +612,9 @@ async fn primary_press_preserves_pane_drafts_and_cursors() {
.expect("restored parent cursor"),
parent_cursor
);
assert!(app.handle_owned_screen_mouse_primary_press(
assert!(app.handle_owned_screen_mouse_primary(
&mut tui,
MousePrimaryPressEvent {
column: side_area.x,
row: side_conversation_area.bottom(),
},
primary_press(side_area.x, side_conversation_area.bottom()),
));
let mut side_terminal = render_app(&mut app, /*width*/ 83, /*height*/ 16);
assert_eq!(
@@ -696,28 +641,113 @@ async fn primary_press_does_not_switch_behind_overlay_or_popup() {
Vec::new(),
app.keymap.pager.clone(),
));
assert!(!app.handle_owned_screen_mouse_primary_press(
&mut tui,
MousePrimaryPressEvent {
column: side_area.x,
row: side_area.y,
},
));
assert!(
!app.handle_owned_screen_mouse_primary(&mut tui, primary_press(side_area.x, side_area.y),)
);
assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Parent);
app.overlay = None;
let keymap = app.keymap.clone();
app.chat_widget.open_keymap_debug(&keymap);
assert!(!app.handle_owned_screen_mouse_primary_press(
&mut tui,
MousePrimaryPressEvent {
column: side_area.x,
row: side_area.y,
},
));
assert!(
!app.handle_owned_screen_mouse_primary(&mut tui, primary_press(side_area.x, side_area.y),)
);
assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Parent);
}
#[tokio::test]
async fn primary_drag_resizes_panes_without_changing_pane_state() {
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 _initial = render_app(&mut app, /*width*/ 120, /*height*/ 12);
let initial_parent = app
.chat_widget
.by_slot(PaneSlot::Parent)
.and_then(|pane| pane.owned_screen.as_ref())
.expect("parent screen")
.last_pane_area;
let initial_side = app
.chat_widget
.by_slot(PaneSlot::Side)
.and_then(|pane| pane.owned_screen.as_ref())
.expect("side screen")
.last_pane_area;
assert_eq!((initial_parent.width, initial_side.width), (60, 59));
let mut tui = crate::tui::test_support::make_test_tui().expect("create test TUI");
app.backtrack.primed = true;
assert!(app.handle_owned_screen_mouse_primary(
&mut tui,
primary_press(
initial_parent.right(),
initial_parent.y.saturating_add(/*rhs*/ 2)
),
));
assert!(app.chat_widget.owned_screen_split_is_dragging());
assert!(app.handle_owned_screen_mouse_primary(
&mut tui,
primary_event(MousePrimaryEventKind::Drag, /*column*/ 70, u16::MAX,),
));
let active = render_app(&mut app, /*width*/ 120, /*height*/ 12);
let parent = app
.chat_widget
.by_slot(PaneSlot::Parent)
.and_then(|pane| pane.owned_screen.as_ref())
.expect("parent screen")
.last_pane_area;
let side = app
.chat_widget
.by_slot(PaneSlot::Side)
.and_then(|pane| pane.owned_screen.as_ref())
.expect("side screen")
.last_pane_area;
assert_eq!((parent.width, side.width), (70, 49));
assert_eq!(active.backend().buffer()[(parent.right(), 2)].symbol(), "┃");
assert!(
active.backend().buffer()[(parent.right(), 2)]
.style()
.add_modifier
.contains(ratatui::style::Modifier::BOLD)
);
assert_snapshot!(
"owned_screen_resized_parent_wide_dragging",
active.backend()
);
assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Parent);
assert!(app.backtrack.primed);
assert_eq!(
(
app.chat_widget
.by_slot(PaneSlot::Parent)
.expect("parent pane")
.composer_text_with_pending(),
app.chat_widget
.by_slot(PaneSlot::Side)
.expect("side pane")
.composer_text_with_pending(),
),
("parent draft".to_string(), "side draft".to_string())
);
assert!(app.handle_owned_screen_mouse_primary(
&mut tui,
primary_event(MousePrimaryEventKind::Release, /*column*/ 70, u16::MAX,),
));
assert!(!app.chat_widget.owned_screen_split_is_dragging());
let settled = render_app(&mut app, /*width*/ 120, /*height*/ 12);
assert_eq!(
settled.backend().buffer()[(parent.right(), 2)].symbol(),
"│"
);
}
#[tokio::test]
async fn mouse_wheel_routes_by_pointer_without_changing_focus() {
let mut app = app_with_owned_side().await;
@@ -0,0 +1,16 @@
---
source: tui/src/app/owned_screen_tests.rs
expression: active.backend()
---
" Parent ⌥ + 1 / ⌥ + 2 focus ┃ Side "
"parent 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 "
+4 -1
View File
@@ -96,7 +96,10 @@ pub(crate) async fn run_cwd_selection_prompt(
if let Some(event) = events.next().await {
match event {
TuiEvent::Key(key_event) => screen.handle_key(key_event),
TuiEvent::Paste(_) | TuiEvent::MouseScroll(_) | TuiEvent::MousePrimaryPress(_) => {}
TuiEvent::Paste(_)
| TuiEvent::MouseScroll(_)
| TuiEvent::MousePrimary(_)
| TuiEvent::FocusLost => {}
TuiEvent::Draw | TuiEvent::Resize => {
tui.draw(u16::MAX, |frame| {
frame.render_widget_ref(&screen, frame.area());
@@ -99,7 +99,10 @@ pub(crate) async fn run_external_agent_config_migration_prompt(
if let Some(event) = events.next().await {
match event {
TuiEvent::Key(key_event) => screen.handle_key(key_event),
TuiEvent::Paste(_) | TuiEvent::MouseScroll(_) | TuiEvent::MousePrimaryPress(_) => {}
TuiEvent::Paste(_)
| TuiEvent::MouseScroll(_)
| TuiEvent::MousePrimary(_)
| TuiEvent::FocusLost => {}
TuiEvent::Draw | TuiEvent::Resize => {
let _ = tui.draw(u16::MAX, |frame| {
frame.render_widget_ref(&screen, frame.area());
+4 -1
View File
@@ -152,7 +152,10 @@ pub(crate) async fn run_model_migration_prompt(
if let Some(event) = events.next().await {
match event {
TuiEvent::Key(key_event) => screen.handle_key(key_event),
TuiEvent::Paste(_) | TuiEvent::MouseScroll(_) | TuiEvent::MousePrimaryPress(_) => {}
TuiEvent::Paste(_)
| TuiEvent::MouseScroll(_)
| TuiEvent::MousePrimary(_)
| TuiEvent::FocusLost => {}
TuiEvent::Draw | TuiEvent::Resize => {
let _ = alt.tui.draw(u16::MAX, |frame| {
frame.render_widget_ref(&screen, frame.area());
@@ -509,7 +509,7 @@ pub(crate) async fn run_onboarding_app(
TuiEvent::Paste(text) => {
onboarding_screen.handle_paste(text);
}
TuiEvent::MouseScroll(_) | TuiEvent::MousePrimaryPress(_) => {}
TuiEvent::MouseScroll(_) | TuiEvent::MousePrimary(_) | TuiEvent::FocusLost => {}
TuiEvent::Draw | TuiEvent::Resize => {
if !did_full_clear_after_success
&& onboarding_screen.steps.iter().any(|step| {
+1 -1
View File
@@ -475,7 +475,7 @@ async fn run_session_picker_with_loader(
TuiEvent::Paste(pasted) => {
state.handle_paste(pasted);
}
TuiEvent::MouseScroll(_) | TuiEvent::MousePrimaryPress(_) => {}
TuiEvent::MouseScroll(_) | TuiEvent::MousePrimary(_) | TuiEvent::FocusLost => {}
TuiEvent::Draw | TuiEvent::Resize => {
if let Ok(size) = alt.tui.terminal.size() {
let list_height =
+4 -1
View File
@@ -166,7 +166,10 @@ async fn run_startup_hooks_review_app(
}
}
}
TuiEvent::Paste(_) | TuiEvent::MouseScroll(_) | TuiEvent::MousePrimaryPress(_) => {}
TuiEvent::Paste(_)
| TuiEvent::MouseScroll(_)
| TuiEvent::MousePrimary(_)
| TuiEvent::FocusLost => {}
TuiEvent::Draw | TuiEvent::Resize => draw_view(tui, &view)?,
}
}
+13 -3
View File
@@ -519,8 +519,10 @@ pub enum TuiEvent {
Paste(String),
/// A vertical mouse-wheel event reported while an owned screen has mouse capture.
MouseScroll(MouseScrollEvent),
/// A primary mouse-button press with terminal coordinates.
MousePrimaryPress(MousePrimaryPressEvent),
/// A primary mouse-button lifecycle event with terminal coordinates.
MousePrimary(MousePrimaryEvent),
/// Notification that the terminal stopped receiving keyboard and mouse input.
FocusLost,
/// A terminal size notification that should be handled as resize-sensitive draw work.
///
/// Resize is separate from `Draw` so the app can run feature-gated pre-render logic without
@@ -538,11 +540,19 @@ pub struct MouseScrollEvent {
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct MousePrimaryPressEvent {
pub struct MousePrimaryEvent {
pub kind: MousePrimaryEventKind,
pub column: u16,
pub row: u16,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MousePrimaryEventKind {
Press,
Drag,
Release,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MouseScrollDirection {
Up,
+69 -21
View File
@@ -34,7 +34,8 @@ use tokio_stream::wrappers::BroadcastStream;
use tokio_stream::wrappers::WatchStream;
use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
use super::MousePrimaryPressEvent;
use super::MousePrimaryEvent;
use super::MousePrimaryEventKind;
use super::MouseScrollDirection;
use super::MouseScrollEvent;
use super::TuiEvent;
@@ -184,7 +185,7 @@ impl<S: EventSource + Default + Unpin> TuiEventStream<S> {
/// a mapped event, hits `Pending`, or sees EOF/error. When the broker is paused, it drops
/// the underlying stream and returns `Pending` to fully release stdin.
pub fn poll_crossterm_event(&mut self, cx: &mut Context<'_>) -> Poll<Option<TuiEvent>> {
// Some crossterm events map to None (e.g. FocusLost, mouse movement); loop so we keep polling
// Some crossterm events map to None (for example, mouse movement); loop so we keep polling
// until we return a mapped event, hit Pending, or see EOF/error.
for _ in 0..MAX_SKIPPED_EVENTS_PER_POLL {
let poll_result = {
@@ -277,7 +278,22 @@ impl<S: EventSource + Default + Unpin> TuiEventStream<S> {
row: mouse_event.row,
})),
MouseEventKind::Down(crossterm::event::MouseButton::Left) => {
Some(TuiEvent::MousePrimaryPress(MousePrimaryPressEvent {
Some(TuiEvent::MousePrimary(MousePrimaryEvent {
kind: MousePrimaryEventKind::Press,
column: mouse_event.column,
row: mouse_event.row,
}))
}
MouseEventKind::Drag(crossterm::event::MouseButton::Left) => {
Some(TuiEvent::MousePrimary(MousePrimaryEvent {
kind: MousePrimaryEventKind::Drag,
column: mouse_event.column,
row: mouse_event.row,
}))
}
MouseEventKind::Up(crossterm::event::MouseButton::Left) => {
Some(TuiEvent::MousePrimary(MousePrimaryEvent {
kind: MousePrimaryEventKind::Release,
column: mouse_event.column,
row: mouse_event.row,
}))
@@ -296,7 +312,7 @@ impl<S: EventSource + Default + Unpin> TuiEventStream<S> {
}
Event::FocusLost => {
self.terminal_focused.store(false, Ordering::Relaxed);
None
Some(TuiEvent::FocusLost)
}
}
}
@@ -433,23 +449,14 @@ mod tests {
}
#[tokio::test(flavor = "current_thread")]
async fn key_event_skips_unmapped() {
async fn focus_lost_maps_to_event_and_updates_terminal_state() {
let (broker, handle, _draw_tx, draw_rx, terminal_focused) = setup();
let mut stream = make_stream(broker, draw_rx, terminal_focused);
let mut stream = make_stream(broker, draw_rx, terminal_focused.clone());
handle.send(Ok(Event::FocusLost));
handle.send(Ok(Event::Key(KeyEvent::new(
KeyCode::Char('a'),
KeyModifiers::NONE,
))));
let next = stream.next().await.unwrap();
match next {
TuiEvent::Key(key) => {
assert_eq!(key, KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
}
other => panic!("expected key event, got {other:?}"),
}
assert!(matches!(stream.next().await, Some(TuiEvent::FocusLost)));
assert!(!terminal_focused.load(Ordering::Relaxed));
}
#[tokio::test(flavor = "current_thread")]
@@ -507,7 +514,7 @@ mod tests {
}
#[tokio::test(flavor = "current_thread")]
async fn primary_mouse_press_maps_to_event() {
async fn primary_mouse_lifecycle_maps_to_events() {
let (broker, handle, _draw_tx, draw_rx, terminal_focused) = setup();
let mut stream = make_stream(broker, draw_rx, terminal_focused);
@@ -517,14 +524,43 @@ mod tests {
row: 4,
modifiers: KeyModifiers::NONE,
})));
handle.send(Ok(Event::Mouse(MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
column: 5,
row: 6,
modifiers: KeyModifiers::NONE,
})));
handle.send(Ok(Event::Mouse(MouseEvent {
kind: MouseEventKind::Up(MouseButton::Left),
column: 7,
row: 8,
modifiers: KeyModifiers::NONE,
})));
assert!(matches!(
stream.next().await,
Some(TuiEvent::MousePrimaryPress(MousePrimaryPressEvent {
Some(TuiEvent::MousePrimary(MousePrimaryEvent {
kind: MousePrimaryEventKind::Press,
column: 3,
row: 4,
}))
));
assert!(matches!(
stream.next().await,
Some(TuiEvent::MousePrimary(MousePrimaryEvent {
kind: MousePrimaryEventKind::Drag,
column: 5,
row: 6,
}))
));
assert!(matches!(
stream.next().await,
Some(TuiEvent::MousePrimary(MousePrimaryEvent {
kind: MousePrimaryEventKind::Release,
column: 7,
row: 8,
}))
));
}
#[tokio::test(flavor = "current_thread")]
@@ -540,13 +576,25 @@ mod tests {
modifiers: KeyModifiers::NONE,
})));
handle.send(Ok(Event::Mouse(MouseEvent {
kind: MouseEventKind::Up(MouseButton::Left),
kind: MouseEventKind::Down(MouseButton::Middle),
column: 3,
row: 4,
modifiers: KeyModifiers::NONE,
})));
handle.send(Ok(Event::Mouse(MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
kind: MouseEventKind::Drag(MouseButton::Right),
column: 3,
row: 4,
modifiers: KeyModifiers::NONE,
})));
handle.send(Ok(Event::Mouse(MouseEvent {
kind: MouseEventKind::Up(MouseButton::Middle),
column: 3,
row: 4,
modifiers: KeyModifiers::NONE,
})));
handle.send(Ok(Event::Mouse(MouseEvent {
kind: MouseEventKind::ScrollLeft,
column: 4,
row: 4,
modifiers: KeyModifiers::NONE,
+4 -1
View File
@@ -58,7 +58,10 @@ pub(crate) async fn run_update_prompt_if_needed(
if let Some(event) = events.next().await {
match event {
TuiEvent::Key(key_event) => screen.handle_key(key_event),
TuiEvent::Paste(_) | TuiEvent::MouseScroll(_) | TuiEvent::MousePrimaryPress(_) => {}
TuiEvent::Paste(_)
| TuiEvent::MouseScroll(_)
| TuiEvent::MousePrimary(_)
| TuiEvent::FocusLost => {}
TuiEvent::Draw | TuiEvent::Resize => {
tui.draw(u16::MAX, |frame| {
frame.render_widget_ref(&screen, frame.area());