diff --git a/codex-rs/tui/src/bottom_pane.rs b/codex-rs/tui/src/bottom_pane.rs deleted file mode 100644 index 723ce58a04..0000000000 --- a/codex-rs/tui/src/bottom_pane.rs +++ /dev/null @@ -1,339 +0,0 @@ -//! Bottom pane widget for the chat UI. -//! -//! This widget owns everything that is rendered in the terminal's lower -//! portion: either the multiline [`TextArea`] for user input or an active -//! [`UserApprovalWidget`] modal. All state and key-handling logic that is -//! specific to those UI elements lives here so that the parent -//! [`ChatWidget`] only has to forward events and render calls. - -use std::sync::mpsc::SendError; -use std::sync::mpsc::Sender; - -use crossterm::event::KeyEvent; -use ratatui::buffer::Buffer; -use ratatui::layout::Alignment; -use ratatui::layout::Rect; -use ratatui::style::Style; -use ratatui::style::Stylize; -use ratatui::text::Line; -use ratatui::widgets::BorderType; -use ratatui::widgets::Widget; -use ratatui::widgets::WidgetRef; -use tui_textarea::Input; -use tui_textarea::Key; -use tui_textarea::TextArea; - -use crate::app_event::AppEvent; -use crate::status_indicator_widget::StatusIndicatorWidget; -use crate::user_approval_widget::ApprovalRequest; -use crate::user_approval_widget::UserApprovalWidget; - -/// Minimum number of visible text rows inside the textarea. -const MIN_TEXTAREA_ROWS: usize = 1; -/// Number of terminal rows consumed by the textarea border (top + bottom). -const TEXTAREA_BORDER_LINES: u16 = 2; - -/// Result returned by [`BottomPane::handle_key_event`]. -pub enum InputResult { - /// The user pressed - the contained string is the message that - /// should be forwarded to the agent and appended to the conversation - /// history. - Submitted(String), - None, -} - -/// Internal state of the bottom pane. -/// -/// `ApprovalModal` owns a `current` widget that is guaranteed to exist while -/// this variant is active. Additional queued modals are stored in `queue`. -enum PaneState<'a> { - StatusIndicator { - view: StatusIndicatorWidget, - }, - TextInput, - ApprovalModal { - current: UserApprovalWidget<'a>, - queue: Vec>, - }, -} - -/// Everything that is drawn in the lower half of the chat UI. -pub(crate) struct BottomPane<'a> { - /// Multiline input widget (always kept around so its history/yank buffer - /// is preserved even while a modal is open). - textarea: TextArea<'a>, - - /// Current state (text input vs. approval modal). - state: PaneState<'a>, - - /// Channel used to notify the application that a redraw is required. - app_event_tx: Sender, - - has_input_focus: bool, - - is_task_running: bool, -} - -pub(crate) struct BottomPaneParams { - pub(crate) app_event_tx: Sender, - pub(crate) has_input_focus: bool, -} - -impl<'a> BottomPane<'a> { - pub fn new( - BottomPaneParams { - app_event_tx, - has_input_focus, - }: BottomPaneParams, - ) -> Self { - let mut textarea = TextArea::default(); - textarea.set_placeholder_text("send a message"); - textarea.set_cursor_line_style(Style::default()); - let state = PaneState::TextInput; - update_border_for_input_focus(&mut textarea, &state, has_input_focus); - - Self { - textarea, - state, - app_event_tx, - has_input_focus, - is_task_running: false, - } - } - - /// Update the status indicator with the latest log line. Only effective - /// when the pane is currently in `StatusIndicator` mode. - pub(crate) fn update_status_text(&mut self, text: String) -> Result<(), SendError> { - if let PaneState::StatusIndicator { view } = &mut self.state { - view.update_text(text); - self.request_redraw()?; - } - Ok(()) - } - - pub(crate) fn set_input_focus(&mut self, has_input_focus: bool) { - self.has_input_focus = has_input_focus; - update_border_for_input_focus(&mut self.textarea, &self.state, has_input_focus); - } - - /// Forward a key event to the appropriate child widget. - pub fn handle_key_event( - &mut self, - key_event: KeyEvent, - ) -> Result> { - match &mut self.state { - PaneState::StatusIndicator { view } => { - if view.handle_key_event(key_event)? { - self.request_redraw()?; - } - Ok(InputResult::None) - } - PaneState::ApprovalModal { current, queue } => { - // While in modal mode we always consume the Event. - current.handle_key_event(key_event)?; - - // If the modal has finished, either advance to the next one - // in the queue or fall back to the textarea. - if current.is_complete() { - if !queue.is_empty() { - // Replace `current` with the first queued modal and - // drop the old value. - *current = queue.remove(0); - } else if self.is_task_running { - let desired_height = { - let text_rows = self.textarea.lines().len().max(MIN_TEXTAREA_ROWS); - text_rows as u16 + TEXTAREA_BORDER_LINES - }; - - self.set_state(PaneState::StatusIndicator { - view: StatusIndicatorWidget::new( - self.app_event_tx.clone(), - desired_height, - ), - })?; - } else { - self.set_state(PaneState::TextInput)?; - } - } - - // Always request a redraw while a modal is up to ensure the - // UI stays responsive. - self.request_redraw()?; - Ok(InputResult::None) - } - PaneState::TextInput => { - match key_event.into() { - Input { - key: Key::Enter, - shift: false, - alt: false, - ctrl: false, - } => { - let text = self.textarea.lines().join("\n"); - // Clear the textarea (there is no dedicated clear API). - self.textarea.select_all(); - self.textarea.cut(); - self.request_redraw()?; - Ok(InputResult::Submitted(text)) - } - Input { - key: Key::Enter, .. - } - | Input { - key: Key::Char('j'), - ctrl: true, - alt: false, - shift: false, - } => { - // If the user has their terminal emulator configured so - // Enter+Shift (or any modifier) sends a different key - // event, we should let them insert a newline. - // - // We also allow Ctrl+J to insert a newline. - self.textarea.insert_newline(); - self.request_redraw()?; - Ok(InputResult::None) - } - input => { - self.textarea.input(input); - self.request_redraw()?; - Ok(InputResult::None) - } - } - } - } - } - - pub fn set_task_running(&mut self, is_task_running: bool) -> Result<(), SendError> { - self.is_task_running = is_task_running; - - match self.state { - PaneState::TextInput => { - if is_task_running { - self.set_state(PaneState::StatusIndicator { - view: StatusIndicatorWidget::new(self.app_event_tx.clone(), { - let text_rows = - self.textarea.lines().len().max(MIN_TEXTAREA_ROWS) as u16; - text_rows + TEXTAREA_BORDER_LINES - }), - })?; - } else { - return Ok(()); - } - } - PaneState::StatusIndicator { .. } => { - if is_task_running { - return Ok(()); - } else { - self.set_state(PaneState::TextInput)?; - } - } - PaneState::ApprovalModal { .. } => { - // Do not change state if a modal is showing. - return Ok(()); - } - } - - self.request_redraw()?; - Ok(()) - } - - /// Enqueue a new approval request coming from the agent. - pub fn push_approval_request( - &mut self, - request: ApprovalRequest, - ) -> Result<(), SendError> { - let widget = UserApprovalWidget::new(request, self.app_event_tx.clone()); - - match &mut self.state { - PaneState::StatusIndicator { .. } => self.set_state(PaneState::ApprovalModal { - current: widget, - queue: Vec::new(), - }), - PaneState::TextInput => { - // Transition to modal state with an empty queue. - self.set_state(PaneState::ApprovalModal { - current: widget, - queue: Vec::new(), - }) - } - PaneState::ApprovalModal { queue, .. } => { - queue.push(widget); - Ok(()) - } - } - } - - fn set_state(&mut self, state: PaneState<'a>) -> Result<(), SendError> { - self.state = state; - update_border_for_input_focus(&mut self.textarea, &self.state, self.has_input_focus); - self.request_redraw() - } - - fn request_redraw(&self) -> Result<(), SendError> { - self.app_event_tx.send(AppEvent::Redraw) - } - - /// Height (terminal rows) required to render the pane in its current - /// state (modal or textarea). - pub fn required_height(&self, area: &Rect) -> u16 { - match &self.state { - PaneState::StatusIndicator { view } => view.get_height(), - PaneState::ApprovalModal { current, .. } => current.get_height(area), - PaneState::TextInput => { - let text_rows = self.textarea.lines().len(); - std::cmp::max(text_rows, MIN_TEXTAREA_ROWS) as u16 + TEXTAREA_BORDER_LINES - } - } - } -} - -impl WidgetRef for &BottomPane<'_> { - fn render_ref(&self, area: Rect, buf: &mut Buffer) { - match &self.state { - PaneState::StatusIndicator { view } => view.render_ref(area, buf), - PaneState::ApprovalModal { current, .. } => current.render(area, buf), - PaneState::TextInput => self.textarea.render(area, buf), - } - } -} - -// Note this sets the border for the TextArea, but the TextArea is not visible -// for all variants of PaneState. -fn update_border_for_input_focus(textarea: &mut TextArea, state: &PaneState, has_focus: bool) { - struct BlockState { - right_title: Line<'static>, - border_style: Style, - } - - let accepting_input = match state { - PaneState::TextInput => true, - PaneState::ApprovalModal { .. } => true, - PaneState::StatusIndicator { .. } => false, - }; - - let block_state = if has_focus && accepting_input { - BlockState { - right_title: Line::from("Enter to send | Ctrl+D to quit | Ctrl+J for newline") - .alignment(Alignment::Right), - border_style: Style::default(), - } - } else { - BlockState { - right_title: Line::from(""), - border_style: Style::default().dim(), - } - }; - - let BlockState { - right_title, - border_style, - } = block_state; - textarea.set_block( - ratatui::widgets::Block::default() - .title_bottom(right_title) - .borders(ratatui::widgets::Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(border_style), - ); -} diff --git a/codex-rs/tui/src/bottom_pane/approval_modal_state.rs b/codex-rs/tui/src/bottom_pane/approval_modal_state.rs new file mode 100644 index 0000000000..f162698655 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/approval_modal_state.rs @@ -0,0 +1,70 @@ +use std::sync::mpsc::{SendError, Sender}; + +use crossterm::event::KeyEvent; +use ratatui::{buffer::Buffer, layout::Rect, widgets::WidgetRef}; + +use crate::{ + app_event::AppEvent, + user_approval_widget::{ApprovalRequest, UserApprovalWidget}, +}; + +use super::{BottomPane, OverlayState}; + +/// Modal overlay asking the user to approve/deny a sequence of requests. +pub(crate) struct ApprovalModalState<'a> { + current: UserApprovalWidget<'a>, + queue: Vec, + app_event_tx: Sender, +} + +impl<'a> ApprovalModalState<'a> { + pub fn new(request: ApprovalRequest, app_event_tx: Sender) -> Self { + Self { + current: UserApprovalWidget::new(request, app_event_tx.clone()), + queue: Vec::new(), + app_event_tx, + } + } + + pub fn enqueue_request(&mut self, req: ApprovalRequest) { + self.queue.push(req); + } + + /// Advance to next request if the current one is finished. + fn maybe_advance(&mut self) { + if self.current.is_complete() { + if let Some(req) = self.queue.pop() { + self.current = UserApprovalWidget::new(req, self.app_event_tx.clone()); + } + } + } +} + +impl<'a> OverlayState<'a> for ApprovalModalState<'a> { + fn handle_key_event( + &mut self, + _pane: &mut BottomPane<'a>, + key_event: KeyEvent, + ) -> Result<(), SendError> { + self.current.handle_key_event(key_event)?; + self.maybe_advance(); + Ok(()) + } + + fn is_complete(&self) -> bool { + self.current.is_complete() && self.queue.is_empty() + } + + fn required_height(&self, area: &Rect) -> u16 { + self.current.get_height(area) + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + (&self.current).render_ref(area, buf); + } + + fn push_approval_request(&mut self, req: ApprovalRequest) -> bool { + self.enqueue_request(req); + true + } +} diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs new file mode 100644 index 0000000000..8f80f4686d --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -0,0 +1,204 @@ +//! Bottom pane widget: always shows the multiline text input, and – when +//! active – an *overlay* such as a status indicator or approval-request +//! modal. + +#[allow(unused)] +use std::sync::mpsc::SendError; +#[allow(unused)] +use std::sync::mpsc::Sender; + +use crossterm::event::KeyEvent; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::widgets::WidgetRef; + +use crate::app_event::AppEvent; +use crate::user_approval_widget::ApprovalRequest; + +mod approval_modal_state; +mod status_indicator_state; +mod text_input_state; + +pub(crate) use text_input_state::InputResult; +pub(crate) use text_input_state::TextInputState; + +use approval_modal_state::ApprovalModalState; +use status_indicator_state::StatusIndicatorState; + +/// Trait implemented by every *overlay* that can be shown on top of the text +/// input. +pub(crate) trait OverlayState<'a> { + /// Handle a key event while the overlay is active. + fn handle_key_event( + &mut self, + pane: &mut BottomPane<'a>, + key_event: KeyEvent, + ) -> Result<(), SendError>; + + /// Return `true` once the overlay has finished and should be removed. + fn is_complete(&self) -> bool { + false + } + + /// Height required to render the overlay. + fn required_height(&self, area: &Rect) -> u16; + + /// Render the overlay – assumes the underlying text-input has already been + /// drawn. + fn render(&self, area: Rect, buf: &mut Buffer); + + /// Update the status indicator text – default: ignore and return false. + fn update_status_text(&mut self, _text: String) -> bool { + false + } + + /// Called when task status toggles. Default: keep overlay. + fn on_task_running_changed(&mut self, _running: bool) -> bool { + true // return true to keep overlay + } + + /// Try to handle approval request; return true if consumed. + fn push_approval_request(&mut self, _req: ApprovalRequest) -> bool { + false + } +} + +/// Everything that is drawn in the lower half of the chat UI. +pub(crate) struct BottomPane<'a> { + text_input: TextInputState<'a>, + overlay: Option + 'a>>, + + app_event_tx: Sender, + + has_input_focus: bool, + is_task_running: bool, +} + +pub(crate) struct BottomPaneParams { + pub(crate) app_event_tx: Sender, + pub(crate) has_input_focus: bool, +} + +impl<'a> BottomPane<'a> { + pub fn new(params: BottomPaneParams) -> Self { + Self { + text_input: TextInputState::new(params.has_input_focus), + overlay: None, + app_event_tx: params.app_event_tx, + has_input_focus: params.has_input_focus, + is_task_running: false, + } + } + + /// Forward a key event to the active overlay or to the text-input. + pub fn handle_key_event( + &mut self, + key_event: KeyEvent, + ) -> Result> { + if let Some(mut overlay) = self.overlay.take() { + overlay.handle_key_event(self, key_event)?; + if !overlay.is_complete() { + self.overlay = Some(overlay); + } else if self.is_task_running { + let height = self.text_input.required_height(&Rect::default()); + self.overlay = Some(Box::new(StatusIndicatorState::new( + self.app_event_tx.clone(), + height, + ))); + } + self.request_redraw()?; + Ok(InputResult::None) + } else { + let (res, needs_redraw) = self.text_input.handle_key_event(key_event); + if needs_redraw { + self.request_redraw()?; + } + Ok(res) + } + } + + /// Update the status indicator text (only when the status overlay is active). + pub(crate) fn update_status_text(&mut self, text: String) -> Result<(), SendError> { + if let Some(ov) = &mut self.overlay { + if ov.update_status_text(text) { + self.request_redraw()?; + } + } + Ok(()) + } + + pub(crate) fn set_input_focus(&mut self, has_focus: bool) { + self.has_input_focus = has_focus; + self.text_input.set_input_focus(has_focus); + } + + pub fn set_task_running(&mut self, running: bool) -> Result<(), SendError> { + self.is_task_running = running; + + match (running, self.overlay.is_some()) { + (true, false) => { + // Show status indicator overlay. + let height = self.text_input.required_height(&Rect::default()); + self.overlay = Some(Box::new(StatusIndicatorState::new( + self.app_event_tx.clone(), + height, + ))); + self.request_redraw()?; + } + (false, true) => { + if let Some(mut ov) = self.overlay.take() { + if ov.on_task_running_changed(false) { + self.overlay = Some(ov); + } else { + // overlay closed + } + self.request_redraw()?; + } + } + _ => {} + } + Ok(()) + } + + /// Called when the agent requests user approval. + pub fn push_approval_request( + &mut self, + request: ApprovalRequest, + ) -> Result<(), SendError> { + if let Some(ov) = self.overlay.as_mut() { + if ov.push_approval_request(request.clone()) { + self.request_redraw()?; + return Ok(()); + } + } + + // Otherwise create a new approval modal overlay. + let modal = ApprovalModalState::new(request, self.app_event_tx.clone()); + self.overlay = Some(Box::new(modal)); + self.request_redraw() + } + + /// Height (terminal rows) required by the current bottom pane. + pub fn required_height(&self, area: &Rect) -> u16 { + if let Some(ov) = &self.overlay { + ov.required_height(area) + } else { + self.text_input.required_height(area) + } + } + + pub(crate) fn request_redraw(&self) -> Result<(), SendError> { + self.app_event_tx.send(AppEvent::Redraw) + } +} + +impl WidgetRef for &BottomPane<'_> { + fn render_ref(&self, area: Rect, buf: &mut Buffer) { + // Show overlay if present. + if let Some(ov) = &self.overlay { + ov.render(area, buf); + } else { + (&self.text_input).render_ref(area, buf); + } + } +} diff --git a/codex-rs/tui/src/bottom_pane/status_indicator_state.rs b/codex-rs/tui/src/bottom_pane/status_indicator_state.rs new file mode 100644 index 0000000000..91039d0d5d --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/status_indicator_state.rs @@ -0,0 +1,55 @@ +use std::sync::mpsc::{SendError, Sender}; + +use crossterm::event::KeyEvent; +use ratatui::{buffer::Buffer, layout::Rect, widgets::WidgetRef}; + +use crate::{app_event::AppEvent, status_indicator_widget::StatusIndicatorWidget}; + +use super::{BottomPane, OverlayState}; + +pub(crate) struct StatusIndicatorState { + view: StatusIndicatorWidget, +} + +impl StatusIndicatorState { + pub fn new(app_event_tx: Sender, height: u16) -> Self { + Self { + view: StatusIndicatorWidget::new(app_event_tx, height), + } + } + + pub fn update_text(&mut self, text: String) { + self.view.update_text(text); + } +} + +impl<'a> OverlayState<'a> for StatusIndicatorState { + fn handle_key_event( + &mut self, + _pane: &mut BottomPane<'a>, + key_event: KeyEvent, + ) -> Result<(), SendError> { + // If underlying view consumes key, schedule redraw. + if self.view.handle_key_event(key_event)? { + // we don't have pane reference for redraw; will be done by caller. + } + Ok(()) + } + + fn update_status_text(&mut self, text: String) -> bool { + self.update_text(text); + true + } + + fn on_task_running_changed(&mut self, running: bool) -> bool { + running // keep only while running == true + } + + fn required_height(&self, _area: &Rect) -> u16 { + self.view.get_height() + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + self.view.render_ref(area, buf); + } +} diff --git a/codex-rs/tui/src/bottom_pane/text_input_state.rs b/codex-rs/tui/src/bottom_pane/text_input_state.rs new file mode 100644 index 0000000000..b6208b9e31 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/text_input_state.rs @@ -0,0 +1,118 @@ +use crossterm::event::KeyEvent; +use ratatui::{buffer::Buffer, layout::Rect, widgets::WidgetRef}; +use ratatui::widgets::Widget; +use tui_textarea::{Input, Key, TextArea}; + + + +/// Minimum number of visible text rows inside the textarea. +const MIN_TEXTAREA_ROWS: usize = 1; +/// Rows consumed by the border. +const BORDER_LINES: u16 = 2; + +/// Result returned when the user interacts with the text area. +pub enum InputResult { + Submitted(String), + None, +} + +pub(crate) struct TextInputState<'a> { + textarea: TextArea<'a>, +} + +impl<'a> TextInputState<'a> { + pub fn new(has_input_focus: bool) -> Self { + let mut textarea = TextArea::default(); + textarea.set_placeholder_text("send a message"); + textarea.set_cursor_line_style(ratatui::style::Style::default()); + + let mut this = Self { textarea }; + this.update_border(has_input_focus); + this + } + + pub fn set_input_focus(&mut self, has_focus: bool) { + self.update_border(has_focus); + } + + /// Handle key event when no overlay is present. + pub fn handle_key_event(&mut self, key_event: KeyEvent) -> (InputResult, bool) { + match key_event.into() { + Input { + key: Key::Enter, + shift: false, + alt: false, + ctrl: false, + } => { + let text = self.textarea.lines().join("\n"); + self.textarea.select_all(); + self.textarea.cut(); + (InputResult::Submitted(text), true) + } + Input { + key: Key::Enter, .. + } + | Input { + key: Key::Char('j'), + ctrl: true, + alt: false, + shift: false, + } => { + self.textarea.insert_newline(); + (InputResult::None, true) + } + input => { + self.textarea.input(input); + (InputResult::None, true) + } + } + } + + pub fn required_height(&self, _area: &Rect) -> u16 { + let rows = self.textarea.lines().len().max(MIN_TEXTAREA_ROWS); + rows as u16 + BORDER_LINES + } + + fn update_border(&mut self, has_focus: bool) { + use ratatui::{ + layout::Alignment, + style::{Style, Stylize}, + text::Line, + widgets::{BorderType, Borders}, + }; + + struct BlockState { + right_title: Line<'static>, + border_style: Style, + } + + let bs = if has_focus { + BlockState { + right_title: Line::from( + "Enter to send | Ctrl+D to quit | Ctrl+J for newline", + ) + .alignment(Alignment::Right), + border_style: Style::default(), + } + } else { + BlockState { + right_title: Line::from(""), + border_style: Style::default().dim(), + } + }; + + self.textarea.set_block( + ratatui::widgets::Block::default() + .title_bottom(bs.right_title) + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(bs.border_style), + ); + } +} + +impl WidgetRef for &TextInputState<'_> { + fn render_ref(&self, area: Rect, buf: &mut Buffer) { + self.textarea.render(area, buf); + } +} diff --git a/codex-rs/tui/src/user_approval_widget.rs b/codex-rs/tui/src/user_approval_widget.rs index cbfccf1972..6082e8e715 100644 --- a/codex-rs/tui/src/user_approval_widget.rs +++ b/codex-rs/tui/src/user_approval_widget.rs @@ -34,6 +34,7 @@ use crate::exec_command::relativize_to_home; use crate::exec_command::strip_bash_lc_and_escape; /// Request coming from the agent that needs user approval. +#[derive(Clone)] pub(crate) enum ApprovalRequest { Exec { id: String,