mirror of
https://github.com/openai/codex.git
synced 2026-05-02 18:37:01 +00:00
(Hopefully) temporary solution to the invisible approvals problem - prints commands to history when they need approval and then also prints the result of the approval. In the near future we should be able to do some fancy stuff with updating commands before writing them to permanent history. Also, ctr-c while in the approval modal now acts as esc (aborts command) and puts the TUI in the state where one additional ctr-c will exit.
301 lines
9.3 KiB
Rust
301 lines
9.3 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,
|
|
}
|
|
}
|
|
|
|
/// 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());
|
|
}
|
|
}
|