Files
llmx/codex-rs/tui/src/bottom_pane.rs
Michael Bolin 31d0d7a305 feat: initial import of Rust implementation of Codex CLI in codex-rs/ (#629)
As stated in `codex-rs/README.md`:

Today, Codex CLI is written in TypeScript and requires Node.js 22+ to
run it. For a number of users, this runtime requirement inhibits
adoption: they would be better served by a standalone executable. As
maintainers, we want Codex to run efficiently in a wide range of
environments with minimal overhead. We also want to take advantage of
operating system-specific APIs to provide better sandboxing, where
possible.

To that end, we are moving forward with a Rust implementation of Codex
CLI contained in this folder, which has the following benefits:

- The CLI compiles to small, standalone, platform-specific binaries.
- Can make direct, native calls to
[seccomp](https://man7.org/linux/man-pages/man2/seccomp.2.html) and
[landlock](https://man7.org/linux/man-pages/man7/landlock.7.html) in
order to support sandboxing on Linux.
- No runtime garbage collection, resulting in lower memory consumption
and better, more predictable performance.

Currently, the Rust implementation is materially behind the TypeScript
implementation in functionality, so continue to use the TypeScript
implmentation for the time being. We will publish native executables via
GitHub Releases as soon as we feel the Rust version is usable.
2025-04-24 13:31:40 -07:00

304 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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),
);
}