chore: move each view used in BottomPane into its own file

This commit is contained in:
Michael Bolin
2025-05-13 22:05:48 -07:00
parent 1bf00a3a95
commit 45140daeb3
6 changed files with 448 additions and 339 deletions

View File

@@ -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),
);
}

View File

@@ -0,0 +1,70 @@
use std::sync::mpsc::{SendError, Sender};
use crossterm::event::KeyEvent;
use ratatui::{buffer::Buffer, layout::Rect, widgets::WidgetRef};
use crate::{
app_event::AppEvent,
user_approval_widget::{ApprovalRequest, UserApprovalWidget},
};
use super::{BottomPane, 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<'a> ApprovalModalState<'a> {
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
}
}

View 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<'a> BottomPane<'a> {
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);
}
}
}

View File

@@ -0,0 +1,55 @@
use std::sync::mpsc::{SendError, Sender};
use crossterm::event::KeyEvent;
use ratatui::{buffer::Buffer, layout::Rect, widgets::WidgetRef};
use crate::{app_event::AppEvent, status_indicator_widget::StatusIndicatorWidget};
use super::{BottomPane, 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);
}
}

View File

@@ -0,0 +1,118 @@
use crossterm::event::KeyEvent;
use ratatui::{buffer::Buffer, layout::Rect, widgets::WidgetRef};
use ratatui::widgets::Widget;
use tui_textarea::{Input, Key, 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<'a> TextInputState<'a> {
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,
style::{Style, Stylize},
text::Line,
widgets::{BorderType, 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);
}
}

View File

@@ -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,