chore: move each view used in BottomPane into its own file (#928)

`BottomPane` was getting a bit unwieldy because it maintained a
`PaneState` enum with three variants and many of its methods had `match`
statements to handle each variant. To replace the enum, this PR:

* Introduces a `trait BottomPaneView` that has two implementations:
`StatusIndicatorView` and `ApprovalModalView`.
* Migrates `PaneState::TextInput` into its own struct, `ChatComposer`,
that does **not** implement `BottomPaneView`.
* Updates `BottomPane` so it has `composer: ChatComposer` and
`active_view: Option<Box<dyn BottomPaneView<'a> + 'a>>`. The idea is
that `active_view` takes priority and is displayed when it is `Some`;
otherwise, `ChatComposer` is displayed.
* While methods of `BottomPane` often have to check whether
`active_view` is present to decide which component to delegate to, the
code is more straightforward than before and introducing new
implementations of `BottomPaneView` should be less painful.

Because we want to retain the `TextArea` owned by `ChatComposer` even
when another view is displayed, to keep the ownership logic simple, it
seemed best to keep `ChatComposer` distinct from `BottomPaneView`.
This commit is contained in:
Michael Bolin
2025-05-14 10:13:29 -07:00
committed by GitHub
parent 399e819c9b
commit 0402aef126
8 changed files with 490 additions and 358 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,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::BottomPaneView;
/// Modal overlay asking the user to approve/deny a sequence of requests.
pub(crate) struct ApprovalModalView<'a> {
current: UserApprovalWidget<'a>,
queue: Vec<ApprovalRequest>,
app_event_tx: Sender<AppEvent>,
}
impl ApprovalModalView<'_> {
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> BottomPaneView<'a> for ApprovalModalView<'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 calculate_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 try_consume_approval_request(&mut self, req: ApprovalRequest) -> Option<ApprovalRequest> {
self.enqueue_request(req);
None
}
}

View File

@@ -0,0 +1,56 @@
use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use std::sync::mpsc::SendError;
use crate::app_event::AppEvent;
use crate::user_approval_widget::ApprovalRequest;
use super::BottomPane;
/// Type to use for a method that may require a redraw of the UI.
pub(crate) enum ConditionalUpdate {
NeedsRedraw,
NoRedraw,
}
/// Trait implemented by every view that can be shown in the bottom pane.
pub(crate) trait BottomPaneView<'a> {
/// Handle a key event while the view is active. A redraw is always
/// scheduled after this call.
fn handle_key_event(
&mut self,
pane: &mut BottomPane<'a>,
key_event: KeyEvent,
) -> Result<(), SendError<AppEvent>>;
/// Return `true` if the view has finished and should be removed.
fn is_complete(&self) -> bool {
false
}
/// Height required to render the view.
fn calculate_required_height(&self, area: &Rect) -> u16;
/// Render the view: this will be displayed in place of the composer.
fn render(&self, area: Rect, buf: &mut Buffer);
/// Update the status indicator text.
fn update_status_text(&mut self, _text: String) -> ConditionalUpdate {
ConditionalUpdate::NoRedraw
}
/// Called when task completes to check if the view should be hidden.
fn should_hide_when_task_is_done(&mut self) -> bool {
false
}
/// Try to handle approval request; return the original value if not
/// consumed.
fn try_consume_approval_request(
&mut self,
request: ApprovalRequest,
) -> Option<ApprovalRequest> {
Some(request)
}
}

View File

@@ -0,0 +1,117 @@
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::Borders;
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 ChatComposer<'a> {
textarea: TextArea<'a>,
}
impl ChatComposer<'_> {
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 calculate_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) {
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 &ChatComposer<'_> {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
self.textarea.render(area, buf);
}
}

View File

