Add mouse controls to fork popup overlay

This commit is contained in:
Rakan El Khalil
2026-03-14 01:08:33 -07:00
parent 7503033ab7
commit 4910cdea38
12 changed files with 255 additions and 18 deletions

View File

@@ -155,6 +155,7 @@ mod app_server_adapter;
mod app_server_requests;
mod loaded_threads;
mod fork_session_overlay;
mod fork_session_overlay_mouse;
mod fork_session_terminal;
mod pending_interactive_replay;
@@ -3909,6 +3910,7 @@ impl App {
TuiEvent::Key(key_event) => {
self.handle_key_event(tui, app_server, key_event).await;
}
TuiEvent::Mouse(_) => {}
TuiEvent::Paste(pasted) => {
// Many terminals convert newlines to \r when pasting (e.g., iTerm2),
// but tui-textarea expects \n. Normalize CR to LF.

View File

@@ -28,6 +28,9 @@ use crate::tui::TuiEvent;
use crate::vt100_backend::VT100Backend;
use crate::vt100_render::render_screen;
use super::fork_session_overlay_mouse::OverlayMouseAction;
use super::fork_session_overlay_mouse::PopupDragState;
use super::fork_session_overlay_mouse::overlay_mouse_action;
use super::fork_session_terminal::ForkSessionTerminal;
const DEFAULT_POPUP_WIDTH_NUMERATOR: u16 = 2;
@@ -60,6 +63,7 @@ pub(crate) struct ForkSessionOverlayState {
popup: Rect,
command_state: OverlayCommandState,
focused_pane: OverlayFocusedPane,
drag_state: Option<PopupDragState>,
}
fn popup_size_bounds(area: Rect) -> Rect {
@@ -209,9 +213,7 @@ fn focus_toggle_shortcut(key_event: KeyEvent) -> bool {
fn popup_hint(command_state: OverlayCommandState) -> Vec<Span<'static>> {
match command_state {
OverlayCommandState::PassThrough => {
vec!["^O switch".cyan(), " ".into(), "ctrl+] prefix".dim()]
}
OverlayCommandState::PassThrough => vec!["ctrl+] prefix".dim()],
OverlayCommandState::AwaitingPrefix => {
vec![
"m move".cyan(),
@@ -259,17 +261,11 @@ fn popup_block(
Some(code) => format!("exited {code}").red().bold(),
None => "running".green().bold(),
};
let focus = match focused_pane {
OverlayFocusedPane::Background => "background focus".cyan().bold(),
OverlayFocusedPane::Popup => "popup focus".cyan().bold(),
};
let mut title = vec![
" fork session ".bold().cyan(),
" ".into(),
" fork session".bold().cyan(),
" ".into(),
status,
" ".into(),
focus,
" ".into(),
" ".into(),
];
title.extend(popup_hint(command_state));
let title = Line::from(title);
@@ -364,13 +360,16 @@ impl App {
popup,
command_state: OverlayCommandState::PassThrough,
focused_pane: OverlayFocusedPane::Popup,
drag_state: None,
});
tui.set_mouse_capture_enabled(true)?;
tui.frame_requester().schedule_frame();
Ok(())
}
pub(crate) async fn close_fork_session_overlay(&mut self, tui: &mut tui::Tui) -> Result<()> {
self.fork_session_overlay = None;
tui.set_mouse_capture_enabled(false)?;
self.restore_inline_view_after_fork_overlay_close(tui)?;
tui.frame_requester().schedule_frame();
Ok(())
@@ -627,6 +626,36 @@ impl App {
}
}
}
TuiEvent::Mouse(mouse_event) => {
let viewport = tui.terminal.size()?;
let area = Rect::new(0, 0, viewport.width, viewport.height);
if let Some(state) = self.fork_session_overlay.as_mut() {
match overlay_mouse_action(area, state.popup, state.drag_state, mouse_event) {
OverlayMouseAction::Ignore => {}
OverlayMouseAction::FocusBackground => {
state.focused_pane = OverlayFocusedPane::Background;
state.command_state = OverlayCommandState::PassThrough;
state.drag_state = None;
tui.frame_requester().schedule_frame();
}
OverlayMouseAction::FocusPopup(drag_state) => {
state.focused_pane = OverlayFocusedPane::Popup;
state.command_state = OverlayCommandState::PassThrough;
state.drag_state = Some(drag_state);
tui.frame_requester().schedule_frame();
}
OverlayMouseAction::MovePopup(popup) => {
state.focused_pane = OverlayFocusedPane::Popup;
state.command_state = OverlayCommandState::PassThrough;
state.popup = popup;
tui.frame_requester().schedule_frame();
}
OverlayMouseAction::EndDrag => {
state.drag_state = None;
}
}
}
}
TuiEvent::Draw => {
if self
.fork_session_overlay
@@ -1086,6 +1115,7 @@ ready for a fresh turn\r\n",
popup: default_popup_rect(Rect::new(0, 0, 100, 28)),
command_state: OverlayCommandState::PassThrough,
focused_pane: OverlayFocusedPane::Popup,
drag_state: None,
});
let area = Rect::new(0, 0, 100, 28);
@@ -1125,6 +1155,7 @@ ready for a fresh turn\r\n",
popup: default_popup_rect(Rect::new(0, 0, 100, 28)),
command_state: OverlayCommandState::PassThrough,
focused_pane: OverlayFocusedPane::Background,
drag_state: None,
});
let area = Rect::new(0, 0, 100, 28);
@@ -1168,6 +1199,7 @@ ready for a fresh turn\r\n",
popup: default_popup_rect(Rect::new(0, 0, 80, 18)),
command_state: OverlayCommandState::PassThrough,
focused_pane: OverlayFocusedPane::Background,
drag_state: None,
});
let area = Rect::new(0, 0, 80, 18);

