Files
llmx/codex-rs/tui/src/bottom_pane.rs

304 lines
11 KiB
Rust
Raw Normal View History

//! 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 = 3;
/// 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 BottomPane<'_> {
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());
update_border_for_input_focus(&mut textarea, has_input_focus);
Self {
textarea,
state: PaneState::TextInput,
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, 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.state = PaneState::StatusIndicator {
view: StatusIndicatorWidget::new(
self.app_event_tx.clone(),
desired_height,
),
};
} else {
self.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 => {
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.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.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.
///
/// Returns `true` when this is the *first* modal - in that case the caller
/// should trigger a redraw so that the modal becomes visible.
pub fn push_approval_request(&mut self, request: ApprovalRequest) -> bool {
let widget = UserApprovalWidget::new(request, self.app_event_tx.clone());
match &mut self.state {
PaneState::StatusIndicator { .. } => {
self.state = PaneState::ApprovalModal {
current: widget,
queue: Vec::new(),
};
true // Needs redraw so the modal appears.
}
PaneState::TextInput => {
// Transition to modal state with an empty queue.
self.state = PaneState::ApprovalModal {
current: widget,
queue: Vec::new(),
};
true // Needs redraw so the modal appears.
}
PaneState::ApprovalModal { queue, .. } => {
queue.push(widget);
false // Already in modal mode - no redraw required.
}
}
}
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),
}
}
}
fn update_border_for_input_focus(textarea: &mut TextArea, has_input_focus: bool) {
let (title, border_style) = if has_input_focus {
(
"use Enter to send for now (CtrlD to quit)",
Style::default().dim(),
)
} else {
("", Style::default())
};
let right_title = if has_input_focus {
Line::from("press enter to send").alignment(Alignment::Right)
} else {
Line::from("")
};
textarea.set_block(
ratatui::widgets::Block::default()
.title_bottom(title)
.title_bottom(right_title)
.borders(ratatui::widgets::Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(border_style),
);
}