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<Box<dyn BottomPaneView<'a> + '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`.
This commit is contained in:
@@ -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 <Enter> - 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<UserApprovalWidget<'a>>,
|
||||
},
|
||||
}
|
||||
|
||||
/// 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<AppEvent>,
|
||||
|
||||
has_input_focus: bool,
|
||||
|
||||
is_task_running: bool,
|
||||
}
|
||||
|
||||
pub(crate) struct BottomPaneParams {
|
||||
pub(crate) app_event_tx: Sender<AppEvent>,
|
||||
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<AppEvent>> {
|
||||
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<InputResult, SendError<AppEvent>> {
|
||||
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<AppEvent>> {
|
||||
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<AppEvent>> {
|
||||
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<AppEvent>> {
|
||||
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<AppEvent>> {
|
||||
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),
|
||||
);
|
||||
}
|
||||
73
codex-rs/tui/src/bottom_pane/approval_modal_view.rs
Normal file
73
codex-rs/tui/src/bottom_pane/approval_modal_view.rs
Normal file
@@ -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<ApprovalRequest>,
|
||||
app_event_tx: Sender<AppEvent>,
|
||||
}
|
||||
|
||||
impl ApprovalModalView<'_> {
|
||||
pub fn new(request: ApprovalRequest, app_event_tx: Sender<AppEvent>) -> 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<AppEvent>> {
|
||||
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<ApprovalRequest> {
|
||||
self.enqueue_request(req);
|
||||
None
|
||||
}
|
||||
}
|
||||
56
codex-rs/tui/src/bottom_pane/bottom_pane_view.rs
Normal file
56
codex-rs/tui/src/bottom_pane/bottom_pane_view.rs
Normal file
@@ -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<AppEvent>>;
|
||||
|
||||
/// 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<ApprovalRequest> {
|
||||
Some(request)
|
||||
}
|
||||
}
|
||||
117
codex-rs/tui/src/bottom_pane/chat_composer.rs
Normal file
117
codex-rs/tui/src/bottom_pane/chat_composer.rs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
182
codex-rs/tui/src/bottom_pane/mod.rs
Normal file
182
codex-rs/tui/src/bottom_pane/mod.rs
Normal file
@@ -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<Box<dyn BottomPaneView<'a> + 'a>>,
|
||||
|
||||
app_event_tx: Sender<AppEvent>,
|
||||
has_input_focus: bool,
|
||||
is_task_running: bool,
|
||||
}
|
||||
|
||||
pub(crate) struct BottomPaneParams {
|
||||
pub(crate) app_event_tx: Sender<AppEvent>,
|
||||
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<InputResult, SendError<AppEvent>> {
|
||||
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<AppEvent>> {
|
||||
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<AppEvent>> {
|
||||
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<AppEvent>> {
|
||||
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<AppEvent>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
57
codex-rs/tui/src/bottom_pane/status_indicator_view.rs
Normal file
57
codex-rs/tui/src/bottom_pane/status_indicator_view.rs
Normal file
@@ -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<AppEvent>, 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<AppEvent>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -351,7 +351,7 @@ impl ChatWidget<'_> {
|
||||
pub(crate) fn update_latest_log(
|
||||
&mut self,
|
||||
line: String,
|
||||
) -> std::result::Result<(), std::sync::mpsc::SendError<AppEvent>> {
|
||||
) -> std::result::Result<(), SendError<AppEvent>> {
|
||||
// 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<AppEvent>> {
|
||||
) -> std::result::Result<(), SendError<AppEvent>> {
|
||||
// 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)
|
||||
|
||||
@@ -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<AtomicUsize>,
|
||||
running: std::sync::Arc<AtomicBool>,
|
||||
frame_idx: Arc<AtomicUsize>,
|
||||
running: Arc<AtomicBool>,
|
||||
// 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<bool, std::sync::mpsc::SendError<AppEvent>> {
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user