View File

@@ -0,0 +1,150 @@
use crossterm::event::MouseButton;
use crossterm::event::MouseEvent;
use crossterm::event::MouseEventKind;
use ratatui::layout::Rect;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) struct PopupDragState {
column_offset: u16,
row_offset: u16,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum OverlayMouseAction {
Ignore,
FocusBackground,
FocusPopup(PopupDragState),
MovePopup(Rect),
EndDrag,
}
pub(crate) fn overlay_mouse_action(
area: Rect,
popup: Rect,
drag_state: Option<PopupDragState>,
mouse_event: MouseEvent,
) -> OverlayMouseAction {
match mouse_event.kind {
MouseEventKind::Down(MouseButton::Left) => {
if popup_contains_position(popup, mouse_event.column, mouse_event.row) {
OverlayMouseAction::FocusPopup(PopupDragState {
column_offset: mouse_event.column.saturating_sub(popup.x),
row_offset: mouse_event.row.saturating_sub(popup.y),
})
} else {
OverlayMouseAction::FocusBackground
}
}
MouseEventKind::Drag(MouseButton::Left) => {
if let Some(drag_state) = drag_state {
let max_x = area.right().saturating_sub(popup.width);
let max_y = area.bottom().saturating_sub(popup.height);
let x = mouse_event
.column
.saturating_sub(drag_state.column_offset)
.clamp(area.x, max_x);
let y = mouse_event
.row
.saturating_sub(drag_state.row_offset)
.clamp(area.y, max_y);
OverlayMouseAction::MovePopup(Rect::new(x, y, popup.width, popup.height))
} else {
OverlayMouseAction::Ignore
}
}
MouseEventKind::Up(MouseButton::Left) => OverlayMouseAction::EndDrag,
MouseEventKind::Down(_)
| MouseEventKind::Up(_)
| MouseEventKind::Drag(_)
| MouseEventKind::Moved
| MouseEventKind::ScrollDown
| MouseEventKind::ScrollUp
| MouseEventKind::ScrollLeft
| MouseEventKind::ScrollRight => OverlayMouseAction::Ignore,
}
}
fn popup_contains_position(popup: Rect, column: u16, row: u16) -> bool {
column >= popup.x && column < popup.right() && row >= popup.y && row < popup.bottom()
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn mouse_down_outside_popup_focuses_background() {
let popup = Rect::new(20, 8, 40, 16);
let area = Rect::new(0, 0, 120, 40);
let mouse_event = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 4,
row: 3,
modifiers: crossterm::event::KeyModifiers::NONE,
};
assert_eq!(
overlay_mouse_action(area, popup, None, mouse_event),
OverlayMouseAction::FocusBackground
);
}
#[test]
fn mouse_down_inside_popup_starts_drag() {
let popup = Rect::new(20, 8, 40, 16);
let area = Rect::new(0, 0, 120, 40);
let mouse_event = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 27,
row: 10,
modifiers: crossterm::event::KeyModifiers::NONE,
};
assert_eq!(
overlay_mouse_action(area, popup, None, mouse_event),
OverlayMouseAction::FocusPopup(PopupDragState {
column_offset: 7,
row_offset: 2,
})
);
}
#[test]
fn mouse_drag_moves_popup_and_clamps_to_viewport() {
let area = Rect::new(0, 0, 120, 40);
let popup = Rect::new(20, 8, 40, 16);
let drag_state = PopupDragState {
column_offset: 7,
row_offset: 2,
};
let mouse_event = MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
column: 118,
row: 39,
modifiers: crossterm::event::KeyModifiers::NONE,
};
assert_eq!(
overlay_mouse_action(area, popup, Some(drag_state), mouse_event),
OverlayMouseAction::MovePopup(Rect::new(80, 24, 40, 16))
);
}
#[test]
fn mouse_up_ends_drag() {
let popup = Rect::new(20, 8, 40, 16);
let area = Rect::new(0, 0, 120, 40);
let mouse_event = MouseEvent {
kind: MouseEventKind::Up(MouseButton::Left),
column: 27,
row: 10,
modifiers: crossterm::event::KeyModifiers::NONE,
};
assert_eq!(
overlay_mouse_action(area, popup, None, mouse_event),
OverlayMouseAction::EndDrag
);
}
}

