mirror of
https://github.com/openai/codex.git
synced 2026-04-30 09:26:44 +00:00
778 lines
34 KiB
Rust
778 lines
34 KiB
Rust
use crate::app_event::AppEvent;
|
||
use crate::app_event_sender::AppEventSender;
|
||
use crate::chatwidget::ChatWidget;
|
||
use crate::command_utils::strip_surrounding_quotes;
|
||
use crate::danger_warning_screen::DangerWarningOutcome;
|
||
use crate::danger_warning_screen::DangerWarningScreen;
|
||
use crate::file_search::FileSearchManager;
|
||
use crate::get_git_diff::get_git_diff;
|
||
use crate::git_warning_screen::GitWarningOutcome;
|
||
use crate::git_warning_screen::GitWarningScreen;
|
||
use crate::slash_command::SlashCommand;
|
||
use crate::tui;
|
||
use codex_core::config::Config;
|
||
use codex_core::protocol::Event;
|
||
use codex_core::protocol::EventMsg;
|
||
use codex_core::protocol::Op;
|
||
use color_eyre::eyre::Result;
|
||
use crossterm::SynchronizedUpdate;
|
||
use crossterm::event::KeyCode;
|
||
use crossterm::event::KeyEvent;
|
||
use crossterm::event::KeyEventKind;
|
||
use crossterm::execute as ct_execute;
|
||
use crossterm::terminal::EnterAlternateScreen;
|
||
use crossterm::terminal::LeaveAlternateScreen;
|
||
use crossterm::terminal::supports_keyboard_enhancement;
|
||
use ratatui::layout::Offset;
|
||
use ratatui::layout::Rect;
|
||
use ratatui::prelude::Backend;
|
||
use ratatui::text::Line;
|
||
use std::path::PathBuf;
|
||
use std::sync::Arc;
|
||
use std::sync::atomic::AtomicBool;
|
||
use std::sync::atomic::Ordering;
|
||
use std::sync::mpsc::Receiver;
|
||
use std::sync::mpsc::channel;
|
||
use std::thread;
|
||
use std::time::Duration;
|
||
|
||
/// Time window for debouncing redraw requests.
|
||
const REDRAW_DEBOUNCE: Duration = Duration::from_millis(10);
|
||
|
||
/// Top-level application state: which full-screen view is currently active.
|
||
#[allow(clippy::large_enum_variant)]
|
||
enum AppState<'a> {
|
||
/// The main chat UI is visible.
|
||
Chat {
|
||
/// Boxed to avoid a large enum variant and reduce the overall size of
|
||
/// `AppState`.
|
||
widget: Box<ChatWidget<'a>>,
|
||
},
|
||
/// The start-up warning that recommends running codex inside a Git repo.
|
||
GitWarning { screen: GitWarningScreen },
|
||
/// Full‑screen warning when switching to the fully‑unsafe execution mode.
|
||
DangerWarning {
|
||
screen: DangerWarningScreen,
|
||
/// Retain the chat widget so background events can still be processed.
|
||
widget: Box<ChatWidget<'a>>,
|
||
pending_approval: codex_core::protocol::AskForApproval,
|
||
pending_sandbox: codex_core::protocol::SandboxPolicy,
|
||
},
|
||
}
|
||
|
||
/// Strip a single pair of surrounding quotes from the provided string if present.
|
||
/// Supports straight and common curly quotes: '…', "…", ‘…’, “…”.
|
||
// strip_surrounding_quotes moved to command_utils.rs
|
||
|
||
pub(crate) struct App<'a> {
|
||
app_event_tx: AppEventSender,
|
||
app_event_rx: Receiver<AppEvent>,
|
||
app_state: AppState<'a>,
|
||
|
||
/// Config is stored here so we can recreate ChatWidgets as needed.
|
||
config: Config,
|
||
|
||
file_search: FileSearchManager,
|
||
|
||
/// True when a redraw has been scheduled but not yet executed.
|
||
pending_redraw: Arc<AtomicBool>,
|
||
|
||
pending_history_lines: Vec<Line<'static>>,
|
||
|
||
/// Stored parameters needed to instantiate the ChatWidget later, e.g.,
|
||
/// after dismissing the Git-repo warning.
|
||
chat_args: Option<ChatWidgetArgs>,
|
||
|
||
enhanced_keys_supported: bool,
|
||
/// One-shot flag to resync viewport and cursor after leaving the
|
||
/// alternate-screen Danger warning so the composer stays at the bottom.
|
||
fixup_viewport_after_danger: bool,
|
||
/// If set, defer opening the DangerWarning screen until after the next
|
||
/// redraw so any selection popups are cleared from the normal screen.
|
||
pending_show_danger: Option<(codex_core::protocol::AskForApproval, codex_core::protocol::SandboxPolicy)>,
|
||
last_bottom_pane_area: Option<Rect>,
|
||
}
|
||
|
||
/// Aggregate parameters needed to create a `ChatWidget`, as creation may be
|
||
/// deferred until after the Git warning screen is dismissed.
|
||
#[derive(Clone)]
|
||
struct ChatWidgetArgs {
|
||
config: Config,
|
||
initial_prompt: Option<String>,
|
||
initial_images: Vec<PathBuf>,
|
||
enhanced_keys_supported: bool,
|
||
cli_flags_used: Vec<String>,
|
||
cli_model: Option<String>,
|
||
}
|
||
|
||
impl App<'_> {
|
||
/// Handle `/model <arg>` from the slash command dispatcher.
|
||
fn handle_model_command(&mut self, args: &str) {
|
||
let arg = args.trim();
|
||
if let AppState::Chat { widget } = &mut self.app_state {
|
||
let normalized = strip_surrounding_quotes(arg).trim().to_string();
|
||
if !normalized.is_empty() {
|
||
widget.update_model_and_reconfigure(normalized);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Handle `/approvals <preset>` from the slash command dispatcher.
|
||
fn handle_approvals_command(&mut self, args: &str) {
|
||
let arg = args.trim();
|
||
if let AppState::Chat { widget } = &mut self.app_state {
|
||
let normalized = strip_surrounding_quotes(arg).trim().to_string();
|
||
if !normalized.is_empty() {
|
||
use crate::command_utils::parse_execution_mode_token;
|
||
if let Some((approval, sandbox)) = parse_execution_mode_token(&normalized) {
|
||
widget.update_execution_mode_and_reconfigure(approval, sandbox);
|
||
} else {
|
||
widget.add_diff_output(format!(
|
||
"`/approvals {normalized}` — unrecognized execution mode"
|
||
));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
pub(crate) fn new(
|
||
config: Config,
|
||
initial_prompt: Option<String>,
|
||
show_git_warning: bool,
|
||
initial_images: Vec<std::path::PathBuf>,
|
||
cli_flags_used: Vec<String>,
|
||
cli_model: Option<String>,
|
||
) -> Self {
|
||
let (app_event_tx, app_event_rx) = channel();
|
||
let app_event_tx = AppEventSender::new(app_event_tx);
|
||
let pending_redraw = Arc::new(AtomicBool::new(false));
|
||
|
||
let enhanced_keys_supported = supports_keyboard_enhancement().unwrap_or(false);
|
||
|
||
// Spawn a dedicated thread for reading the crossterm event loop and
|
||
// re-publishing the events as AppEvents, as appropriate.
|
||
{
|
||
let app_event_tx = app_event_tx.clone();
|
||
std::thread::spawn(move || {
|
||
loop {
|
||
// This timeout is necessary to avoid holding the event lock
|
||
// that crossterm::event::read() acquires. In particular,
|
||
// reading the cursor position (crossterm::cursor::position())
|
||
// needs to acquire the event lock, and so will fail if it
|
||
// can't acquire it within 2 sec. Resizing the terminal
|
||
// crashes the app if the cursor position can't be read.
|
||
if let Ok(true) = crossterm::event::poll(Duration::from_millis(100)) {
|
||
if let Ok(event) = crossterm::event::read() {
|
||
match event {
|
||
crossterm::event::Event::Key(key_event) => {
|
||
app_event_tx.send(AppEvent::KeyEvent(key_event));
|
||
}
|
||
crossterm::event::Event::Resize(_, _) => {
|
||
app_event_tx.send(AppEvent::RequestRedraw);
|
||
}
|
||
crossterm::event::Event::Paste(pasted) => {
|
||
// Many terminals convert newlines to \r when
|
||
// pasting, e.g. [iTerm2][]. But [tui-textarea
|
||
// expects \n][tui-textarea]. This seems like a bug
|
||
// in tui-textarea IMO, but work around it for now.
|
||
// [tui-textarea]: https://github.com/rhysd/tui-textarea/blob/4d18622eeac13b309e0ff6a55a46ac6706da68cf/src/textarea.rs#L782-L783
|
||
// [iTerm2]: https://github.com/gnachman/iTerm2/blob/5d0c0d9f68523cbd0494dad5422998964a2ecd8d/sources/iTermPasteHelper.m#L206-L216
|
||
let pasted = pasted.replace("\r", "\n");
|
||
app_event_tx.send(AppEvent::Paste(pasted));
|
||
}
|
||
_ => {
|
||
// Ignore any other events.
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
// Timeout expired, no `Event` is available
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
let (app_state, chat_args) = if show_git_warning {
|
||
(
|
||
AppState::GitWarning {
|
||
screen: GitWarningScreen::new(),
|
||
},
|
||
Some(ChatWidgetArgs {
|
||
config: config.clone(),
|
||
initial_prompt,
|
||
initial_images,
|
||
enhanced_keys_supported,
|
||
cli_flags_used: cli_flags_used.clone(),
|
||
cli_model: cli_model.clone(),
|
||
}),
|
||
)
|
||
} else {
|
||
let chat_widget = ChatWidget::new(
|
||
config.clone(),
|
||
app_event_tx.clone(),
|
||
initial_prompt,
|
||
initial_images,
|
||
enhanced_keys_supported,
|
||
cli_flags_used.clone(),
|
||
cli_model.clone(),
|
||
);
|
||
(
|
||
AppState::Chat {
|
||
widget: Box::new(chat_widget),
|
||
},
|
||
None,
|
||
)
|
||
};
|
||
|
||
let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone());
|
||
Self {
|
||
app_event_tx,
|
||
pending_history_lines: Vec::new(),
|
||
app_event_rx,
|
||
app_state,
|
||
config,
|
||
file_search,
|
||
pending_redraw,
|
||
chat_args,
|
||
enhanced_keys_supported,
|
||
fixup_viewport_after_danger: false,
|
||
pending_show_danger: None,
|
||
last_bottom_pane_area: None,
|
||
}
|
||
}
|
||
|
||
/// Clone of the internal event sender so external tasks (e.g. log bridge)
|
||
/// can inject `AppEvent`s.
|
||
pub fn event_sender(&self) -> AppEventSender {
|
||
self.app_event_tx.clone()
|
||
}
|
||
|
||
/// Schedule a redraw if one is not already pending.
|
||
#[allow(clippy::unwrap_used)]
|
||
fn schedule_redraw(&self) {
|
||
// Attempt to set the flag to `true`. If it was already `true`, another
|
||
// redraw is already pending so we can return early.
|
||
if self
|
||
.pending_redraw
|
||
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||
.is_err()
|
||
{
|
||
return;
|
||
}
|
||
|
||
let tx = self.app_event_tx.clone();
|
||
let pending_redraw = self.pending_redraw.clone();
|
||
thread::spawn(move || {
|
||
thread::sleep(REDRAW_DEBOUNCE);
|
||
tx.send(AppEvent::Redraw);
|
||
pending_redraw.store(false, Ordering::SeqCst);
|
||
});
|
||
}
|
||
|
||
pub(crate) fn run(&mut self, terminal: &mut tui::Tui) -> Result<()> {
|
||
// Insert an event to trigger the first render.
|
||
let app_event_tx = self.app_event_tx.clone();
|
||
app_event_tx.send(AppEvent::RequestRedraw);
|
||
|
||
while let Ok(event) = self.app_event_rx.recv() {
|
||
match event {
|
||
AppEvent::InsertHistory(lines) => {
|
||
self.pending_history_lines.extend(lines);
|
||
self.app_event_tx.send(AppEvent::RequestRedraw);
|
||
}
|
||
AppEvent::RequestRedraw => {
|
||
self.schedule_redraw();
|
||
}
|
||
AppEvent::Redraw => {
|
||
std::io::stdout().sync_update(|_| self.draw_next_frame(terminal))??;
|
||
if let Some((approval, sandbox)) = self.pending_show_danger.take() {
|
||
if let Some(area) = self.last_bottom_pane_area {
|
||
use crossterm::cursor::MoveTo;
|
||
use crossterm::terminal::{Clear, ClearType};
|
||
use crossterm::queue;
|
||
use std::io::Write;
|
||
for y in area.y..area.bottom() {
|
||
let _ = queue!(std::io::stdout(), MoveTo(0, y), Clear(ClearType::CurrentLine));
|
||
}
|
||
let _ = std::io::stdout().flush();
|
||
}
|
||
if let AppState::Chat { widget } = std::mem::replace(
|
||
&mut self.app_state,
|
||
AppState::GitWarning { screen: GitWarningScreen::new() },
|
||
) {
|
||
let _ = ct_execute!(std::io::stdout(), EnterAlternateScreen);
|
||
self.app_state = AppState::DangerWarning {
|
||
screen: DangerWarningScreen::new(),
|
||
widget,
|
||
pending_approval: approval,
|
||
pending_sandbox: sandbox,
|
||
};
|
||
self.app_event_tx.send(AppEvent::RequestRedraw);
|
||
}
|
||
}
|
||
}
|
||
AppEvent::KeyEvent(key_event) => {
|
||
match key_event {
|
||
KeyEvent {
|
||
code: KeyCode::Char('c'),
|
||
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
||
kind: KeyEventKind::Press,
|
||
..
|
||
} => {
|
||
match &mut self.app_state {
|
||
AppState::Chat { widget } => {
|
||
widget.on_ctrl_c();
|
||
}
|
||
AppState::GitWarning { .. } => {
|
||
// No-op.
|
||
}
|
||
AppState::DangerWarning { .. } => {
|
||
// No-op on Ctrl+C in danger screen
|
||
}
|
||
}
|
||
}
|
||
KeyEvent {
|
||
code: KeyCode::Char('d'),
|
||
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
||
kind: KeyEventKind::Press,
|
||
..
|
||
} => {
|
||
match &mut self.app_state {
|
||
AppState::Chat { widget } => {
|
||
if widget.composer_is_empty() {
|
||
self.app_event_tx.send(AppEvent::ExitRequest);
|
||
} else {
|
||
// Treat Ctrl+D as a normal key event when the composer
|
||
// is not empty so that it doesn't quit the application
|
||
// prematurely.
|
||
self.dispatch_key_event(key_event);
|
||
}
|
||
}
|
||
AppState::GitWarning { .. } => {
|
||
self.app_event_tx.send(AppEvent::ExitRequest);
|
||
}
|
||
AppState::DangerWarning { .. } => {
|
||
// Ignore Ctrl+D while danger screen is visible.
|
||
}
|
||
}
|
||
}
|
||
KeyEvent {
|
||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||
..
|
||
} => {
|
||
self.dispatch_key_event(key_event);
|
||
}
|
||
_ => {
|
||
// Ignore Release key events for now.
|
||
}
|
||
};
|
||
}
|
||
AppEvent::Paste(text) => {
|
||
self.dispatch_paste_event(text);
|
||
}
|
||
AppEvent::CodexEvent(event) => {
|
||
self.dispatch_codex_event(event);
|
||
}
|
||
AppEvent::ExitRequest => {
|
||
break;
|
||
}
|
||
AppEvent::SelectModel(model) => {
|
||
if let AppState::Chat { widget } = &mut self.app_state {
|
||
widget.update_model_and_reconfigure(model);
|
||
}
|
||
}
|
||
AppEvent::SelectExecutionMode { approval, sandbox } => {
|
||
// Intercept the dangerous preset with a full‑screen warning.
|
||
if let AppState::Chat { widget: _ } = &self.app_state {
|
||
if crate::command_utils::ExecutionPreset::from_policies(approval, &sandbox)
|
||
== Some(crate::command_utils::ExecutionPreset::FullYolo)
|
||
{
|
||
// Defer opening the danger screen until after the next redraw so the
|
||
// selection popup is closed and the normal screen is clean.
|
||
self.pending_show_danger = Some((approval, sandbox));
|
||
self.app_event_tx.send(AppEvent::RequestRedraw);
|
||
} else if let AppState::Chat { widget } = std::mem::replace(
|
||
&mut self.app_state,
|
||
AppState::GitWarning { screen: GitWarningScreen::new() },
|
||
) {
|
||
// Restore chat state and apply immediately for safe presets.
|
||
let mut w = widget;
|
||
w.update_execution_mode_and_reconfigure(approval, sandbox);
|
||
self.app_state = AppState::Chat { widget: w };
|
||
}
|
||
}
|
||
}
|
||
AppEvent::OpenModelSelector => {
|
||
if let AppState::Chat { widget } = &mut self.app_state {
|
||
widget.show_model_selector();
|
||
}
|
||
}
|
||
AppEvent::OpenApprovalSelector => {
|
||
if let AppState::Chat { widget } = &mut self.app_state {
|
||
widget.show_execution_selector();
|
||
}
|
||
}
|
||
AppEvent::CodexOp(op) => match &mut self.app_state {
|
||
AppState::Chat { widget } => widget.submit_op(op),
|
||
AppState::GitWarning { .. } => {}
|
||
AppState::DangerWarning { widget, .. } => widget.submit_op(op),
|
||
},
|
||
AppEvent::LatestLog(line) => match &mut self.app_state {
|
||
AppState::Chat { widget } => widget.update_latest_log(line),
|
||
AppState::GitWarning { .. } => {}
|
||
AppState::DangerWarning { widget, .. } => widget.update_latest_log(line),
|
||
},
|
||
AppEvent::DispatchCommand(command) => match command {
|
||
SlashCommand::New => {
|
||
let new_widget = Box::new(ChatWidget::new(
|
||
self.config.clone(),
|
||
self.app_event_tx.clone(),
|
||
None,
|
||
Vec::new(),
|
||
self.enhanced_keys_supported,
|
||
self.chat_args
|
||
.as_ref()
|
||
.map(|a| a.cli_flags_used.clone())
|
||
.unwrap_or_default(),
|
||
self.chat_args.as_ref().and_then(|a| a.cli_model.clone()),
|
||
));
|
||
self.app_state = AppState::Chat { widget: new_widget };
|
||
self.app_event_tx.send(AppEvent::RequestRedraw);
|
||
}
|
||
SlashCommand::Compact => {
|
||
if let AppState::Chat { widget } = &mut self.app_state {
|
||
widget.clear_token_usage();
|
||
self.app_event_tx.send(AppEvent::CodexOp(Op::Compact));
|
||
}
|
||
}
|
||
SlashCommand::Quit => {
|
||
break;
|
||
}
|
||
SlashCommand::Diff => {
|
||
let (is_git_repo, diff_text) = match get_git_diff() {
|
||
Ok(v) => v,
|
||
Err(e) => {
|
||
let msg = format!("Failed to compute diff: {e}");
|
||
if let AppState::Chat { widget } = &mut self.app_state {
|
||
widget.add_diff_output(msg);
|
||
}
|
||
continue;
|
||
}
|
||
};
|
||
|
||
if let AppState::Chat { widget } = &mut self.app_state {
|
||
let text = if is_git_repo {
|
||
diff_text
|
||
} else {
|
||
"`/diff` — _not inside a git repository_".to_string()
|
||
};
|
||
widget.add_diff_output(text);
|
||
}
|
||
}
|
||
#[cfg(debug_assertions)]
|
||
SlashCommand::TestApproval => {
|
||
use std::collections::HashMap;
|
||
|
||
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
|
||
use codex_core::protocol::FileChange;
|
||
|
||
self.app_event_tx.send(AppEvent::CodexEvent(Event {
|
||
id: "1".to_string(),
|
||
// msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
|
||
// call_id: "1".to_string(),
|
||
// command: vec!["git".into(), "apply".into()],
|
||
// cwd: self.config.cwd.clone(),
|
||
// reason: Some("test".to_string()),
|
||
// }),
|
||
msg: EventMsg::ApplyPatchApprovalRequest(
|
||
ApplyPatchApprovalRequestEvent {
|
||
call_id: "1".to_string(),
|
||
changes: HashMap::from([
|
||
(
|
||
PathBuf::from("/tmp/test.txt"),
|
||
FileChange::Add {
|
||
content: "test".to_string(),
|
||
},
|
||
),
|
||
(
|
||
PathBuf::from("/tmp/test2.txt"),
|
||
FileChange::Update {
|
||
unified_diff: "+test\n-test2".to_string(),
|
||
move_path: None,
|
||
},
|
||
),
|
||
]),
|
||
reason: None,
|
||
grant_root: Some(PathBuf::from("/tmp")),
|
||
},
|
||
),
|
||
}));
|
||
}
|
||
SlashCommand::Model => {
|
||
// Disallow `/model` without arguments; no action.
|
||
}
|
||
SlashCommand::Approvals => {
|
||
// Disallow `/approvals` without arguments; no action.
|
||
}
|
||
},
|
||
AppEvent::DispatchCommandWithArgs(command, args) => match command {
|
||
SlashCommand::Model => self.handle_model_command(&args),
|
||
SlashCommand::Approvals => self.handle_approvals_command(&args),
|
||
#[cfg(debug_assertions)]
|
||
SlashCommand::TestApproval => {
|
||
self.app_event_tx.send(AppEvent::DispatchCommand(command));
|
||
}
|
||
SlashCommand::New
|
||
| SlashCommand::Quit
|
||
| SlashCommand::Diff
|
||
| SlashCommand::Compact => {
|
||
self.app_event_tx.send(AppEvent::DispatchCommand(command));
|
||
}
|
||
},
|
||
AppEvent::StartFileSearch(query) => {
|
||
self.file_search.on_user_query(query);
|
||
}
|
||
AppEvent::FileSearchResult { query, matches } => {
|
||
if let AppState::Chat { widget } = &mut self.app_state {
|
||
widget.apply_file_search_result(query, matches);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
terminal.clear()?;
|
||
|
||
Ok(())
|
||
}
|
||
|
||
pub(crate) fn token_usage(&self) -> codex_core::protocol::TokenUsage {
|
||
match &self.app_state {
|
||
AppState::Chat { widget } => widget.token_usage().clone(),
|
||
AppState::GitWarning { .. } => codex_core::protocol::TokenUsage::default(),
|
||
AppState::DangerWarning { widget, .. } => widget.token_usage().clone(),
|
||
}
|
||
}
|
||
|
||
fn draw_next_frame(&mut self, terminal: &mut tui::Tui) -> Result<()> {
|
||
let screen_size = terminal.size()?;
|
||
let last_known_screen_size = terminal.last_known_screen_size;
|
||
if screen_size != last_known_screen_size && !self.fixup_viewport_after_danger {
|
||
let cursor_pos = terminal.get_cursor_position()?;
|
||
let last_known_cursor_pos = terminal.last_known_cursor_pos;
|
||
if cursor_pos.y != last_known_cursor_pos.y {
|
||
// The terminal was resized. The only point of reference we have for where our viewport
|
||
// was moved is the cursor position.
|
||
// NB this assumes that the cursor was not wrapped as part of the resize.
|
||
let cursor_delta = cursor_pos.y as i32 - last_known_cursor_pos.y as i32;
|
||
|
||
let new_viewport_area = terminal.viewport_area.offset(Offset {
|
||
x: 0,
|
||
y: cursor_delta,
|
||
});
|
||
terminal.set_viewport_area(new_viewport_area);
|
||
terminal.clear()?;
|
||
}
|
||
}
|
||
|
||
let size = terminal.size()?;
|
||
let desired_height = match &self.app_state {
|
||
AppState::Chat { widget } => widget.desired_height(size.width),
|
||
AppState::GitWarning { .. } => 10,
|
||
AppState::DangerWarning { .. } => size.height,
|
||
};
|
||
|
||
// If we just left the alternate-screen danger modal, refresh our internal
|
||
// cursor position and anchor the viewport to the bottom so the input bar
|
||
// isn't pushed to the top. Additionally, scroll the region above the new
|
||
// viewport down to eliminate any stale bottom-pane artifacts without
|
||
// clearing the chat history.
|
||
if self.fixup_viewport_after_danger {
|
||
self.fixup_viewport_after_danger = false;
|
||
let pos = terminal.get_cursor_position()?;
|
||
terminal.last_known_cursor_pos = pos;
|
||
let old_area = terminal.viewport_area;
|
||
let mut new_area = old_area;
|
||
new_area.height = desired_height.min(size.height);
|
||
new_area.width = size.width;
|
||
new_area.y = size.height.saturating_sub(new_area.height);
|
||
if new_area != old_area {
|
||
terminal.set_viewport_area(new_area);
|
||
}
|
||
}
|
||
|
||
let mut area = terminal.viewport_area;
|
||
area.height = desired_height.min(size.height);
|
||
area.width = size.width;
|
||
if area.bottom() > size.height {
|
||
terminal
|
||
.backend_mut()
|
||
.scroll_region_up(0..area.top(), area.bottom() - size.height)?;
|
||
area.y = size.height - area.height;
|
||
}
|
||
if area != terminal.viewport_area {
|
||
terminal.clear()?;
|
||
terminal.set_viewport_area(area);
|
||
}
|
||
if !self.pending_history_lines.is_empty() {
|
||
crate::insert_history::insert_history_lines(
|
||
terminal,
|
||
self.pending_history_lines.clone(),
|
||
);
|
||
self.pending_history_lines.clear();
|
||
}
|
||
match &mut self.app_state {
|
||
AppState::Chat { widget } => {
|
||
terminal.draw(|frame| frame.render_widget_ref(&**widget, frame.area()))?;
|
||
self.last_bottom_pane_area = Some(area);
|
||
}
|
||
AppState::GitWarning { screen } => {
|
||
terminal.draw(|frame| frame.render_widget_ref(&*screen, frame.area()))?;
|
||
self.last_bottom_pane_area = None;
|
||
}
|
||
AppState::DangerWarning { screen, .. } => {
|
||
terminal.draw(|frame| frame.render_widget_ref(&*screen, frame.area()))?;
|
||
self.last_bottom_pane_area = None;
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Dispatch a KeyEvent to the current view and let it decide what to do
|
||
/// with it.
|
||
fn dispatch_key_event(&mut self, key_event: KeyEvent) {
|
||
match &mut self.app_state {
|
||
AppState::Chat { widget } => {
|
||
widget.handle_key_event(key_event);
|
||
}
|
||
AppState::GitWarning { screen } => match screen.handle_key_event(key_event) {
|
||
GitWarningOutcome::Continue => {
|
||
// User accepted – switch to chat view.
|
||
let args = match self.chat_args.take() {
|
||
Some(args) => args,
|
||
None => panic!("ChatWidgetArgs already consumed"),
|
||
};
|
||
|
||
let widget = Box::new(ChatWidget::new(
|
||
args.config,
|
||
self.app_event_tx.clone(),
|
||
args.initial_prompt,
|
||
args.initial_images,
|
||
args.enhanced_keys_supported,
|
||
args.cli_flags_used,
|
||
args.cli_model,
|
||
));
|
||
self.app_state = AppState::Chat { widget };
|
||
self.app_event_tx.send(AppEvent::RequestRedraw);
|
||
}
|
||
GitWarningOutcome::Quit => {
|
||
self.app_event_tx.send(AppEvent::ExitRequest);
|
||
}
|
||
GitWarningOutcome::None => {
|
||
// do nothing
|
||
}
|
||
},
|
||
AppState::DangerWarning { screen, .. } => match screen.handle_key_event(key_event) {
|
||
DangerWarningOutcome::Continue => {
|
||
let taken = std::mem::replace(
|
||
&mut self.app_state,
|
||
AppState::GitWarning {
|
||
screen: GitWarningScreen::new(),
|
||
},
|
||
);
|
||
let _ = ct_execute!(std::io::stdout(), LeaveAlternateScreen);
|
||
// After leaving the alternate screen, resync our viewport/cursor
|
||
// so the chat composer stays anchored at the bottom.
|
||
self.fixup_viewport_after_danger = true;
|
||
if let AppState::DangerWarning {
|
||
mut widget,
|
||
pending_approval,
|
||
pending_sandbox,
|
||
..
|
||
} = taken
|
||
{
|
||
let approval = pending_approval;
|
||
let sandbox = pending_sandbox;
|
||
widget.update_execution_mode_and_reconfigure(approval, sandbox);
|
||
self.app_state = AppState::Chat { widget };
|
||
self.app_event_tx.send(AppEvent::RequestRedraw);
|
||
}
|
||
}
|
||
DangerWarningOutcome::Cancel => {
|
||
let taken = std::mem::replace(
|
||
&mut self.app_state,
|
||
AppState::GitWarning {
|
||
screen: GitWarningScreen::new(),
|
||
},
|
||
);
|
||
let _ = ct_execute!(std::io::stdout(), LeaveAlternateScreen);
|
||
// After leaving the alternate screen, resync our viewport/cursor
|
||
// so the chat composer stays anchored at the bottom.
|
||
self.fixup_viewport_after_danger = true;
|
||
if let AppState::DangerWarning { widget, .. } = taken {
|
||
self.app_state = AppState::Chat { widget };
|
||
self.app_event_tx.send(AppEvent::RequestRedraw);
|
||
}
|
||
}
|
||
DangerWarningOutcome::None => {}
|
||
},
|
||
}
|
||
}
|
||
|
||
fn dispatch_paste_event(&mut self, pasted: String) {
|
||
match &mut self.app_state {
|
||
AppState::Chat { widget } => widget.handle_paste(pasted),
|
||
AppState::GitWarning { .. } => {}
|
||
AppState::DangerWarning { .. } => {}
|
||
}
|
||
}
|
||
|
||
fn dispatch_codex_event(&mut self, event: Event) {
|
||
match &mut self.app_state {
|
||
AppState::Chat { widget } => widget.handle_codex_event(event),
|
||
AppState::GitWarning { .. } => {}
|
||
AppState::DangerWarning { widget, .. } => widget.handle_codex_event(event),
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::strip_surrounding_quotes;
|
||
|
||
#[test]
|
||
fn strip_surrounding_quotes_cases() {
|
||
let cases = vec![
|
||
("o3", "o3"),
|
||
(" \"codex-mini-latest\" ", "codex-mini-latest"),
|
||
("another_model", "another_model"),
|
||
("‘quoted’", "quoted"),
|
||
("“smart”", "smart"),
|
||
];
|
||
for (input, expected) in cases {
|
||
assert_eq!(strip_surrounding_quotes(input), expected.to_string());
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn model_command_args_extraction_and_normalization() {
|
||
let cases = vec![
|
||
("/model", "", ""),
|
||
("/model o3", "o3", "o3"),
|
||
("/model another_model", "another_model", "another_model"),
|
||
];
|
||
for (line, raw_expected, norm_expected) in cases {
|
||
// Extract raw args as in chat_composer
|
||
let raw = if let Some(stripped) = line.strip_prefix('/') {
|
||
let token = stripped.trim_start();
|
||
let cmd_token = token.split_whitespace().next().unwrap_or("");
|
||
let rest = &token[cmd_token.len()..];
|
||
rest.trim_start().to_string()
|
||
} else {
|
||
String::new()
|
||
};
|
||
assert_eq!(raw, raw_expected, "raw args for '{line}'");
|
||
// Normalize as in app dispatch logic
|
||
let normalized = strip_surrounding_quotes(&raw).trim().to_string();
|
||
assert_eq!(normalized, norm_expected, "normalized args for '{line}'");
|
||
}
|
||
}
|
||
}
|