From 0402aef126b8f94ba84a8223dbe55ca0153a3f05 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 14 May 2025 10:13:29 -0700 Subject: [PATCH] chore: move each view used in BottomPane into its own file (#928) `BottomPane` was getting a bit unwieldy because it maintained a `PaneState` enum with three variants and many of its methods had `match` statements to handle each variant. To replace the enum, this PR: * Introduces a `trait BottomPaneView` that has two implementations: `StatusIndicatorView` and `ApprovalModalView`. * Migrates `PaneState::TextInput` into its own struct, `ChatComposer`, that does **not** implement `BottomPaneView`. * Updates `BottomPane` so it has `composer: ChatComposer` and `active_view: Option + 'a>>`. The idea is that `active_view` takes priority and is displayed when it is `Some`; otherwise, `ChatComposer` is displayed. * While methods of `BottomPane` often have to check whether `active_view` is present to decide which component to delegate to, the code is more straightforward than before and introducing new implementations of `BottomPaneView` should be less painful. Because we want to retain the `TextArea` owned by `ChatComposer` even when another view is displayed, to keep the ownership logic simple, it seemed best to keep `ChatComposer` distinct from `BottomPaneView`. --- codex-rs/tui/src/bottom_pane.rs | 339 ------------------ .../src/bottom_pane/approval_modal_view.rs | 73 ++++ .../tui/src/bottom_pane/bottom_pane_view.rs | 56 +++ codex-rs/tui/src/bottom_pane/chat_composer.rs | 117 ++++++ codex-rs/tui/src/bottom_pane/mod.rs | 182 ++++++++++ .../src/bottom_pane/status_indicator_view.rs | 57 +++ codex-rs/tui/src/chatwidget.rs | 6 +- codex-rs/tui/src/status_indicator_widget.rs | 18 +- 8 files changed, 490 insertions(+), 358 deletions(-) delete mode 100644 codex-rs/tui/src/bottom_pane.rs create mode 100644 codex-rs/tui/src/bottom_pane/approval_modal_view.rs create mode 100644 codex-rs/tui/src/bottom_pane/bottom_pane_view.rs create mode 100644 codex-rs/tui/src/bottom_pane/chat_composer.rs create mode 100644 codex-rs/tui/src/bottom_pane/mod.rs create mode 100644 codex-rs/tui/src/bottom_pane/status_indicator_view.rs diff --git a/codex-rs/tui/src/bottom_pane.rs b/codex-rs/tui/src/bottom_pane.rs deleted file mode 100644 index 723ce58a..00000000 --- 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_view.rs b/codex-rs/tui/src/bottom_pane/approval_modal_view.rs new file mode 100644 index 00000000..71bc5d5f --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/approval_modal_view.rs @@ -0,0 +1,73 @@ +use std::sync::mpsc::SendError; +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; +use crate::user_approval_widget::UserApprovalWidget; + +use super::BottomPane; +use super::BottomPaneView; + +/// Modal overlay asking the user to approve/deny a sequence of requests. +pub(crate) struct ApprovalModalView<'a> { + current: UserApprovalWidget<'a>, + queue: Vec, + app_event_tx: Sender, +} + +impl ApprovalModalView<'_> { + 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> BottomPaneView<'a> for ApprovalModalView<'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 calculate_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 try_consume_approval_request(&mut self, req: ApprovalRequest) -> Option { + self.enqueue_request(req); + None + } +} diff --git a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs new file mode 100644 index 00000000..328319e7 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs @@ -0,0 +1,56 @@ +use crossterm::event::KeyEvent; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use std::sync::mpsc::SendError; + +use crate::app_event::AppEvent; +use crate::user_approval_widget::ApprovalRequest; + +use super::BottomPane; + +/// Type to use for a method that may require a redraw of the UI. +pub(crate) enum ConditionalUpdate { + NeedsRedraw, + NoRedraw, +} + +/// Trait implemented by every view that can be shown in the bottom pane. +pub(crate) trait BottomPaneView<'a> { + /// Handle a key event while the view is active. A redraw is always + /// scheduled after this call. + fn handle_key_event( + &mut self, + pane: &mut BottomPane<'a>, + key_event: KeyEvent, + ) -> Result<(), SendError>; + + /// Return `true` if the view has finished and should be removed. + fn is_complete(&self) -> bool { + false + } + + /// Height required to render the view. + fn calculate_required_height(&self, area: &Rect) -> u16; + + /// Render the view: this will be displayed in place of the composer. + fn render(&self, area: Rect, buf: &mut Buffer); + + /// Update the status indicator text. + fn update_status_text(&mut self, _text: String) -> ConditionalUpdate { + ConditionalUpdate::NoRedraw + } + + /// Called when task completes to check if the view should be hidden. + fn should_hide_when_task_is_done(&mut self) -> bool { + false + } + + /// Try to handle approval request; return the original value if not + /// consumed. + fn try_consume_approval_request( + &mut self, + request: ApprovalRequest, + ) -> Option { + Some(request) + } +} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs new file mode 100644 index 00000000..6abe6240 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -0,0 +1,117 @@ +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::Borders; +use ratatui::widgets::Widget; +use ratatui::widgets::WidgetRef; +use tui_textarea::Input; +use tui_textarea::Key; +use tui_textarea::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 ChatComposer<'a> { + textarea: TextArea<'a>, +} + +impl ChatComposer<'_> { + 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 calculate_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) { + 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 &ChatComposer<'_> { + fn render_ref(&self, area: Rect, buf: &mut Buffer) { + self.textarea.render(area, buf); + } +} 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 00000000..ca606428 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -0,0 +1,182 @@ +//! Bottom pane: shows the ChatComposer or a BottomPaneView, if one is active. + +use bottom_pane_view::BottomPaneView; +use bottom_pane_view::ConditionalUpdate; +use crossterm::event::KeyEvent; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::widgets::WidgetRef; +use std::sync::mpsc::SendError; +use std::sync::mpsc::Sender; + +use crate::app_event::AppEvent; +use crate::user_approval_widget::ApprovalRequest; + +mod approval_modal_view; +mod bottom_pane_view; +mod chat_composer; +mod status_indicator_view; + +pub(crate) use chat_composer::ChatComposer; +pub(crate) use chat_composer::InputResult; + +use approval_modal_view::ApprovalModalView; +use status_indicator_view::StatusIndicatorView; + +/// Pane displayed in the lower half of the chat UI. +pub(crate) struct BottomPane<'a> { + /// Composer is retained even when a BottomPaneView is displayed so the + /// input state is retained when the view is closed. + composer: ChatComposer<'a>, + + /// If present, this is displayed instead of the `composer`. + active_view: 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 BottomPane<'_> { + pub fn new(params: BottomPaneParams) -> Self { + Self { + composer: ChatComposer::new(params.has_input_focus), + active_view: 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 view or the composer. + pub fn handle_key_event( + &mut self, + key_event: KeyEvent, + ) -> Result> { + if let Some(mut view) = self.active_view.take() { + view.handle_key_event(self, key_event)?; + if !view.is_complete() { + self.active_view = Some(view); + } else if self.is_task_running { + let height = self.composer.calculate_required_height(&Rect::default()); + self.active_view = Some(Box::new(StatusIndicatorView::new( + self.app_event_tx.clone(), + height, + ))); + } + self.request_redraw()?; + Ok(InputResult::None) + } else { + let (input_result, needs_redraw) = self.composer.handle_key_event(key_event); + if needs_redraw { + self.request_redraw()?; + } + Ok(input_result) + } + } + + /// Update the status indicator text (only when the `StatusIndicatorView` is + /// active). + pub(crate) fn update_status_text(&mut self, text: String) -> Result<(), SendError> { + if let Some(view) = &mut self.active_view { + match view.update_status_text(text) { + ConditionalUpdate::NeedsRedraw => { + self.request_redraw()?; + } + ConditionalUpdate::NoRedraw => { + // No redraw needed. + } + } + } + Ok(()) + } + + /// Update the UI to reflect whether this `BottomPane` has input focus. + pub(crate) fn set_input_focus(&mut self, has_focus: bool) { + self.has_input_focus = has_focus; + self.composer.set_input_focus(has_focus); + } + + pub fn set_task_running(&mut self, running: bool) -> Result<(), SendError> { + self.is_task_running = running; + + match (running, self.active_view.is_some()) { + (true, false) => { + // Show status indicator overlay. + let height = self.composer.calculate_required_height(&Rect::default()); + self.active_view = Some(Box::new(StatusIndicatorView::new( + self.app_event_tx.clone(), + height, + ))); + self.request_redraw()?; + } + (false, true) => { + if let Some(mut view) = self.active_view.take() { + if view.should_hide_when_task_is_done() { + // Leave self.active_view as None. + self.request_redraw()?; + } else { + // Preserve the view. + self.active_view = Some(view); + } + } + } + _ => { + // No change. + } + } + Ok(()) + } + + /// Called when the agent requests user approval. + pub fn push_approval_request( + &mut self, + request: ApprovalRequest, + ) -> Result<(), SendError> { + let request = if let Some(view) = self.active_view.as_mut() { + match view.try_consume_approval_request(request) { + Some(request) => request, + None => { + self.request_redraw()?; + return Ok(()); + } + } + } else { + request + }; + + // Otherwise create a new approval modal overlay. + let modal = ApprovalModalView::new(request, self.app_event_tx.clone()); + self.active_view = Some(Box::new(modal)); + self.request_redraw() + } + + /// Height (terminal rows) required by the current bottom pane. + pub fn calculate_required_height(&self, area: &Rect) -> u16 { + if let Some(view) = &self.active_view { + view.calculate_required_height(area) + } else { + self.composer.calculate_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 BottomPaneView if present. + if let Some(ov) = &self.active_view { + ov.render(area, buf); + } else { + (&self.composer).render_ref(area, buf); + } + } +} diff --git a/codex-rs/tui/src/bottom_pane/status_indicator_view.rs b/codex-rs/tui/src/bottom_pane/status_indicator_view.rs new file mode 100644 index 00000000..aa353162 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/status_indicator_view.rs @@ -0,0 +1,57 @@ +use std::sync::mpsc::SendError; +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::status_indicator_widget::StatusIndicatorWidget; + +use super::BottomPane; +use super::BottomPaneView; +use super::bottom_pane_view::ConditionalUpdate; + +pub(crate) struct StatusIndicatorView { + view: StatusIndicatorWidget, +} + +impl StatusIndicatorView { + 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> BottomPaneView<'a> for StatusIndicatorView { + fn handle_key_event( + &mut self, + _pane: &mut BottomPane<'a>, + _key_event: KeyEvent, + ) -> Result<(), SendError> { + Ok(()) + } + + fn update_status_text(&mut self, text: String) -> ConditionalUpdate { + self.update_text(text); + ConditionalUpdate::NeedsRedraw + } + + fn should_hide_when_task_is_done(&mut self) -> bool { + true + } + + fn calculate_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/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index a7ba51eb..c7ffe734 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -351,7 +351,7 @@ impl ChatWidget<'_> { pub(crate) fn update_latest_log( &mut self, line: String, - ) -> std::result::Result<(), std::sync::mpsc::SendError> { + ) -> std::result::Result<(), SendError> { // Forward only if we are currently showing the status indicator. self.bottom_pane.update_status_text(line)?; Ok(()) @@ -365,7 +365,7 @@ impl ChatWidget<'_> { pub(crate) fn handle_scroll_delta( &mut self, scroll_delta: i32, - ) -> std::result::Result<(), std::sync::mpsc::SendError> { + ) -> std::result::Result<(), SendError> { // If the user is trying to scroll exactly one line, we let them, but // otherwise we assume they are trying to scroll in larger increments. let magnified_scroll_delta = if scroll_delta == 1 { @@ -389,7 +389,7 @@ impl ChatWidget<'_> { impl WidgetRef for &ChatWidget<'_> { fn render_ref(&self, area: Rect, buf: &mut Buffer) { - let bottom_height = self.bottom_pane.required_height(&area); + let bottom_height = self.bottom_pane.calculate_required_height(&area); let chunks = Layout::default() .direction(Direction::Vertical) diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index 7f21098e..b4444512 100644 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ b/codex-rs/tui/src/status_indicator_widget.rs @@ -1,10 +1,5 @@ //! A live status indicator that shows the *latest* log line emitted by the //! application while the agent is processing a long‑running task. -//! -//! It replaces the old spinner animation with real log feedback so users can -//! watch Codex “think” in real‑time. Whenever new text is provided via -//! [`StatusIndicatorWidget::update_text`], the parent widget triggers a -//! redraw so the change is visible immediately. use std::sync::Arc; use std::sync::atomic::AtomicBool; @@ -14,7 +9,6 @@ use std::sync::mpsc::Sender; use std::thread; use std::time::Duration; -use crossterm::event::KeyEvent; use ratatui::buffer::Buffer; use ratatui::layout::Alignment; use ratatui::layout::Rect; @@ -45,8 +39,8 @@ pub(crate) struct StatusIndicatorWidget { /// input mode and loading mode. height: u16, - frame_idx: std::sync::Arc, - running: std::sync::Arc, + frame_idx: Arc, + running: Arc, // Keep one sender alive to prevent the channel from closing while the // animation thread is still running. The field itself is currently not // accessed anywhere, therefore the leading underscore silences the @@ -87,14 +81,6 @@ impl StatusIndicatorWidget { } } - pub(crate) fn handle_key_event( - &mut self, - _key: KeyEvent, - ) -> Result> { - // The indicator does not handle any input – always return `false`. - Ok(false) - } - /// Preferred height in terminal rows. pub(crate) fn get_height(&self) -> u16 { self.height