//! 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 - 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>, }, } /// 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, has_input_focus: bool, is_task_running: bool, } pub(crate) struct BottomPaneParams { pub(crate) app_event_tx: Sender, 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> { 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> { 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> { 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> { 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 (Ctrl‑D 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), ); }