use crate::app_backtrack::BacktrackState; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::chatwidget::ChatWidget; use crate::file_search::FileSearchManager; use crate::pager_overlay::Overlay; use crate::resume_picker::ResumeSelection; use crate::tui; use crate::tui::TuiEvent; use codex_ansi_escape::ansi_escape_line; use codex_core::AuthManager; use codex_core::ConversationManager; use codex_core::config::Config; use codex_core::protocol::TokenUsage; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; use crossterm::terminal::supports_keyboard_enhancement; use ratatui::style::Stylize; use ratatui::text::Line; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; use std::thread; use std::time::Duration; use tokio::select; use tokio::sync::mpsc::unbounded_channel; // use uuid::Uuid; pub(crate) struct App { pub(crate) server: Arc, pub(crate) app_event_tx: AppEventSender, pub(crate) chat_widget: ChatWidget, /// Config is stored here so we can recreate ChatWidgets as needed. pub(crate) config: Config, pub(crate) file_search: FileSearchManager, pub(crate) transcript_lines: Vec>, // Pager overlay state (Transcript or Static like Diff) pub(crate) overlay: Option, pub(crate) deferred_history_lines: Vec>, has_emitted_history_lines: bool, pub(crate) enhanced_keys_supported: bool, /// Controls the animation thread that sends CommitTick events. pub(crate) commit_anim_running: Arc, // Esc-backtracking state grouped pub(crate) backtrack: crate::app_backtrack::BacktrackState, } impl App { pub async fn run( tui: &mut tui::Tui, auth_manager: Arc, config: Config, initial_prompt: Option, initial_images: Vec, resume_selection: ResumeSelection, ) -> Result { use tokio_stream::StreamExt; let (app_event_tx, mut app_event_rx) = unbounded_channel(); let app_event_tx = AppEventSender::new(app_event_tx); let conversation_manager = Arc::new(ConversationManager::new(auth_manager.clone())); let enhanced_keys_supported = supports_keyboard_enhancement().unwrap_or(false); let chat_widget = match resume_selection { ResumeSelection::StartFresh | ResumeSelection::Exit => { let init = crate::chatwidget::ChatWidgetInit { config: config.clone(), frame_requester: tui.frame_requester(), app_event_tx: app_event_tx.clone(), initial_prompt: initial_prompt.clone(), initial_images: initial_images.clone(), enhanced_keys_supported, }; ChatWidget::new(init, conversation_manager.clone()) } ResumeSelection::Resume(path) => { let resumed = conversation_manager .resume_conversation_from_rollout( config.clone(), path.clone(), auth_manager.clone(), ) .await .wrap_err_with(|| { format!("Failed to resume session from {}", path.display()) })?; let init = crate::chatwidget::ChatWidgetInit { config: config.clone(), frame_requester: tui.frame_requester(), app_event_tx: app_event_tx.clone(), initial_prompt: initial_prompt.clone(), initial_images: initial_images.clone(), enhanced_keys_supported, }; ChatWidget::new_from_existing( init, resumed.conversation, resumed.session_configured, ) } }; let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); let mut app = Self { server: conversation_manager, app_event_tx, chat_widget, config, file_search, enhanced_keys_supported, transcript_lines: Vec::new(), overlay: None, deferred_history_lines: Vec::new(), has_emitted_history_lines: false, commit_anim_running: Arc::new(AtomicBool::new(false)), backtrack: BacktrackState::default(), }; let tui_events = tui.event_stream(); tokio::pin!(tui_events); tui.frame_requester().schedule_frame(); while select! { Some(event) = app_event_rx.recv() => { app.handle_event(tui, event).await? } Some(event) = tui_events.next() => { app.handle_tui_event(tui, event).await? } } {} tui.terminal.clear()?; Ok(app.token_usage()) } pub(crate) async fn handle_tui_event( &mut self, tui: &mut tui::Tui, event: TuiEvent, ) -> Result { if self.overlay.is_some() { let _ = self.handle_backtrack_overlay_event(tui, event).await?; } else { match event { TuiEvent::Key(key_event) => { self.handle_key_event(tui, key_event).await; } TuiEvent::Paste(pasted) => { // Many terminals convert newlines to \r when pasting (e.g., iTerm2), // but tui-textarea expects \n. Normalize CR to LF. // [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"); self.chat_widget.handle_paste(pasted); } TuiEvent::Draw => { if self .chat_widget .handle_paste_burst_tick(tui.frame_requester()) { return Ok(true); } tui.draw( self.chat_widget.desired_height(tui.terminal.size()?.width), |frame| { frame.render_widget_ref(&self.chat_widget, frame.area()); if let Some((x, y)) = self.chat_widget.cursor_pos(frame.area()) { frame.set_cursor_position((x, y)); } }, )?; } TuiEvent::AttachImage { path, width, height, format_label, } => { self.chat_widget .attach_image(path, width, height, format_label); } } } Ok(true) } async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result { match event { AppEvent::NewSession => { let init = crate::chatwidget::ChatWidgetInit { config: self.config.clone(), frame_requester: tui.frame_requester(), app_event_tx: self.app_event_tx.clone(), initial_prompt: None, initial_images: Vec::new(), enhanced_keys_supported: self.enhanced_keys_supported, }; self.chat_widget = ChatWidget::new(init, self.server.clone()); tui.frame_requester().schedule_frame(); } AppEvent::InsertHistoryCell(cell) => { let mut cell_transcript = cell.transcript_lines(); if !cell.is_stream_continuation() && !self.transcript_lines.is_empty() { cell_transcript.insert(0, Line::from("")); } if let Some(Overlay::Transcript(t)) = &mut self.overlay { t.insert_lines(cell_transcript.clone()); tui.frame_requester().schedule_frame(); } self.transcript_lines.extend(cell_transcript.clone()); let mut display = cell.display_lines(tui.terminal.last_known_screen_size.width); if !display.is_empty() { // Only insert a separating blank line for new cells that are not // part of an ongoing stream. Streaming continuations should not // accrue extra blank lines between chunks. if !cell.is_stream_continuation() { if self.has_emitted_history_lines { display.insert(0, Line::from("")); } else { self.has_emitted_history_lines = true; } } if self.overlay.is_some() { self.deferred_history_lines.extend(display); } else { tui.insert_history_lines(display); } } } AppEvent::StartCommitAnimation => { if self .commit_anim_running .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) .is_ok() { let tx = self.app_event_tx.clone(); let running = self.commit_anim_running.clone(); thread::spawn(move || { while running.load(Ordering::Relaxed) { thread::sleep(Duration::from_millis(50)); tx.send(AppEvent::CommitTick); } }); } } AppEvent::StopCommitAnimation => { self.commit_anim_running.store(false, Ordering::Release); } AppEvent::CommitTick => { self.chat_widget.on_commit_tick(); } AppEvent::CodexEvent(event) => { self.chat_widget.handle_codex_event(event); } AppEvent::ConversationHistory(ev) => { self.on_conversation_history_for_backtrack(tui, ev).await?; } AppEvent::ExitRequest => { return Ok(false); } AppEvent::CodexOp(op) => self.chat_widget.submit_op(op), AppEvent::DiffResult(text) => { // Clear the in-progress state in the bottom pane self.chat_widget.on_diff_complete(); // Enter alternate screen using TUI helper and build pager lines let _ = tui.enter_alt_screen(); let pager_lines: Vec> = if text.trim().is_empty() { vec!["No changes detected.".italic().into()] } else { text.lines().map(ansi_escape_line).collect() }; self.overlay = Some(Overlay::new_static_with_title( pager_lines, "D I F F".to_string(), )); tui.frame_requester().schedule_frame(); } AppEvent::StartFileSearch(query) => { if !query.is_empty() { self.file_search.on_user_query(query); } } AppEvent::FileSearchResult { query, matches } => { self.chat_widget.apply_file_search_result(query, matches); } AppEvent::UpdateReasoningEffort(effort) => { self.chat_widget.set_reasoning_effort(effort); } AppEvent::UpdateModel(model) => { self.chat_widget.set_model(model); } AppEvent::UpdateAskForApprovalPolicy(policy) => { self.chat_widget.set_approval_policy(policy); } AppEvent::UpdateSandboxPolicy(policy) => { self.chat_widget.set_sandbox_policy(policy); } } Ok(true) } pub(crate) fn token_usage(&self) -> codex_core::protocol::TokenUsage { self.chat_widget.token_usage().clone() } async fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) { match key_event { KeyEvent { code: KeyCode::Char('t'), modifiers: crossterm::event::KeyModifiers::CONTROL, kind: KeyEventKind::Press, .. } => { // Enter alternate screen and set viewport to full size. let _ = tui.enter_alt_screen(); self.overlay = Some(Overlay::new_transcript(self.transcript_lines.clone())); tui.frame_requester().schedule_frame(); } // Esc primes/advances backtracking only in normal (not working) mode // with an empty composer. In any other state, forward Esc so the // active UI (e.g. status indicator, modals, popups) handles it. KeyEvent { code: KeyCode::Esc, kind: KeyEventKind::Press | KeyEventKind::Repeat, .. } => { if self.chat_widget.is_normal_backtrack_mode() && self.chat_widget.composer_is_empty() { self.handle_backtrack_esc_key(tui); } else { self.chat_widget.handle_key_event(key_event); } } // Enter confirms backtrack when primed + count > 0. Otherwise pass to widget. KeyEvent { code: KeyCode::Enter, kind: KeyEventKind::Press, .. } if self.backtrack.primed && self.backtrack.count > 0 && self.chat_widget.composer_is_empty() => { // Delegate to helper for clarity; preserves behavior. self.confirm_backtrack_from_main(); } KeyEvent { kind: KeyEventKind::Press | KeyEventKind::Repeat, .. } => { // Any non-Esc key press should cancel a primed backtrack. // This avoids stale "Esc-primed" state after the user starts typing // (even if they later backspace to empty). if key_event.code != KeyCode::Esc && self.backtrack.primed { self.reset_backtrack_state(); } self.chat_widget.handle_key_event(key_event); } _ => { // Ignore Release key events. } }; } }