mirror of
https://github.com/openai/codex.git
synced 2026-09-16 12:13:30 +00:00
chore: move each view used in BottomPane into its own file
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_state.rs
Normal file
73
codex-rs/tui/src/bottom_pane/approval_modal_state.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::OverlayState;
|
||||
|
||||
/// Modal overlay asking the user to approve/deny a sequence of requests.
|
||||
pub(crate) struct ApprovalModalState<'a> {
|
||||
current: UserApprovalWidget<'a>,
|
||||
queue: Vec<ApprovalRequest>,
|
||||
app_event_tx: Sender<AppEvent>,
|
||||
}
|
||||
|
||||
impl ApprovalModalState<'_> {
|
||||
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> OverlayState<'a> for ApprovalModalState<'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 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 push_approval_request(&mut self, req: ApprovalRequest) -> bool {
|
||||
self.enqueue_request(req);
|
||||
true
|
||||
}
|
||||
}
|
||||
204
codex-rs/tui/src/bottom_pane/mod.rs
Normal file
204
codex-rs/tui/src/bottom_pane/mod.rs
Normal file
@@ -0,0 +1,204 @@
|
||||
//! Bottom pane widget: always shows the multiline text input, and – when
|
||||
//! active – an *overlay* such as a status indicator or approval-request
|
||||
//! modal.
|
||||
|
||||
#[allow(unused)]
|
||||
use std::sync::mpsc::SendError;
|
||||
#[allow(unused)]
|
||||
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;
|
||||
|
||||
mod approval_modal_state;
|
||||
mod status_indicator_state;
|
||||
mod text_input_state;
|
||||
|
||||
pub(crate) use text_input_state::InputResult;
|
||||
pub(crate) use text_input_state::TextInputState;
|
||||
|
||||
use approval_modal_state::ApprovalModalState;
|
||||
use status_indicator_state::StatusIndicatorState;
|
||||
|
||||
/// Trait implemented by every *overlay* that can be shown on top of the text
|
||||
/// input.
|
||||
pub(crate) trait OverlayState<'a> {
|
||||
/// Handle a key event while the overlay is active.
|
||||
fn handle_key_event(
|
||||
&mut self,
|
||||
pane: &mut BottomPane<'a>,
|
||||
key_event: KeyEvent,
|
||||
) -> Result<(), SendError<AppEvent>>;
|
||||
|
||||
/// Return `true` once the overlay has finished and should be removed.
|
||||
fn is_complete(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Height required to render the overlay.
|
||||
fn required_height(&self, area: &Rect) -> u16;
|
||||
|
||||
/// Render the overlay – assumes the underlying text-input has already been
|
||||
/// drawn.
|
||||
fn render(&self, area: Rect, buf: &mut Buffer);
|
||||
|
||||
/// Update the status indicator text – default: ignore and return false.
|
||||
fn update_status_text(&mut self, _text: String) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Called when task status toggles. Default: keep overlay.
|
||||
fn on_task_running_changed(&mut self, _running: bool) -> bool {
|
||||
true // return true to keep overlay
|
||||
}
|
||||
|
||||
/// Try to handle approval request; return true if consumed.
|
||||
fn push_approval_request(&mut self, _req: ApprovalRequest) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything that is drawn in the lower half of the chat UI.
|
||||
pub(crate) struct BottomPane<'a> {
|
||||
text_input: TextInputState<'a>,
|
||||
overlay: Option<Box<dyn OverlayState<'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 {
|
||||
text_input: TextInputState::new(params.has_input_focus),
|
||||
overlay: 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 overlay or to the text-input.
|
||||
pub fn handle_key_event(
|
||||
&mut self,
|
||||
key_event: KeyEvent,
|
||||
) -> Result<InputResult, SendError<AppEvent>> {
|
||||
if let Some(mut overlay) = self.overlay.take() {
|
||||
overlay.handle_key_event(self, key_event)?;
|
||||
if !overlay.is_complete() {
|
||||
self.overlay = Some(overlay);
|
||||
} else if self.is_task_running {
|
||||
let height = self.text_input.required_height(&Rect::default());
|
||||
self.overlay = Some(Box::new(StatusIndicatorState::new(
|
||||
self.app_event_tx.clone(),
|
||||
height,
|
||||
)));
|
||||
}
|
||||
self.request_redraw()?;
|
||||
Ok(InputResult::None)
|
||||
} else {
|
||||
let (res, needs_redraw) = self.text_input.handle_key_event(key_event);
|
||||
if needs_redraw {
|
||||
self.request_redraw()?;
|
||||
}
|
||||
Ok(res)
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the status indicator text (only when the status overlay is active).
|
||||
pub(crate) fn update_status_text(&mut self, text: String) -> Result<(), SendError<AppEvent>> {
|
||||
if let Some(ov) = &mut self.overlay {
|
||||
if ov.update_status_text(text) {
|
||||
self.request_redraw()?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn set_input_focus(&mut self, has_focus: bool) {
|
||||
self.has_input_focus = has_focus;
|
||||
self.text_input.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.overlay.is_some()) {
|
||||
(true, false) => {
|
||||
// Show status indicator overlay.
|
||||
let height = self.text_input.required_height(&Rect::default());
|
||||
self.overlay = Some(Box::new(StatusIndicatorState::new(
|
||||
self.app_event_tx.clone(),
|
||||
height,
|
||||
)));
|
||||
self.request_redraw()?;
|
||||
}
|
||||
(false, true) => {
|
||||
if let Some(mut ov) = self.overlay.take() {
|
||||
if ov.on_task_running_changed(false) {
|
||||
self.overlay = Some(ov);
|
||||
} else {
|
||||
// overlay closed
|
||||
}
|
||||
self.request_redraw()?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Called when the agent requests user approval.
|
||||
pub fn push_approval_request(
|
||||
&mut self,
|
||||
request: ApprovalRequest,
|
||||
) -> Result<(), SendError<AppEvent>> {
|
||||
if let Some(ov) = self.overlay.as_mut() {
|
||||
if ov.push_approval_request(request.clone()) {
|
||||
self.request_redraw()?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise create a new approval modal overlay.
|
||||
let modal = ApprovalModalState::new(request, self.app_event_tx.clone());
|
||||
self.overlay = Some(Box::new(modal));
|
||||
self.request_redraw()
|
||||
}
|
||||
|
||||
/// Height (terminal rows) required by the current bottom pane.
|
||||
pub fn required_height(&self, area: &Rect) -> u16 {
|
||||
if let Some(ov) = &self.overlay {
|
||||
ov.required_height(area)
|
||||
} else {
|
||||
self.text_input.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 overlay if present.
|
||||
if let Some(ov) = &self.overlay {
|
||||
ov.render(area, buf);
|
||||
} else {
|
||||
(&self.text_input).render_ref(area, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
60
codex-rs/tui/src/bottom_pane/status_indicator_state.rs
Normal file
60
codex-rs/tui/src/bottom_pane/status_indicator_state.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
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::OverlayState;
|
||||
|
||||
pub(crate) struct StatusIndicatorState {
|
||||
view: StatusIndicatorWidget,
|
||||
}
|
||||
|
||||
impl StatusIndicatorState {
|
||||
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> OverlayState<'a> for StatusIndicatorState {
|
||||
fn handle_key_event(
|
||||
&mut self,
|
||||
_pane: &mut BottomPane<'a>,
|
||||
key_event: KeyEvent,
|
||||
) -> Result<(), SendError<AppEvent>> {
|
||||
// If underlying view consumes key, schedule redraw.
|
||||
if self.view.handle_key_event(key_event)? {
|
||||
// we don't have pane reference for redraw; will be done by caller.
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn update_status_text(&mut self, text: String) -> bool {
|
||||
self.update_text(text);
|
||||
true
|
||||
}
|
||||
|
||||
fn on_task_running_changed(&mut self, running: bool) -> bool {
|
||||
running // keep only while running == true
|
||||
}
|
||||
|
||||
fn required_height(&self, _area: &Rect) -> u16 {
|
||||
self.view.get_height()
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
self.view.render_ref(area, buf);
|
||||
}
|
||||
}
|
||||
118
codex-rs/tui/src/bottom_pane/text_input_state.rs
Normal file
118
codex-rs/tui/src/bottom_pane/text_input_state.rs
Normal file
@@ -0,0 +1,118 @@
|
||||
use crossterm::event::KeyEvent;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
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 TextInputState<'a> {
|
||||
textarea: TextArea<'a>,
|
||||
}
|
||||
|
||||
impl TextInputState<'_> {
|
||||
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 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) {
|
||||
use ratatui::layout::Alignment;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::BorderType;
|
||||
use ratatui::widgets::Borders;
|
||||
|
||||
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 &TextInputState<'_> {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
self.textarea.render(area, buf);
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ use crate::exec_command::relativize_to_home;
|
||||
use crate::exec_command::strip_bash_lc_and_escape;
|
||||
|
||||
/// Request coming from the agent that needs user approval.
|
||||
#[derive(Clone)]
|
||||
pub(crate) enum ApprovalRequest {
|
||||
Exec {
|
||||
id: String,
|
||||
|
||||
Reference in New Issue
Block a user