From 0402aef126b8f94ba84a8223dbe55ca0153a3f05 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 14 May 2025 10:13:29 -0700 Subject: [PATCH 1/2] 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 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_view.rs b/codex-rs/tui/src/bottom_pane/approval_modal_view.rs new file mode 100644 index 0000000000..71bc5d5f75 --- /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 0000000000..328319e70e --- /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 0000000000..6abe624051 --- /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 0000000000..ca606428ae --- /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 0000000000..aa353162ea --- /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 a7ba51eb80..c7ffe73431 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 7f21098eba..b4444512e8 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 From 7c34ec776d96c2e712db1d9a7032814739cfedf3 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 14 May 2025 10:54:37 -0700 Subject: [PATCH 2/2] feat: add mcp subcommand to CLI to run Codex as an MCP server --- codex-rs/Cargo.lock | 1 + codex-rs/cli/Cargo.toml | 1 + codex-rs/cli/src/main.rs | 6 ++ codex-rs/mcp-server/Cargo.toml | 8 +++ codex-rs/mcp-server/src/lib.rs | 113 ++++++++++++++++++++++++++++++++ codex-rs/mcp-server/src/main.rs | 113 +------------------------------- 6 files changed, 132 insertions(+), 110 deletions(-) create mode 100644 codex-rs/mcp-server/src/lib.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index d67a2df70a..44b0ad5a91 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -491,6 +491,7 @@ dependencies = [ "codex-common", "codex-core", "codex-exec", + "codex-mcp-server", "codex-tui", "serde_json", "tokio", diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index d10bf02d29..f7ad70e9df 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -24,6 +24,7 @@ clap = { version = "4", features = ["derive"] } codex-core = { path = "../core" } codex-common = { path = "../common", features = ["cli"] } codex-exec = { path = "../exec" } +codex-mcp-server = { path = "../mcp-server" } codex-tui = { path = "../tui" } serde_json = "1" tokio = { version = "1", features = [ diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 70d122fcee..aa0691d81e 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -33,6 +33,9 @@ enum Subcommand { #[clap(visible_alias = "e")] Exec(ExecCli), + /// Experimental: run Codex as an MCP server. + Mcp, + /// Run the Protocol stream via stdin/stdout #[clap(visible_alias = "p")] Proto(ProtoCli), @@ -70,6 +73,9 @@ async fn main() -> anyhow::Result<()> { Some(Subcommand::Exec(exec_cli)) => { codex_exec::run_main(exec_cli).await?; } + Some(Subcommand::Mcp) => { + codex_mcp_server::run_main().await?; + } Some(Subcommand::Proto(proto_cli)) => { proto::run_main(proto_cli).await?; } diff --git a/codex-rs/mcp-server/Cargo.toml b/codex-rs/mcp-server/Cargo.toml index aa5721e43f..9b5153a5e0 100644 --- a/codex-rs/mcp-server/Cargo.toml +++ b/codex-rs/mcp-server/Cargo.toml @@ -3,6 +3,14 @@ name = "codex-mcp-server" version = { workspace = true } edition = "2024" +[[bin]] +name = "codex-mcp-server" +path = "src/main.rs" + +[lib] +name = "codex_mcp_server" +path = "src/lib.rs" + [lints] workspace = true diff --git a/codex-rs/mcp-server/src/lib.rs b/codex-rs/mcp-server/src/lib.rs new file mode 100644 index 0000000000..e621f779f0 --- /dev/null +++ b/codex-rs/mcp-server/src/lib.rs @@ -0,0 +1,113 @@ +//! Prototype MCP server. +#![deny(clippy::print_stdout, clippy::print_stderr)] + +use std::io::Result as IoResult; + +use mcp_types::JSONRPCMessage; +use tokio::io::AsyncBufReadExt; +use tokio::io::AsyncWriteExt; +use tokio::io::BufReader; +use tokio::io::{self}; +use tokio::sync::mpsc; +use tracing::debug; +use tracing::error; +use tracing::info; + +mod codex_tool_config; +mod codex_tool_runner; +mod message_processor; + +use crate::message_processor::MessageProcessor; + +/// Size of the bounded channels used to communicate between tasks. The value +/// is a balance between throughput and memory usage – 128 messages should be +/// plenty for an interactive CLI. +const CHANNEL_CAPACITY: usize = 128; + +pub async fn run_main() -> IoResult<()> { + // Install a simple subscriber so `tracing` output is visible. Users can + // control the log level with `RUST_LOG`. + tracing_subscriber::fmt() + .with_writer(std::io::stderr) + .init(); + + // Set up channels. + let (incoming_tx, mut incoming_rx) = mpsc::channel::(CHANNEL_CAPACITY); + let (outgoing_tx, mut outgoing_rx) = mpsc::channel::(CHANNEL_CAPACITY); + + // Task: read from stdin, push to `incoming_tx`. + let stdin_reader_handle = tokio::spawn({ + let incoming_tx = incoming_tx.clone(); + async move { + let stdin = io::stdin(); + let reader = BufReader::new(stdin); + let mut lines = reader.lines(); + + while let Some(line) = lines.next_line().await.unwrap_or_default() { + match serde_json::from_str::(&line) { + Ok(msg) => { + if incoming_tx.send(msg).await.is_err() { + // Receiver gone – nothing left to do. + break; + } + } + Err(e) => error!("Failed to deserialize JSONRPCMessage: {e}"), + } + } + + debug!("stdin reader finished (EOF)"); + } + }); + + // Task: process incoming messages. + let processor_handle = tokio::spawn({ + let mut processor = MessageProcessor::new(outgoing_tx.clone()); + async move { + while let Some(msg) = incoming_rx.recv().await { + match msg { + JSONRPCMessage::Request(r) => processor.process_request(r), + JSONRPCMessage::Response(r) => processor.process_response(r), + JSONRPCMessage::Notification(n) => processor.process_notification(n), + JSONRPCMessage::BatchRequest(b) => processor.process_batch_request(b), + JSONRPCMessage::Error(e) => processor.process_error(e), + JSONRPCMessage::BatchResponse(b) => processor.process_batch_response(b), + } + } + + info!("processor task exited (channel closed)"); + } + }); + + // Task: write outgoing messages to stdout. + let stdout_writer_handle = tokio::spawn(async move { + let mut stdout = io::stdout(); + while let Some(msg) = outgoing_rx.recv().await { + match serde_json::to_string(&msg) { + Ok(json) => { + if let Err(e) = stdout.write_all(json.as_bytes()).await { + error!("Failed to write to stdout: {e}"); + break; + } + if let Err(e) = stdout.write_all(b"\n").await { + error!("Failed to write newline to stdout: {e}"); + break; + } + if let Err(e) = stdout.flush().await { + error!("Failed to flush stdout: {e}"); + break; + } + } + Err(e) => error!("Failed to serialize JSONRPCMessage: {e}"), + } + } + + info!("stdout writer exited (channel closed)"); + }); + + // Wait for all tasks to finish. The typical exit path is the stdin reader + // hitting EOF which, once it drops `incoming_tx`, propagates shutdown to + // the processor and then to the stdout task. + let _ = tokio::join!(stdin_reader_handle, processor_handle, stdout_writer_handle); + + Ok(()) +} diff --git a/codex-rs/mcp-server/src/main.rs b/codex-rs/mcp-server/src/main.rs index 87e8d7bbe2..baef8587f7 100644 --- a/codex-rs/mcp-server/src/main.rs +++ b/codex-rs/mcp-server/src/main.rs @@ -1,114 +1,7 @@ -//! Prototype MCP server. -#![deny(clippy::print_stdout, clippy::print_stderr)] - -use std::io::Result as IoResult; - -use mcp_types::JSONRPCMessage; -use tokio::io::AsyncBufReadExt; -use tokio::io::AsyncWriteExt; -use tokio::io::BufReader; -use tokio::io::{self}; -use tokio::sync::mpsc; -use tracing::debug; -use tracing::error; -use tracing::info; - -mod codex_tool_config; -mod codex_tool_runner; -mod message_processor; - -use crate::message_processor::MessageProcessor; - -/// Size of the bounded channels used to communicate between tasks. The value -/// is a balance between throughput and memory usage – 128 messages should be -/// plenty for an interactive CLI. -const CHANNEL_CAPACITY: usize = 128; +use codex_mcp_server::run_main; #[tokio::main] -async fn main() -> IoResult<()> { - // Install a simple subscriber so `tracing` output is visible. Users can - // control the log level with `RUST_LOG`. - tracing_subscriber::fmt() - .with_writer(std::io::stderr) - .init(); - - // Set up channels. - let (incoming_tx, mut incoming_rx) = mpsc::channel::(CHANNEL_CAPACITY); - let (outgoing_tx, mut outgoing_rx) = mpsc::channel::(CHANNEL_CAPACITY); - - // Task: read from stdin, push to `incoming_tx`. - let stdin_reader_handle = tokio::spawn({ - let incoming_tx = incoming_tx.clone(); - async move { - let stdin = io::stdin(); - let reader = BufReader::new(stdin); - let mut lines = reader.lines(); - - while let Some(line) = lines.next_line().await.unwrap_or_default() { - match serde_json::from_str::(&line) { - Ok(msg) => { - if incoming_tx.send(msg).await.is_err() { - // Receiver gone – nothing left to do. - break; - } - } - Err(e) => error!("Failed to deserialize JSONRPCMessage: {e}"), - } - } - - debug!("stdin reader finished (EOF)"); - } - }); - - // Task: process incoming messages. - let processor_handle = tokio::spawn({ - let mut processor = MessageProcessor::new(outgoing_tx.clone()); - async move { - while let Some(msg) = incoming_rx.recv().await { - match msg { - JSONRPCMessage::Request(r) => processor.process_request(r), - JSONRPCMessage::Response(r) => processor.process_response(r), - JSONRPCMessage::Notification(n) => processor.process_notification(n), - JSONRPCMessage::BatchRequest(b) => processor.process_batch_request(b), - JSONRPCMessage::Error(e) => processor.process_error(e), - JSONRPCMessage::BatchResponse(b) => processor.process_batch_response(b), - } - } - - info!("processor task exited (channel closed)"); - } - }); - - // Task: write outgoing messages to stdout. - let stdout_writer_handle = tokio::spawn(async move { - let mut stdout = io::stdout(); - while let Some(msg) = outgoing_rx.recv().await { - match serde_json::to_string(&msg) { - Ok(json) => { - if let Err(e) = stdout.write_all(json.as_bytes()).await { - error!("Failed to write to stdout: {e}"); - break; - } - if let Err(e) = stdout.write_all(b"\n").await { - error!("Failed to write newline to stdout: {e}"); - break; - } - if let Err(e) = stdout.flush().await { - error!("Failed to flush stdout: {e}"); - break; - } - } - Err(e) => error!("Failed to serialize JSONRPCMessage: {e}"), - } - } - - info!("stdout writer exited (channel closed)"); - }); - - // Wait for all tasks to finish. The typical exit path is the stdin reader - // hitting EOF which, once it drops `incoming_tx`, propagates shutdown to - // the processor and then to the stdout task. - let _ = tokio::join!(stdin_reader_handle, processor_handle, stdout_writer_handle); - +async fn main() -> std::io::Result<()> { + run_main().await?; Ok(()) }