@@ -0,0 +1,182 @@
//! Bottom pane: shows the ChatComposer or a BottomPaneView, if one is active.
use bottom_pane_view::BottomPaneView;
use bottom_pane_view::ConditionalUpdate;
use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::widgets::WidgetRef;
use std::sync::mpsc::SendError;
use std::sync::mpsc::Sender;
use crate::app_event::AppEvent;
use crate::user_approval_widget::ApprovalRequest;
mod approval_modal_view;
mod bottom_pane_view;
mod chat_composer;
mod status_indicator_view;
pub(crate) use chat_composer::ChatComposer;
pub(crate) use chat_composer::InputResult;
use approval_modal_view::ApprovalModalView;
use status_indicator_view::StatusIndicatorView;
/// Pane displayed in the lower half of the chat UI.
pub(crate) struct BottomPane<'a> {
/// Composer is retained even when a BottomPaneView is displayed so the
/// input state is retained when the view is closed.
composer: ChatComposer<'a>,
/// If present, this is displayed instead of the `composer`.
active_view: Option<Box<dyn BottomPaneView<'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 {
composer: ChatComposer::new(params.has_input_focus),
active_view: 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 view or the composer.
pub fn handle_key_event(
&mut self,
key_event: KeyEvent,
) -> Result<InputResult, SendError<AppEvent>> {
if let Some(mut view) = self.active_view.take() {
view.handle_key_event(self, key_event)?;
if !view.is_complete() {
self.active_view = Some(view);
} else if self.is_task_running {
let height = self.composer.calculate_required_height(&Rect::default());
self.active_view = Some(Box::new(StatusIndicatorView::new(
self.app_event_tx.clone(),
height,
)));
}
self.request_redraw()?;
Ok(InputResult::None)
} else {
let (input_result, needs_redraw) = self.composer.handle_key_event(key_event);
if needs_redraw {
self.request_redraw()?;
}
Ok(input_result)
}
}
/// Update the status indicator text (only when the `StatusIndicatorView` is
/// active).
pub(crate) fn update_status_text(&mut self, text: String) -> Result<(), SendError<AppEvent>> {
if let Some(view) = &mut self.active_view {
match view.update_status_text(text) {
ConditionalUpdate::NeedsRedraw => {
self.request_redraw()?;
}
ConditionalUpdate::NoRedraw => {
// No redraw needed.
}
}
}
Ok(())
}
/// Update the UI to reflect whether this `BottomPane` has input focus.
pub(crate) fn set_input_focus(&mut self, has_focus: bool) {
self.has_input_focus = has_focus;
self.composer.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.active_view.is_some()) {
(true, false) => {
// Show status indicator overlay.
let height = self.composer.calculate_required_height(&Rect::default());
self.active_view = Some(Box::new(StatusIndicatorView::new(
self.app_event_tx.clone(),
height,
)));
self.request_redraw()?;
}
(false, true) => {
if let Some(mut view) = self.active_view.take() {
if view.should_hide_when_task_is_done() {
// Leave self.active_view as None.
self.request_redraw()?;
} else {
// Preserve the view.
self.active_view = Some(view);
}
}
}
_ => {
// No change.
}
}
Ok(())
}
/// Called when the agent requests user approval.
pub fn push_approval_request(
&mut self,
request: ApprovalRequest,
) -> Result<(), SendError<AppEvent>> {
let request = if let Some(view) = self.active_view.as_mut() {
match view.try_consume_approval_request(request) {
Some(request) => request,
None => {
self.request_redraw()?;
return Ok(());
}
}
} else {
request
};
// Otherwise create a new approval modal overlay.
let modal = ApprovalModalView::new(request, self.app_event_tx.clone());
self.active_view = Some(Box::new(modal));
self.request_redraw()
}
/// Height (terminal rows) required by the current bottom pane.
pub fn calculate_required_height(&self, area: &Rect) -> u16 {
if let Some(view) = &self.active_view {
view.calculate_required_height(area)
} else {
self.composer.calculate_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 BottomPaneView if present.
if let Some(ov) = &self.active_view {
ov.render(area, buf);
} else {
(&self.composer).render_ref(area, buf);
}
}
}

View File

@@ -0,0 +1,57 @@
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::BottomPaneView;
use super::bottom_pane_view::ConditionalUpdate;
pub(crate) struct StatusIndicatorView {
view: StatusIndicatorWidget,
}
impl StatusIndicatorView {
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> BottomPaneView<'a> for StatusIndicatorView {
fn handle_key_event(
&mut self,
_pane: &mut BottomPane<'a>,
_key_event: KeyEvent,
) -> Result<(), SendError<AppEvent>> {
Ok(())
}
fn update_status_text(&mut self, text: String) -> ConditionalUpdate {
self.update_text(text);
ConditionalUpdate::NeedsRedraw
}
fn should_hide_when_task_is_done(&mut self) -> bool {
true
}
fn calculate_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

@@ -351,7 +351,7 @@ impl ChatWidget<'_> {
pub(crate) fn update_latest_log(
&mut self,
line: String,
) -> std::result::Result<(), std::sync::mpsc::SendError<AppEvent>> {
) -> std::result::Result<(), SendError<AppEvent>> {
// Forward only if we are currently showing the status indicator.
self.bottom_pane.update_status_text(line)?;
Ok(())
@@ -365,7 +365,7 @@ impl ChatWidget<'_> {
pub(crate) fn handle_scroll_delta(
&mut self,
scroll_delta: i32,
) -> std::result::Result<(), std::sync::mpsc::SendError<AppEvent>> {
) -> std::result::Result<(), SendError<AppEvent>> {
// If the user is trying to scroll exactly one line, we let them, but
// otherwise we assume they are trying to scroll in larger increments.
let magnified_scroll_delta = if scroll_delta == 1 {
@@ -389,7 +389,7 @@ impl ChatWidget<'_> {
impl WidgetRef for &ChatWidget<'_> {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let bottom_height = self.bottom_pane.required_height(&area);
let bottom_height = self.bottom_pane.calculate_required_height(&area);
let chunks = Layout::default()
.direction(Direction::Vertical)

View File

@@ -1,10 +1,5 @@
//! A live status indicator that shows the *latest* log line emitted by the
//! application while the agent is processing a longrunning task.
//!
//! It replaces the old spinner animation with real log feedback so users can
//! watch Codex “think” in realtime. Whenever new text is provided via
//! [`StatusIndicatorWidget::update_text`], the parent widget triggers a
//! redraw so the change is visible immediately.
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
@@ -14,7 +9,6 @@ use std::sync::mpsc::Sender;
use std::thread;
use std::time::Duration;
use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
use ratatui::layout::Alignment;
use ratatui::layout::Rect;
@@ -45,8 +39,8 @@ pub(crate) struct StatusIndicatorWidget {
/// input mode and loading mode.
height: u16,
frame_idx: std::sync::Arc<AtomicUsize>,
running: std::sync::Arc<AtomicBool>,
frame_idx: Arc<AtomicUsize>,
running: Arc<AtomicBool>,
// Keep one sender alive to prevent the channel from closing while the
// animation thread is still running. The field itself is currently not
// accessed anywhere, therefore the leading underscore silences the
@@ -87,14 +81,6 @@ impl StatusIndicatorWidget {
}
}
pub(crate) fn handle_key_event(
&mut self,
_key: KeyEvent,
) -> Result<bool, std::sync::mpsc::SendError<AppEvent>> {
// The indicator does not handle any input always return `false`.
Ok(false)
}
/// Preferred height in terminal rows.
pub(crate) fn get_height(&self) -> u16 {
self.height