View File

@@ -215,6 +215,7 @@ async fn fork_session_overlay_open_from_inline_viewport_snapshot() {
popup: default_popup_rect(Rect::new(0, 0, width, height)),
command_state: OverlayCommandState::PassThrough,
focused_pane: OverlayFocusedPane::Popup,
drag_state: None,
});
let mut pending_history_lines = Vec::new();

View File

@@ -8,7 +8,7 @@ expression: snapshot_buffer(&buf)
│ model: gpt-5.3-codex /model to change │
│ directory: ~/code/codex/codex-rs/tui │
╰─────────────────────────────────────────────╯
╭ fork session running popup focus ^O switch ctrl+] prefix
╭ fork session running ctrl+] prefix───────────────────────────
│Independent Codex session │
background sessi│ │
│> /tmp/worktree │

View File

@@ -8,7 +8,7 @@ expression: snapshot_buffer(&buf)
│ model: gpt-5.3-codex /model to change │
│ directory: ~/code/codex/codex-rs/tui │
╰─────────────────────────────────────────────╯
╭ fork session running background focus ^O switch ctrl+] p
╭ fork session running ctrl+] prefix───────────────────────────
│Independent Codex session │
background sessi│ │
│> /tmp/worktree │

View File

@@ -96,6 +96,7 @@ 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::Mouse(_) => {}
TuiEvent::Paste(_) => {}
TuiEvent::Draw => {
tui.draw(u16::MAX, |frame| {

View File

@@ -152,6 +152,7 @@ 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::Mouse(_) => {}
TuiEvent::Paste(_) => {}
TuiEvent::Draw => {
let _ = alt.tui.draw(u16::MAX, |frame| {

View File

@@ -457,6 +457,7 @@ pub(crate) async fn run_onboarding_app(
TuiEvent::Key(key_event) => {
onboarding_screen.handle_key_event(key_event);
}
TuiEvent::Mouse(_) => {}
TuiEvent::Paste(text) => {
onboarding_screen.handle_paste(text);
}

View File

@@ -16,10 +16,13 @@ use crossterm::Command;
use crossterm::SynchronizedUpdate;
use crossterm::event::DisableBracketedPaste;
use crossterm::event::DisableFocusChange;
use crossterm::event::DisableMouseCapture;
use crossterm::event::EnableBracketedPaste;
use crossterm::event::EnableFocusChange;
use crossterm::event::EnableMouseCapture;
use crossterm::event::KeyEvent;
use crossterm::event::KeyboardEnhancementFlags;
use crossterm::event::MouseEvent;
use crossterm::event::PopKeyboardEnhancementFlags;
use crossterm::event::PushKeyboardEnhancementFlags;
use crossterm::terminal::EnterAlternateScreen;
@@ -129,6 +132,7 @@ fn restore_common(should_disable_raw_mode: bool) -> Result<()> {
let _ = execute!(stdout(), PopKeyboardEnhancementFlags);
execute!(stdout(), DisableBracketedPaste)?;
let _ = execute!(stdout(), DisableFocusChange);
let _ = execute!(stdout(), DisableMouseCapture);
if should_disable_raw_mode {
disable_raw_mode()?;
}
@@ -234,6 +238,7 @@ fn set_panic_hook() {
#[derive(Clone, Debug)]
pub enum TuiEvent {
Key(KeyEvent),
Mouse(MouseEvent),
Paste(String),
Draw,
}
@@ -255,6 +260,7 @@ pub struct Tui {
notification_backend: Option<DesktopNotificationBackend>,
// When false, enter_alt_screen() becomes a no-op (for Zellij scrollback support)
alt_screen_enabled: bool,
mouse_capture_enabled: bool,
}
impl Tui {
@@ -283,6 +289,7 @@ impl Tui {
enhanced_keys_supported,
notification_backend: Some(detect_backend(NotificationMethod::default())),
alt_screen_enabled: true,
mouse_capture_enabled: false,
}
}
@@ -291,6 +298,11 @@ impl Tui {
self.alt_screen_enabled = enabled;
}
pub fn set_mouse_capture_enabled(&mut self, enabled: bool) -> Result<()> {
self.mouse_capture_enabled = enabled;
self.apply_mouse_capture_state()
}
pub fn set_notification_method(&mut self, method: NotificationMethod) {
self.notification_backend = Some(detect_backend(method));
}
@@ -333,6 +345,7 @@ impl Tui {
// Leave alt screen if active to avoid conflicts with external program `f`.
let was_alt_screen = self.is_alt_screen_active();
let mouse_capture_enabled = self.mouse_capture_enabled;
if was_alt_screen {
let _ = self.leave_alt_screen();
}
@@ -346,6 +359,9 @@ impl Tui {
if let Err(err) = set_modes() {
tracing::warn!("failed to re-enable terminal modes after external program: {err}");
}
if mouse_capture_enabled && let Err(err) = self.apply_mouse_capture_state() {
tracing::warn!("failed to re-enable mouse capture after external program: {err}");
}
// After the external program `f` finishes, reset terminal state and flush any buffered keypresses.
flush_terminal_input_buffer();
@@ -357,6 +373,15 @@ impl Tui {
output
}
fn apply_mouse_capture_state(&mut self) -> Result<()> {
if self.mouse_capture_enabled {
execute!(self.terminal.backend_mut(), EnableMouseCapture)?;
} else {
execute!(self.terminal.backend_mut(), DisableMouseCapture)?;
}
Ok(())
}
/// Emit a desktop notification now if the terminal is unfocused.
/// Returns true if a notification was posted.
pub fn notify(&mut self, message: impl AsRef<str>) -> bool {

View File

@@ -172,11 +172,11 @@ impl<S: EventSource + Default + Unpin> TuiEventStream<S> {
/// Poll the shared crossterm stream for the next mapped `TuiEvent`.
///
/// This skips events we don't use (mouse events, etc.) and keeps polling until it yields
/// This skips events we don't use and keeps polling until it yields
/// 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); loop so we keep polling
// Some crossterm events map to None (e.g. FocusLost); loop so we keep polling
// until we return a mapped event, hit Pending, or see EOF/error.
loop {
let poll_result = {
@@ -233,7 +233,7 @@ impl<S: EventSource + Default + Unpin> TuiEventStream<S> {
}
}
/// Map a crossterm event to a [`TuiEvent`], skipping events we don't use (mouse events, etc.).
/// Map a crossterm event to a [`TuiEvent`], skipping events we don't use.
fn map_crossterm_event(&mut self, event: Event) -> Option<TuiEvent> {
match event {
Event::Key(key_event) => {
@@ -244,6 +244,7 @@ impl<S: EventSource + Default + Unpin> TuiEventStream<S> {
}
Some(TuiEvent::Key(key_event))
}
Event::Mouse(mouse_event) => Some(TuiEvent::Mouse(mouse_event)),
Event::Resize(_, _) => Some(TuiEvent::Draw),
Event::Paste(pasted) => Some(TuiEvent::Paste(pasted)),
Event::FocusGained => {
@@ -255,7 +256,6 @@ impl<S: EventSource + Default + Unpin> TuiEventStream<S> {
self.terminal_focused.store(false, Ordering::Relaxed);
None
}
_ => None,
}
}
}
@@ -297,6 +297,9 @@ mod tests {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use crossterm::event::MouseButton;
use crossterm::event::MouseEvent;
use crossterm::event::MouseEventKind;
use pretty_assertions::assert_eq;
use std::task::Context;
use std::task::Poll;
@@ -408,6 +411,26 @@ mod tests {
}
}
#[tokio::test(flavor = "current_thread")]
async fn mouse_event_is_forwarded() {
let (broker, handle, _draw_tx, draw_rx, terminal_focused) = setup();
let mut stream = make_stream(broker, draw_rx, terminal_focused);
let expected_mouse = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 12,
row: 7,
modifiers: KeyModifiers::NONE,
};
handle.send(Ok(Event::Mouse(expected_mouse)));
let next = stream.next().await.unwrap();
match next {
TuiEvent::Mouse(mouse) => assert_eq!(mouse, expected_mouse),
other => panic!("expected mouse event, got {other:?}"),
}
}
#[tokio::test(flavor = "current_thread")]
async fn draw_and_key_events_yield_both() {
let (broker, handle, draw_tx, draw_rx, terminal_focused) = setup();

View File

@@ -56,6 +56,7 @@ 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::Mouse(_) => {}
TuiEvent::Paste(_) => {}
TuiEvent::Draw => {
tui.draw(u16::MAX, |frame| {