Proof of concept for a resizable viewport. The general approach here is to duplicate the `Terminal` struct from ratatui, but with our own logic. This is a "light fork" in that we are still using all the base ratatui functions (`Buffer`, `Widget` and so on), but we're doing our own bookkeeping at the top level to determine where to draw everything. This approach could use improvement—e.g, when the window is resized to a smaller size, if the UI wraps, we don't correctly clear out the artifacts from wrapping. This is possible with a little work (i.e. tracking what parts of our UI would have been wrapped), but this behavior is at least at par with the existing behavior. https://github.com/user-attachments/assets/4eb17689-09fd-4daa-8315-c7ebc654986d cc @joshka who might have Thoughts™
305 lines
9.4 KiB
Rust
305 lines
9.4 KiB
Rust
//! Bottom pane: shows the ChatComposer or a BottomPaneView, if one is active.
|
|
|
|
use crate::app_event::AppEvent;
|
|
use crate::app_event_sender::AppEventSender;
|
|
use crate::user_approval_widget::ApprovalRequest;
|
|
use bottom_pane_view::BottomPaneView;
|
|
use bottom_pane_view::ConditionalUpdate;
|
|
use codex_core::protocol::TokenUsage;
|
|
use codex_file_search::FileMatch;
|
|
use crossterm::event::KeyEvent;
|
|
use ratatui::buffer::Buffer;
|
|
use ratatui::layout::Rect;
|
|
use ratatui::widgets::WidgetRef;
|
|
|
|
mod approval_modal_view;
|
|
mod bottom_pane_view;
|
|
mod chat_composer;
|
|
mod chat_composer_history;
|
|
mod command_popup;
|
|
mod file_search_popup;
|
|
mod status_indicator_view;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub(crate) enum CancellationEvent {
|
|
Ignored,
|
|
Handled,
|
|
}
|
|
|
|
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: AppEventSender,
|
|
has_input_focus: bool,
|
|
is_task_running: bool,
|
|
ctrl_c_quit_hint: bool,
|
|
}
|
|
|
|
pub(crate) struct BottomPaneParams {
|
|
pub(crate) app_event_tx: AppEventSender,
|
|
pub(crate) has_input_focus: bool,
|
|
}
|
|
|
|
impl BottomPane<'_> {
|
|
pub fn new(params: BottomPaneParams) -> Self {
|
|
Self {
|
|
composer: ChatComposer::new(params.has_input_focus, params.app_event_tx.clone()),
|
|
active_view: None,
|
|
app_event_tx: params.app_event_tx,
|
|
has_input_focus: params.has_input_focus,
|
|
is_task_running: false,
|
|
ctrl_c_quit_hint: false,
|
|
}
|
|
}
|
|
|
|
pub fn desired_height(&self) -> u16 {
|
|
self.composer.desired_height()
|
|
}
|
|
|
|
/// Forward a key event to the active view or the composer.
|
|
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> InputResult {
|
|
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 {
|
|
self.active_view = Some(Box::new(StatusIndicatorView::new(
|
|
self.app_event_tx.clone(),
|
|
)));
|
|
}
|
|
self.request_redraw();
|
|
InputResult::None
|
|
} else {
|
|
let (input_result, needs_redraw) = self.composer.handle_key_event(key_event);
|
|
if needs_redraw {
|
|
self.request_redraw();
|
|
}
|
|
input_result
|
|
}
|
|
}
|
|
|
|
/// Handle Ctrl-C in the bottom pane. If a modal view is active it gets a
|
|
/// chance to consume the event (e.g. to dismiss itself).
|
|
pub(crate) fn on_ctrl_c(&mut self) -> CancellationEvent {
|
|
let mut view = match self.active_view.take() {
|
|
Some(view) => view,
|
|
None => return CancellationEvent::Ignored,
|
|
};
|
|
|
|
let event = view.on_ctrl_c(self);
|
|
match event {
|
|
CancellationEvent::Handled => {
|
|
if !view.is_complete() {
|
|
self.active_view = Some(view);
|
|
} else if self.is_task_running {
|
|
self.active_view = Some(Box::new(StatusIndicatorView::new(
|
|
self.app_event_tx.clone(),
|
|
)));
|
|
}
|
|
self.show_ctrl_c_quit_hint();
|
|
}
|
|
CancellationEvent::Ignored => {
|
|
self.active_view = Some(view);
|
|
}
|
|
}
|
|
event
|
|
}
|
|
|
|
pub fn handle_paste(&mut self, pasted: String) {
|
|
if self.active_view.is_none() {
|
|
let needs_redraw = self.composer.handle_paste(pasted);
|
|
if needs_redraw {
|
|
self.request_redraw();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Update the status indicator text (only when the `StatusIndicatorView` is
|
|
/// active).
|
|
pub(crate) fn update_status_text(&mut self, text: String) {
|
|
if let Some(view) = &mut self.active_view {
|
|
match view.update_status_text(text) {
|
|
ConditionalUpdate::NeedsRedraw => {
|
|
self.request_redraw();
|
|
}
|
|
ConditionalUpdate::NoRedraw => {
|
|
// No redraw needed.
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn show_ctrl_c_quit_hint(&mut self) {
|
|
self.ctrl_c_quit_hint = true;
|
|
self.composer
|
|
.set_ctrl_c_quit_hint(true, self.has_input_focus);
|
|
self.request_redraw();
|
|
}
|
|
|
|
pub(crate) fn clear_ctrl_c_quit_hint(&mut self) {
|
|
if self.ctrl_c_quit_hint {
|
|
self.ctrl_c_quit_hint = false;
|
|
self.composer
|
|
.set_ctrl_c_quit_hint(false, self.has_input_focus);
|
|
self.request_redraw();
|
|
}
|
|
}
|
|
|
|
pub(crate) fn ctrl_c_quit_hint_visible(&self) -> bool {
|
|
self.ctrl_c_quit_hint
|
|
}
|
|
|
|
pub fn set_task_running(&mut self, running: bool) {
|
|
self.is_task_running = running;
|
|
|
|
match (running, self.active_view.is_some()) {
|
|
(true, false) => {
|
|
// Show status indicator overlay.
|
|
self.active_view = Some(Box::new(StatusIndicatorView::new(
|
|
self.app_event_tx.clone(),
|
|
)));
|
|
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.
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn composer_is_empty(&self) -> bool {
|
|
self.composer.is_empty()
|
|
}
|
|
|
|
pub(crate) fn is_task_running(&self) -> bool {
|
|
self.is_task_running
|
|
}
|
|
|
|
/// Update the *context-window remaining* indicator in the composer. This
|
|
/// is forwarded directly to the underlying `ChatComposer`.
|
|
pub(crate) fn set_token_usage(
|
|
&mut self,
|
|
token_usage: TokenUsage,
|
|
model_context_window: Option<u64>,
|
|
) {
|
|
self.composer
|
|
.set_token_usage(token_usage, model_context_window);
|
|
self.request_redraw();
|
|
}
|
|
|
|
/// Called when the agent requests user approval.
|
|
pub fn push_approval_request(&mut self, request: ApprovalRequest) {
|
|
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;
|
|
}
|
|
}
|
|
} 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(crate) fn request_redraw(&self) {
|
|
self.app_event_tx.send(AppEvent::RequestRedraw)
|
|
}
|
|
|
|
// --- History helpers ---
|
|
|
|
pub(crate) fn set_history_metadata(&mut self, log_id: u64, entry_count: usize) {
|
|
self.composer.set_history_metadata(log_id, entry_count);
|
|
}
|
|
|
|
pub(crate) fn on_history_entry_response(
|
|
&mut self,
|
|
log_id: u64,
|
|
offset: usize,
|
|
entry: Option<String>,
|
|
) {
|
|
let updated = self
|
|
.composer
|
|
.on_history_entry_response(log_id, offset, entry);
|
|
|
|
if updated {
|
|
self.request_redraw();
|
|
}
|
|
}
|
|
|
|
pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec<FileMatch>) {
|
|
self.composer.on_file_search_result(query, matches);
|
|
self.request_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);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::app_event::AppEvent;
|
|
use std::path::PathBuf;
|
|
use std::sync::mpsc::channel;
|
|
|
|
fn exec_request() -> ApprovalRequest {
|
|
ApprovalRequest::Exec {
|
|
id: "1".to_string(),
|
|
command: vec!["echo".into(), "ok".into()],
|
|
cwd: PathBuf::from("."),
|
|
reason: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ctrl_c_on_modal_consumes_and_shows_quit_hint() {
|
|
let (tx_raw, _rx) = channel::<AppEvent>();
|
|
let tx = AppEventSender::new(tx_raw);
|
|
let mut pane = BottomPane::new(BottomPaneParams {
|
|
app_event_tx: tx,
|
|
has_input_focus: true,
|
|
});
|
|
pane.push_approval_request(exec_request());
|
|
assert_eq!(CancellationEvent::Handled, pane.on_ctrl_c());
|
|
assert!(pane.ctrl_c_quit_hint_visible());
|
|
assert_eq!(CancellationEvent::Ignored, pane.on_ctrl_c());
|
|
}
|
|
}
|