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(
|
pub(crate) fn update_latest_log(
|
||||||
&mut self,
|
&mut self,
|
||||||
line: String,
|
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.
|
// Forward only if we are currently showing the status indicator.
|
||||||
self.bottom_pane.update_status_text(line)?;
|
self.bottom_pane.update_status_text(line)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -365,7 +365,7 @@ impl ChatWidget<'_> {
|
|||||||
pub(crate) fn handle_scroll_delta(
|
pub(crate) fn handle_scroll_delta(
|
||||||
&mut self,
|
&mut self,
|
||||||
scroll_delta: i32,
|
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
|
// 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.
|
// otherwise we assume they are trying to scroll in larger increments.
|
||||||
let magnified_scroll_delta = if scroll_delta == 1 {
|
let magnified_scroll_delta = if scroll_delta == 1 {
|
||||||
@@ -389,7 +389,7 @@ impl ChatWidget<'_> {
|
|||||||
|
|
||||||
impl WidgetRef for &ChatWidget<'_> {
|
impl WidgetRef for &ChatWidget<'_> {
|
||||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
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()
|
let chunks = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
//! A live status indicator that shows the *latest* log line emitted by the
|
//! A live status indicator that shows the *latest* log line emitted by the
|
||||||
//! application while the agent is processing a long‑running task.
|
//! 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::Arc;
|
||||||
use std::sync::atomic::AtomicBool;
|
use std::sync::atomic::AtomicBool;
|
||||||
@@ -14,7 +9,6 @@ use std::sync::mpsc::Sender;
|
|||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use crossterm::event::KeyEvent;
|
|
||||||
use ratatui::buffer::Buffer;
|
use ratatui::buffer::Buffer;
|
||||||
use ratatui::layout::Alignment;
|
use ratatui::layout::Alignment;
|
||||||
use ratatui::layout::Rect;
|
use ratatui::layout::Rect;
|
||||||
@@ -45,8 +39,8 @@ pub(crate) struct StatusIndicatorWidget {
|
|||||||
/// input mode and loading mode.
|
/// input mode and loading mode.
|
||||||
height: u16,
|
height: u16,
|
||||||
|
|
||||||
frame_idx: std::sync::Arc<AtomicUsize>,
|
frame_idx: Arc<AtomicUsize>,
|
||||||
running: std::sync::Arc<AtomicBool>,
|
running: Arc<AtomicBool>,
|
||||||
// Keep one sender alive to prevent the channel from closing while the
|
// Keep one sender alive to prevent the channel from closing while the
|
||||||
// animation thread is still running. The field itself is currently not
|
// animation thread is still running. The field itself is currently not
|
||||||
// accessed anywhere, therefore the leading underscore silences the
|
// 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.
|
/// Preferred height in terminal rows.
|
||||||
pub(crate) fn get_height(&self) -> u16 {
|
pub(crate) fn get_height(&self) -> u16 {
|
||||||
self.height
|
self.height
|
||||||
|
|||||||
Reference in New Issue
Block a user