use crate::app_backtrack::BacktrackState; use crate::app_event::AppEvent; use crate::app_event::ExitMode; #[cfg(target_os = "windows")] use crate::app_event::WindowsSandboxEnableMode; #[cfg(target_os = "windows")] use crate::app_event::WindowsSandboxFallbackReason; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::ApprovalRequest; use crate::chatwidget::ChatWidget; use crate::custom_terminal::Frame; use crate::diff_render::DiffSummary; use crate::exec_command::strip_bash_lc_and_escape; use crate::file_search::FileSearchManager; use crate::history_cell::HistoryCell; use crate::history_cell::UserHistoryCell; use crate::model_migration::ModelMigrationOutcome; use crate::model_migration::migration_copy_for_models; use crate::model_migration::run_model_migration_prompt; use crate::pager_overlay::Overlay; use crate::render::highlight::highlight_bash_to_lines; use crate::render::renderable::Renderable; use crate::resume_picker::SessionSelection; use crate::transcript_copy_action::TranscriptCopyAction; use crate::transcript_copy_action::TranscriptCopyFeedback; use crate::transcript_copy_ui::TranscriptCopyUi; use crate::transcript_multi_click::TranscriptMultiClick; use crate::transcript_scrollbar::render_transcript_scrollbar_if_active; use crate::transcript_scrollbar::split_transcript_area; use crate::transcript_scrollbar_ui::TranscriptScrollbarMouseEvent; use crate::transcript_scrollbar_ui::TranscriptScrollbarMouseHandling; use crate::transcript_scrollbar_ui::TranscriptScrollbarUi; use crate::transcript_selection::TRANSCRIPT_GUTTER_COLS; use crate::transcript_selection::TranscriptSelection; use crate::transcript_selection::TranscriptSelectionPoint; use crate::transcript_view_cache::TranscriptViewCache; use crate::tui; use crate::tui::TuiEvent; use crate::tui::scrolling::MouseScrollState; use crate::tui::scrolling::ScrollConfig; use crate::tui::scrolling::ScrollConfigOverrides; use crate::tui::scrolling::ScrollDirection; use crate::tui::scrolling::ScrollUpdate; use crate::tui::scrolling::TranscriptScroll; use crate::update_action::UpdateAction; use codex_ansi_escape::ansi_escape_line; use codex_core::AuthManager; use codex_core::ThreadManager; use codex_core::config::Config; use codex_core::config::edit::ConfigEditsBuilder; #[cfg(target_os = "windows")] use codex_core::features::Feature; use codex_core::models_manager::manager::RefreshStrategy; use codex_core::models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG; use codex_core::models_manager::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG; use codex_core::protocol::DeprecationNoticeEvent; use codex_core::protocol::EventMsg; use codex_core::protocol::FinalOutput; use codex_core::protocol::ListSkillsResponseEvent; use codex_core::protocol::Op; use codex_core::protocol::SessionSource; use codex_core::protocol::SkillErrorInfo; use codex_core::protocol::TokenUsage; use codex_core::terminal::terminal_info; use codex_protocol::ThreadId; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ModelUpgrade; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; use crossterm::event::MouseButton; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::widgets::Clear; use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; use std::collections::BTreeMap; use std::path::Path; 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; #[cfg(not(debug_assertions))] use crate::history_cell::UpdateAvailableHistoryCell; #[derive(Debug, Clone)] pub struct AppExitInfo { pub token_usage: TokenUsage, pub conversation_id: Option, pub update_action: Option, pub exit_reason: ExitReason, /// ANSI-styled transcript lines to print after the TUI exits. /// /// These lines are rendered against the same width as the final TUI /// viewport and include styling (colors, bold, etc.) so that scrollback /// preserves the visual structure of the on-screen transcript. pub session_lines: Vec, } #[derive(Debug)] pub(crate) enum AppRunControl { Continue, Exit(ExitReason), } #[derive(Debug, Clone)] pub enum ExitReason { UserRequested, Fatal(String), } impl From for codex_tui::AppExitInfo { fn from(info: AppExitInfo) -> Self { let exit_reason = match info.exit_reason { ExitReason::UserRequested => codex_tui::ExitReason::UserRequested, ExitReason::Fatal(message) => codex_tui::ExitReason::Fatal(message), }; codex_tui::AppExitInfo { token_usage: info.token_usage, thread_id: info.conversation_id, update_action: info.update_action.map(Into::into), exit_reason, } } } fn session_summary( token_usage: TokenUsage, conversation_id: Option, ) -> Option { if token_usage.is_zero() { return None; } let usage_line = FinalOutput::from(token_usage).to_string(); let resume_command = conversation_id.map(|conversation_id| format!("codex resume {conversation_id}")); Some(SessionSummary { usage_line, resume_command, }) } fn errors_for_cwd(cwd: &Path, response: &ListSkillsResponseEvent) -> Vec { response .skills .iter() .find(|entry| entry.cwd.as_path() == cwd) .map(|entry| entry.errors.clone()) .unwrap_or_default() } fn emit_skill_load_warnings(app_event_tx: &AppEventSender, errors: &[SkillErrorInfo]) { if errors.is_empty() { return; } let error_count = errors.len(); app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( crate::history_cell::new_warning_event(format!( "Skipped loading {error_count} skill(s) due to invalid SKILL.md files." )), ))); for error in errors { let path = error.path.display(); let message = error.message.as_str(); app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( crate::history_cell::new_warning_event(format!("{path}: {message}")), ))); } } fn emit_deprecation_notice(app_event_tx: &AppEventSender, notice: Option) { let Some(DeprecationNoticeEvent { summary, details }) = notice else { return; }; app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( crate::history_cell::new_deprecation_notice(summary, details), ))); } #[derive(Debug, Clone, PartialEq, Eq)] struct SessionSummary { usage_line: String, resume_command: Option, } fn should_show_model_migration_prompt( current_model: &str, target_model: &str, seen_migrations: &BTreeMap, available_models: &[ModelPreset], ) -> bool { if target_model == current_model { return false; } if let Some(seen_target) = seen_migrations.get(current_model) && seen_target == target_model { return false; } if available_models .iter() .any(|preset| preset.model == current_model && preset.upgrade.is_some()) { return true; } if available_models .iter() .any(|preset| preset.upgrade.as_ref().map(|u| u.id.as_str()) == Some(target_model)) { return true; } false } fn migration_prompt_hidden(config: &Config, migration_config_key: &str) -> bool { match migration_config_key { HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG => config .notices .hide_gpt_5_1_codex_max_migration_prompt .unwrap_or(false), HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG => { config.notices.hide_gpt5_1_migration_prompt.unwrap_or(false) } _ => false, } } async fn handle_model_migration_prompt_if_needed( tui: &mut tui::Tui, config: &mut Config, model: &str, app_event_tx: &AppEventSender, available_models: Vec, ) -> Option { let upgrade = available_models .iter() .find(|preset| preset.model == model) .and_then(|preset| preset.upgrade.as_ref()); if let Some(ModelUpgrade { id: target_model, reasoning_effort_mapping, migration_config_key, migration_markdown, .. }) = upgrade { if migration_prompt_hidden(config, migration_config_key.as_str()) { return None; } let target_model = target_model.to_string(); if !should_show_model_migration_prompt( model, &target_model, &config.notices.model_migrations, &available_models, ) { return None; } let current_preset = available_models.iter().find(|preset| preset.model == model); let target_preset = available_models .iter() .find(|preset| preset.model == target_model); let target_display_name = target_preset .map(|preset| preset.display_name.clone()) .unwrap_or_else(|| target_model.clone()); let heading_label = if target_display_name == model { target_model.clone() } else { target_display_name.clone() }; let target_description = target_preset.and_then(|preset| { if preset.description.is_empty() { None } else { Some(preset.description.clone()) } }); let can_opt_out = current_preset.is_some(); let prompt_copy = migration_copy_for_models( model, &target_model, heading_label, target_description, migration_markdown.clone(), can_opt_out, ); match run_model_migration_prompt(tui, prompt_copy).await { ModelMigrationOutcome::Accepted => { app_event_tx.send(AppEvent::PersistModelMigrationPromptAcknowledged { from_model: model.to_string(), to_model: target_model.clone(), }); config.model = Some(target_model.clone()); let mapped_effort = if let Some(reasoning_effort_mapping) = reasoning_effort_mapping && let Some(reasoning_effort) = config.model_reasoning_effort { reasoning_effort_mapping .get(&reasoning_effort) .cloned() .or(config.model_reasoning_effort) } else { config.model_reasoning_effort }; config.model_reasoning_effort = mapped_effort; app_event_tx.send(AppEvent::UpdateModel(target_model.clone())); app_event_tx.send(AppEvent::UpdateReasoningEffort(mapped_effort)); app_event_tx.send(AppEvent::PersistModelSelection { model: target_model.clone(), effort: mapped_effort, }); } ModelMigrationOutcome::Rejected => { app_event_tx.send(AppEvent::PersistModelMigrationPromptAcknowledged { from_model: model.to_string(), to_model: target_model.clone(), }); } ModelMigrationOutcome::Exit => { return Some(AppExitInfo { token_usage: TokenUsage::default(), conversation_id: None, update_action: None, exit_reason: ExitReason::UserRequested, session_lines: Vec::new(), }); } } } None } pub(crate) struct App { pub(crate) server: Arc, pub(crate) app_event_tx: AppEventSender, pub(crate) chat_widget: ChatWidget, pub(crate) auth_manager: Arc, /// Config is stored here so we can recreate ChatWidgets as needed. pub(crate) config: Config, pub(crate) current_model: String, pub(crate) active_profile: Option, pub(crate) file_search: FileSearchManager, pub(crate) transcript_cells: Vec>, transcript_view_cache: TranscriptViewCache, #[allow(dead_code)] transcript_scroll: TranscriptScroll, transcript_selection: TranscriptSelection, transcript_multi_click: TranscriptMultiClick, transcript_view_top: usize, transcript_total_lines: usize, transcript_copy_ui: TranscriptCopyUi, transcript_copy_action: TranscriptCopyAction, transcript_scrollbar_ui: TranscriptScrollbarUi, // Pager overlay state (Transcript or Static like Diff). pub(crate) overlay: Option, /// History cells received while an overlay is active. /// /// While in an alt-screen overlay, the normal terminal buffer is not visible. /// Instead we queue the incoming cells here and, on overlay close, render them at the *current* /// width and queue them in one batch via `Tui::insert_history_lines`. /// /// This matters for correctness if/when scrollback printing is enabled: if we deferred /// already-rendered `Vec`, we'd bake viewport-width wrapping based on the width at the /// time the cell arrived (which may differ from the width when the overlay closes). pub(crate) deferred_history_cells: Vec>, /// True once at least one history cell has been inserted into terminal scrollback. /// /// Used to decide whether to insert an extra blank separator line when flushing deferred cells. pub(crate) 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, scroll_config: ScrollConfig, scroll_state: MouseScrollState, // Esc-backtracking state grouped pub(crate) backtrack: crate::app_backtrack::BacktrackState, /// When set, the next draw re-renders the transcript into terminal scrollback once. /// /// This is used after a confirmed conversation rollback to ensure scrollback reflects the /// trimmed transcript cells. pub(crate) backtrack_render_pending: bool, pub(crate) feedback: codex_feedback::CodexFeedback, /// Set when the user confirms an update; propagated on exit. pub(crate) pending_update_action: Option, /// Ignore the next ShutdownComplete event when we're intentionally /// stopping a conversation (e.g., before starting a new one). suppress_shutdown_complete: bool, // One-shot suppression of the next world-writable scan after user confirmation. skip_world_writable_scan_once: bool, } impl App { async fn shutdown_current_conversation(&mut self) { if let Some(conversation_id) = self.chat_widget.conversation_id() { // Clear any in-flight rollback guard when switching conversations. self.backtrack.pending_rollback = None; self.suppress_shutdown_complete = true; self.chat_widget.submit_op(Op::Shutdown); self.server.remove_thread(&conversation_id).await; } } #[allow(clippy::too_many_arguments)] pub async fn run( tui: &mut tui::Tui, auth_manager: Arc, mut config: Config, active_profile: Option, initial_prompt: Option, initial_images: Vec, session_selection: SessionSelection, feedback: codex_feedback::CodexFeedback, is_first_run: bool, ollama_chat_support_notice: Option, ) -> Result { use tokio_stream::StreamExt; let (app_event_tx, mut app_event_rx) = unbounded_channel(); let app_event_tx = AppEventSender::new(app_event_tx); emit_deprecation_notice(&app_event_tx, ollama_chat_support_notice); let thread_manager = Arc::new(ThreadManager::new( config.codex_home.clone(), auth_manager.clone(), SessionSource::Cli, )); let mut model = thread_manager .get_models_manager() .get_default_model(&config.model, &config, RefreshStrategy::Offline) .await; let available_models = thread_manager .get_models_manager() .list_models(&config, RefreshStrategy::Offline) .await; let exit_info = handle_model_migration_prompt_if_needed( tui, &mut config, model.as_str(), &app_event_tx, available_models, ) .await; if let Some(exit_info) = exit_info { return Ok(exit_info); } if let Some(updated_model) = config.model.clone() { model = updated_model; } let enhanced_keys_supported = tui.enhanced_keys_supported(); let mut chat_widget = match session_selection { SessionSelection::StartFresh | SessionSelection::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, auth_manager: auth_manager.clone(), models_manager: thread_manager.get_models_manager(), feedback: feedback.clone(), is_first_run, model: config.model.clone(), }; ChatWidget::new(init, thread_manager.clone()) } SessionSelection::Resume(path) => { let resumed = thread_manager .resume_thread_from_rollout(config.clone(), path.clone(), auth_manager.clone()) .await .wrap_err_with(|| { let path_display = path.display(); 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, auth_manager: auth_manager.clone(), models_manager: thread_manager.get_models_manager(), feedback: feedback.clone(), is_first_run, model: config.model.clone(), }; ChatWidget::new_from_existing(init, resumed.thread, resumed.session_configured) } SessionSelection::Fork(path) => { let forked = thread_manager .fork_thread(usize::MAX, config.clone(), path.clone()) .await .wrap_err_with(|| { let path_display = path.display(); format!("Failed to fork 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, auth_manager: auth_manager.clone(), models_manager: thread_manager.get_models_manager(), feedback: feedback.clone(), is_first_run, model: config.model.clone(), }; ChatWidget::new_from_existing(init, forked.thread, forked.session_configured) } }; chat_widget.maybe_prompt_windows_sandbox_enable(); let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); #[cfg(not(debug_assertions))] let upgrade_version = crate::updates::get_upgrade_version(&config); let scroll_config = ScrollConfig::from_terminal( &terminal_info(), ScrollConfigOverrides { events_per_tick: config.tui_scroll_events_per_tick, wheel_lines_per_tick: config.tui_scroll_wheel_lines, trackpad_lines_per_tick: config.tui_scroll_trackpad_lines, trackpad_accel_events: config.tui_scroll_trackpad_accel_events, trackpad_accel_max: config.tui_scroll_trackpad_accel_max, mode: Some(config.tui_scroll_mode), wheel_tick_detect_max_ms: config.tui_scroll_wheel_tick_detect_max_ms, wheel_like_max_duration_ms: config.tui_scroll_wheel_like_max_duration_ms, invert_direction: config.tui_scroll_invert, }, ); let copy_selection_shortcut = crate::transcript_copy_ui::detect_copy_selection_shortcut(); let mut app = Self { server: thread_manager.clone(), app_event_tx, chat_widget, auth_manager: auth_manager.clone(), config, current_model: model.clone(), active_profile, file_search, enhanced_keys_supported, transcript_cells: Vec::new(), transcript_view_cache: TranscriptViewCache::new(), transcript_scroll: TranscriptScroll::default(), transcript_selection: TranscriptSelection::default(), transcript_multi_click: TranscriptMultiClick::default(), transcript_view_top: 0, transcript_total_lines: 0, transcript_copy_ui: TranscriptCopyUi::new_with_shortcut(copy_selection_shortcut), transcript_copy_action: TranscriptCopyAction::default(), transcript_scrollbar_ui: TranscriptScrollbarUi::default(), overlay: None, deferred_history_cells: Vec::new(), has_emitted_history_lines: false, commit_anim_running: Arc::new(AtomicBool::new(false)), scroll_config, scroll_state: MouseScrollState::default(), backtrack: BacktrackState::default(), backtrack_render_pending: false, feedback: feedback.clone(), pending_update_action: None, suppress_shutdown_complete: false, skip_world_writable_scan_once: false, }; // On startup, if Agent mode (workspace-write) or ReadOnly is active, warn about world-writable dirs on Windows. #[cfg(target_os = "windows")] { let should_check = codex_core::get_platform_sandbox().is_some() && matches!( app.config.sandbox_policy.get(), codex_core::protocol::SandboxPolicy::WorkspaceWrite { .. } | codex_core::protocol::SandboxPolicy::ReadOnly ) && !app .config .notices .hide_world_writable_warning .unwrap_or(false); if should_check { let cwd = app.config.cwd.clone(); let env_map: std::collections::HashMap = std::env::vars().collect(); let tx = app.app_event_tx.clone(); let logs_base_dir = app.config.codex_home.clone(); let sandbox_policy = app.config.sandbox_policy.get().clone(); Self::spawn_world_writable_scan(cwd, env_map, logs_base_dir, sandbox_policy, tx); } } #[cfg(not(debug_assertions))] if let Some(latest_version) = upgrade_version { let control = app .handle_event( tui, AppEvent::InsertHistoryCell(Box::new(UpdateAvailableHistoryCell::new( latest_version, crate::update_action::get_update_action(), ))), ) .await?; if let AppRunControl::Exit(exit_reason) = control { return Ok(AppExitInfo { token_usage: app.token_usage(), conversation_id: app.chat_widget.conversation_id(), update_action: app.pending_update_action, exit_reason, session_lines: Vec::new(), }); } } let tui_events = tui.event_stream(); tokio::pin!(tui_events); tui.frame_requester().schedule_frame(); let exit_reason = loop { let control = 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? } }; match control { AppRunControl::Continue => {} AppRunControl::Exit(reason) => break reason, } }; let width = tui.terminal.last_known_screen_size.width; let session_lines = if width == 0 { Vec::new() } else { let transcript = crate::transcript_render::build_transcript_lines(&app.transcript_cells, width); let (lines, line_meta) = (transcript.lines, transcript.meta); let is_user_cell: Vec = app .transcript_cells .iter() .map(|cell| cell.as_any().is::()) .collect(); crate::transcript_render::render_lines_to_ansi(&lines, &line_meta, &is_user_cell, width) }; tui.terminal.clear()?; Ok(AppExitInfo { token_usage: app.token_usage(), conversation_id: app.chat_widget.conversation_id(), update_action: app.pending_update_action, exit_reason, session_lines, }) } pub(crate) async fn handle_tui_event( &mut self, tui: &mut tui::Tui, event: TuiEvent, ) -> Result { if matches!(&event, TuiEvent::Draw) { self.handle_scroll_tick(tui); } 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::Mouse(mouse_event) => { self.handle_mouse_event(tui, mouse_event); } 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.backtrack_render_pending { self.backtrack_render_pending = false; self.render_transcript_once(tui); } self.chat_widget.maybe_post_pending_notification(tui); if self .chat_widget .handle_paste_burst_tick(tui.frame_requester()) { return Ok(AppRunControl::Continue); } let cells = self.transcript_cells.clone(); tui.draw(tui.terminal.size()?.height, |frame| { let chat_height = self.chat_widget.desired_height(frame.area().width); let chat_top = self.render_transcript_cells(frame, &cells, chat_height); let chat_area = Rect { x: frame.area().x, y: chat_top, width: frame.area().width, height: chat_height.min( frame .area() .height .saturating_sub(chat_top.saturating_sub(frame.area().y)), ), }; self.chat_widget.render(chat_area, frame.buffer); let chat_bottom = chat_area.y.saturating_add(chat_area.height); if chat_bottom < frame.area().bottom() { Clear.render_ref( Rect { x: frame.area().x, y: chat_bottom, width: frame.area().width, height: frame.area().bottom().saturating_sub(chat_bottom), }, frame.buffer, ); } if let Some((x, y)) = self.chat_widget.cursor_pos(chat_area) { frame.set_cursor_position((x, y)); } })?; let transcript_scrolled = !matches!(self.transcript_scroll, TranscriptScroll::ToBottom); let selection_active = matches!( (self.transcript_selection.anchor, self.transcript_selection.head), (Some(a), Some(b)) if a != b ); let scroll_position = if self.transcript_total_lines == 0 { None } else { Some(( self.transcript_view_top.saturating_add(1), self.transcript_total_lines, )) }; let copy_selection_key = self.copy_selection_key(); let copy_feedback = self.transcript_copy_feedback_for_footer(); self.chat_widget.set_transcript_ui_state( transcript_scrolled, selection_active, scroll_position, copy_selection_key, copy_feedback, ); } } } Ok(AppRunControl::Continue) } pub(crate) fn render_transcript_cells( &mut self, frame: &mut Frame, cells: &[Arc], chat_height: u16, ) -> u16 { let area = frame.area(); if area.width == 0 || area.height == 0 { self.transcript_scroll = TranscriptScroll::default(); self.transcript_view_top = 0; self.transcript_total_lines = 0; return area.bottom().saturating_sub(chat_height); } let chat_height = chat_height.min(area.height); let max_transcript_height = area.height.saturating_sub(chat_height); if max_transcript_height == 0 { self.transcript_scroll = TranscriptScroll::default(); self.transcript_view_top = 0; self.transcript_total_lines = 0; return area.y; } let transcript_full_area = Rect { x: area.x, y: area.y, width: area.width, height: max_transcript_height, }; let (transcript_area, _) = split_transcript_area(transcript_full_area); self.transcript_view_cache .ensure_wrapped(cells, transcript_area.width); let total_lines = self.transcript_view_cache.lines().len(); if total_lines == 0 { Clear.render_ref(transcript_full_area, frame.buffer); self.transcript_scroll = TranscriptScroll::default(); self.transcript_view_top = 0; self.transcript_total_lines = 0; return area.y; } self.transcript_total_lines = total_lines; let max_visible = std::cmp::min(max_transcript_height as usize, total_lines); let max_start = total_lines.saturating_sub(max_visible); let (scroll_state, top_offset) = { let line_meta = self.transcript_view_cache.line_meta(); self.transcript_scroll.resolve_top(line_meta, max_start) }; self.transcript_scroll = scroll_state; self.transcript_view_top = top_offset; let transcript_visible_height = max_visible as u16; let chat_top = if total_lines <= max_transcript_height as usize { let gap = if transcript_visible_height == 0 { 0 } else { 1 }; area.y .saturating_add(transcript_visible_height) .saturating_add(gap) } else { area.bottom().saturating_sub(chat_height) }; let clear_height = chat_top.saturating_sub(area.y); if clear_height > 0 { Clear.render_ref( Rect { x: area.x, y: area.y, width: area.width, height: clear_height, }, frame.buffer, ); } let transcript_full_area = Rect { x: area.x, y: area.y, width: area.width, height: transcript_visible_height, }; let (transcript_area, transcript_scrollbar_area) = split_transcript_area(transcript_full_area); // Cache a few viewports worth of rasterized rows so redraws during streaming can cheaply // copy already-rendered `Cell`s instead of re-running grapheme segmentation. self.transcript_view_cache .set_raster_capacity(max_visible.saturating_mul(4).max(256)); for (row_index, line_index) in (top_offset..total_lines).enumerate() { if row_index >= max_visible { break; } let y = transcript_area.y + row_index as u16; let row_area = Rect { x: transcript_area.x, y, width: transcript_area.width, height: 1, }; self.transcript_view_cache .render_row_index_into(line_index, row_area, frame.buffer); } self.apply_transcript_selection(transcript_area, frame.buffer); if let (Some(anchor), Some(head)) = ( self.transcript_selection.anchor, self.transcript_selection.head, ) && anchor != head { self.transcript_copy_ui.render_copy_pill( transcript_area, frame.buffer, (anchor.line_index, anchor.column), (head.line_index, head.column), self.transcript_view_top, self.transcript_total_lines, ); } else { self.transcript_copy_ui.clear_affordance(); } render_transcript_scrollbar_if_active( frame.buffer, transcript_scrollbar_area, total_lines, max_visible, top_offset, ); chat_top } /// Handle mouse interaction in the main transcript view. /// /// - Mouse wheel movement scrolls the conversation history using stream-based /// normalization (events-per-line factor, discrete vs. continuous streams), /// independent of the terminal's own scrollback. /// - Mouse drags adjust a text selection defined in terms of /// flattened transcript lines and columns, so the selection is anchored /// to the underlying content rather than absolute screen rows. /// - When the user drags to extend a selection while the view is following the bottom /// and a task is actively running (e.g., streaming a response), the scroll mode is /// first converted into an anchored position so that ongoing updates no longer move /// the viewport under the selection. A simple click without a drag does not change /// scroll behavior. /// - Mouse events outside the transcript area (e.g. over the composer/footer) must not /// start or mutate transcript selection state. A left-click outside the transcript /// clears any existing transcript selection so the user can dismiss the highlight. fn handle_mouse_event( &mut self, tui: &mut tui::Tui, mouse_event: crossterm::event::MouseEvent, ) { use crossterm::event::MouseEventKind; if self.overlay.is_some() { return; } let size = tui.terminal.last_known_screen_size; let width = size.width; let height = size.height; if width == 0 || height == 0 { return; } let chat_height = self.chat_widget.desired_height(width); if chat_height >= height { return; } // Only handle events over the transcript area above the composer. let transcript_height = height.saturating_sub(chat_height); if transcript_height == 0 { return; } let transcript_full_area = Rect { x: 0, y: 0, width, height: transcript_height, }; let (transcript_area, transcript_scrollbar_area) = split_transcript_area(transcript_full_area); let base_x = transcript_area.x.saturating_add(TRANSCRIPT_GUTTER_COLS); let max_x = transcript_area.right().saturating_sub(1); if matches!( self.transcript_scrollbar_ui .handle_mouse_event(TranscriptScrollbarMouseEvent { tui, mouse_event, transcript_area, scrollbar_area: transcript_scrollbar_area, transcript_cells: &self.transcript_cells, transcript_view_cache: &mut self.transcript_view_cache, transcript_scroll: &mut self.transcript_scroll, transcript_view_top: &mut self.transcript_view_top, transcript_total_lines: &mut self.transcript_total_lines, mouse_scroll_state: &mut self.scroll_state, }), TranscriptScrollbarMouseHandling::Handled ) { return; } // Treat the transcript as the only interactive region for transcript selection. // // This prevents clicks in the composer/footer from starting or extending a transcript // selection, while still allowing a left-click outside the transcript to clear an // existing highlight. if !self.transcript_scrollbar_ui.pointer_capture_active() && (mouse_event.row < transcript_full_area.y || mouse_event.row >= transcript_full_area.bottom()) { if matches!( mouse_event.kind, MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Up(MouseButton::Left) ) && (self.transcript_selection.anchor.is_some() || self.transcript_selection.head.is_some()) { self.transcript_selection = TranscriptSelection::default(); // Mouse events do not inherently trigger a redraw; schedule one so the cleared // highlight is reflected immediately. tui.frame_requester().schedule_frame(); } return; } let mut clamped_x = mouse_event.column; let clamped_y = mouse_event.row; if clamped_x < base_x { clamped_x = base_x; } if clamped_x > max_x { clamped_x = max_x; } let streaming = self.chat_widget.is_task_running(); if matches!(mouse_event.kind, MouseEventKind::Down(MouseButton::Left)) && self .transcript_copy_ui .hit_test(mouse_event.column, mouse_event.row) { if self.transcript_copy_action.copy_and_handle( tui, chat_height, &self.transcript_cells, self.transcript_selection, ) { self.transcript_selection = TranscriptSelection::default(); } return; } match mouse_event.kind { MouseEventKind::ScrollUp => { let scroll_update = self.mouse_scroll_update(ScrollDirection::Up); self.apply_scroll_update( tui, scroll_update, transcript_area.height as usize, transcript_area.width, true, ); } MouseEventKind::ScrollDown => { let scroll_update = self.mouse_scroll_update(ScrollDirection::Down); self.apply_scroll_update( tui, scroll_update, transcript_area.height as usize, transcript_area.width, true, ); } MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight => {} MouseEventKind::Down(MouseButton::Left) => { self.transcript_copy_ui.set_dragging(true); let point = self.transcript_point_from_coordinates( transcript_area, base_x, clamped_x, clamped_y, ); if self.transcript_multi_click.on_mouse_down( &mut self.transcript_selection, &self.transcript_cells, transcript_area.width, point, ) { tui.frame_requester().schedule_frame(); } } MouseEventKind::Drag(MouseButton::Left) => { let point = self.transcript_point_from_coordinates( transcript_area, base_x, clamped_x, clamped_y, ); let outcome = crate::transcript_selection::on_mouse_drag( &mut self.transcript_selection, &self.transcript_scroll, point, streaming, ); self.transcript_multi_click .on_mouse_drag(&self.transcript_selection, point); if outcome.lock_scroll { self.lock_transcript_scroll_to_current_view( transcript_area.height as usize, transcript_area.width, ); } if outcome.changed { tui.frame_requester().schedule_frame(); } } MouseEventKind::Up(MouseButton::Left) => { self.transcript_copy_ui.set_dragging(false); let selection_changed = crate::transcript_selection::on_mouse_up(&mut self.transcript_selection); let has_active_selection = self.transcript_selection.anchor.is_some() && self.transcript_selection.head.is_some(); if selection_changed || has_active_selection { tui.frame_requester().schedule_frame(); } } _ => {} } } /// Convert a single mouse scroll event (direction-only) into a normalized scroll update. /// /// This delegates to [`MouseScrollState::on_scroll_event`] using the current [`ScrollConfig`]. /// The returned [`ScrollUpdate`] is intentionally split into: /// /// - `lines`: a *delta* in visual lines to apply immediately to the transcript viewport. /// - Sign convention matches [`ScrollDirection`] (`Up` is negative; `Down` is positive). /// - May be 0 in trackpad-like mode while sub-line fractions are still accumulating. /// - `next_tick_in`: an optional delay after which we should trigger a follow-up tick. /// This is required because stream closure is defined by a *time gap* rather than an /// explicit "gesture end" event. See [`App::apply_scroll_update`] and /// [`App::handle_scroll_tick`]. /// /// In TUI2, that follow-up tick is driven via `TuiEvent::Draw`: we schedule a frame, and on /// the next draw we call [`MouseScrollState::on_tick`] to close idle streams and flush any /// newly-reached whole lines. This prevents perceived "stop lag" where accumulated scroll only /// applies once the next user input arrives. fn mouse_scroll_update(&mut self, direction: ScrollDirection) -> ScrollUpdate { self.scroll_state .on_scroll_event(direction, self.scroll_config) } /// Apply a [`ScrollUpdate`] to the transcript viewport and schedule any needed follow-up tick. /// /// `update.lines` is applied immediately via [`App::scroll_transcript`]. /// /// If `update.next_tick_in` is `Some`, we schedule a future frame so `TuiEvent::Draw` can call /// [`App::handle_scroll_tick`] and close the stream after it goes idle and/or cadence-flush /// pending whole lines. /// /// `schedule_frame` is forwarded to [`App::scroll_transcript`] and controls whether scrolling /// should request an additional draw. Pass `false` when applying scroll during a /// `TuiEvent::Draw` tick to avoid redundant frames. fn apply_scroll_update( &mut self, tui: &mut tui::Tui, update: ScrollUpdate, visible_lines: usize, width: u16, schedule_frame: bool, ) { if update.lines != 0 { self.scroll_transcript(tui, update.lines, visible_lines, width, schedule_frame); } if let Some(delay) = update.next_tick_in { tui.frame_requester().schedule_frame_in(delay); } } /// Drive stream closure and cadence-based flushing for mouse scrolling. /// /// This is called on every `TuiEvent::Draw` before rendering. If a scroll stream is active, it /// may: /// /// - Close the stream once it has been idle for longer than the stream-gap threshold. /// - Flush whole-line deltas on the redraw cadence for trackpad-like streams, even if no new /// events arrive. /// /// The resulting update is applied with `schedule_frame = false` because we are already in a /// draw tick. fn handle_scroll_tick(&mut self, tui: &mut tui::Tui) { let Some((visible_lines, width)) = self.transcript_scroll_dimensions(tui) else { return; }; let update = self.scroll_state.on_tick(); self.apply_scroll_update(tui, update, visible_lines, width, false); } /// Compute the transcript viewport dimensions used for scrolling. /// /// Mouse scrolling is applied in terms of "visible transcript lines": the terminal height /// minus the chat composer height. We compute this from the last known terminal size to avoid /// querying the terminal during non-draw events. /// /// Returns `(visible_lines, width)` or `None` when the terminal is not yet sized or the chat /// area consumes the full height. fn transcript_scroll_dimensions(&self, tui: &tui::Tui) -> Option<(usize, u16)> { let size = tui.terminal.last_known_screen_size; let width = size.width; let height = size.height; if width == 0 || height == 0 { return None; } let chat_height = self.chat_widget.desired_height(width); if chat_height >= height { return None; } let transcript_height = height.saturating_sub(chat_height); if transcript_height == 0 { return None; } let transcript_full_area = Rect { x: 0, y: 0, width, height: transcript_height, }; let (transcript_area, _) = split_transcript_area(transcript_full_area); Some((transcript_height as usize, transcript_area.width)) } /// Scroll the transcript by a number of visual lines. /// /// This is the shared implementation behind mouse wheel movement and PgUp/PgDn keys in /// the main view. Scroll state is expressed in terms of transcript cells and their /// internal line indices, so scrolling refers to logical conversation content and /// remains stable even as wrapping or streaming causes visual reflows. /// /// `schedule_frame` controls whether to request an extra draw; pass `false` when applying /// scroll during a `TuiEvent::Draw` tick to avoid redundant frames. fn scroll_transcript( &mut self, tui: &mut tui::Tui, delta_lines: i32, visible_lines: usize, width: u16, schedule_frame: bool, ) { if visible_lines == 0 { return; } self.transcript_view_cache .ensure_wrapped(&self.transcript_cells, width); let line_meta = self.transcript_view_cache.line_meta(); self.transcript_scroll = self.transcript_scroll .scrolled_by(delta_lines, line_meta, visible_lines); if schedule_frame { // Request a redraw; the frame scheduler coalesces bursts and clamps to 60fps. tui.frame_requester().schedule_frame(); } } /// Convert a `ToBottom` (auto-follow) scroll state into a fixed anchor at the current view. /// /// When the user begins a mouse selection while new output is streaming in, the view /// should stop auto-following the latest line so the selection stays on the intended /// content. This helper inspects the flattened transcript at the given width, derives /// a concrete position corresponding to the current top row, and switches into a scroll /// mode that keeps that position stable until the user scrolls again. fn lock_transcript_scroll_to_current_view(&mut self, visible_lines: usize, width: u16) { if self.transcript_cells.is_empty() || visible_lines == 0 || width == 0 { return; } self.transcript_view_cache .ensure_wrapped(&self.transcript_cells, width); let lines = self.transcript_view_cache.lines(); let line_meta = self.transcript_view_cache.line_meta(); if lines.is_empty() || line_meta.is_empty() { return; } let total_lines = lines.len(); let max_visible = std::cmp::min(visible_lines, total_lines); if max_visible == 0 { return; } let max_start = total_lines.saturating_sub(max_visible); let top_offset = match self.transcript_scroll { TranscriptScroll::ToBottom => max_start, TranscriptScroll::Scrolled { .. } | TranscriptScroll::ScrolledSpacerBeforeCell { .. } => { // Already anchored; nothing to lock. return; } }; if let Some(scroll_state) = TranscriptScroll::anchor_for(line_meta, top_offset) { self.transcript_scroll = scroll_state; } } /// Apply the current transcript selection to the given buffer. /// /// The selection is defined in terms of flattened wrapped transcript line /// indices and columns. This method maps those content-relative endpoints /// into the currently visible viewport based on `transcript_view_top` and /// `transcript_total_lines`, so the highlight moves with the content as the /// user scrolls. fn apply_transcript_selection(&self, area: Rect, buf: &mut Buffer) { let (anchor, head) = match ( self.transcript_selection.anchor, self.transcript_selection.head, ) { (Some(a), Some(h)) => (a, h), _ => return, }; if self.transcript_total_lines == 0 { return; } let base_x = area.x.saturating_add(TRANSCRIPT_GUTTER_COLS); let max_x = area.right().saturating_sub(1); let (start, end) = crate::transcript_selection::ordered_endpoints(anchor, head); let visible_start = self.transcript_view_top; let visible_end = self .transcript_view_top .saturating_add(area.height as usize) .min(self.transcript_total_lines); for (row_index, line_index) in (visible_start..visible_end).enumerate() { if line_index < start.line_index || line_index > end.line_index { continue; } let y = area.y + row_index as u16; let mut first_text_x = None; let mut last_text_x = None; for x in base_x..=max_x { let cell = &buf[(x, y)]; if cell.symbol() != " " { if first_text_x.is_none() { first_text_x = Some(x); } last_text_x = Some(x); } } let (text_start, text_end) = match (first_text_x, last_text_x) { // Treat indentation spaces as part of the selectable region by // starting from the first content column to the right of the // transcript gutter, but still clamp to the last non-space // glyph so trailing padding is not included. (Some(_), Some(e)) => (base_x, e), _ => continue, }; let line_start_col = if line_index == start.line_index { start.column } else { 0 }; let line_end_col = if line_index == end.line_index { end.column } else { max_x.saturating_sub(base_x) }; let row_sel_start = base_x.saturating_add(line_start_col); let row_sel_end = base_x.saturating_add(line_end_col).min(max_x); if row_sel_start > row_sel_end { continue; } let from_x = row_sel_start.max(text_start); let to_x = row_sel_end.min(text_end); if from_x > to_x { continue; } for x in from_x..=to_x { let cell = &mut buf[(x, y)]; let style = cell.style(); cell.set_style(style.add_modifier(ratatui::style::Modifier::REVERSED)); } } } fn transcript_copy_feedback_for_footer(&mut self) -> Option { self.transcript_copy_action.footer_feedback() } fn copy_selection_key(&self) -> crate::key_hint::KeyBinding { self.transcript_copy_ui.key_binding() } /// Map a mouse position in the transcript area to a content-relative /// selection point, if there is transcript content to select. fn transcript_point_from_coordinates( &self, transcript_area: Rect, base_x: u16, x: u16, y: u16, ) -> Option { if self.transcript_total_lines == 0 { return None; } let mut row_index = y.saturating_sub(transcript_area.y); if row_index >= transcript_area.height { if transcript_area.height == 0 { return None; } row_index = transcript_area.height.saturating_sub(1); } let max_line = self.transcript_total_lines.saturating_sub(1); let line_index = self .transcript_view_top .saturating_add(usize::from(row_index)) .min(max_line); let column = x.saturating_sub(base_x); Some(TranscriptSelectionPoint { line_index, column }) } async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result { match event { AppEvent::NewSession => { let summary = session_summary( self.chat_widget.token_usage(), self.chat_widget.conversation_id(), ); self.shutdown_current_conversation().await; if let Err(err) = self.server.remove_and_close_all_threads().await { tracing::warn!(error = %err, "failed to close all threads"); } 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, auth_manager: self.auth_manager.clone(), models_manager: self.server.get_models_manager(), feedback: self.feedback.clone(), is_first_run: false, model: Some(self.current_model.clone()), }; self.chat_widget = ChatWidget::new(init, self.server.clone()); if let Some(summary) = summary { let mut lines: Vec> = vec![summary.usage_line.clone().into()]; if let Some(command) = summary.resume_command { let spans = vec!["To continue this session, run ".into(), command.cyan()]; lines.push(spans.into()); } self.chat_widget.add_plain_history_lines(lines); } tui.frame_requester().schedule_frame(); } AppEvent::OpenResumePicker => { match crate::resume_picker::run_resume_picker( tui, &self.config.codex_home, &self.config.model_provider_id, false, ) .await? { SessionSelection::Resume(path) => { let summary = session_summary( self.chat_widget.token_usage(), self.chat_widget.conversation_id(), ); match self .server .resume_thread_from_rollout( self.config.clone(), path.clone(), self.auth_manager.clone(), ) .await { Ok(resumed) => { self.shutdown_current_conversation().await; 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, auth_manager: self.auth_manager.clone(), models_manager: self.server.get_models_manager(), feedback: self.feedback.clone(), is_first_run: false, model: Some(self.current_model.clone()), }; self.chat_widget = ChatWidget::new_from_existing( init, resumed.thread, resumed.session_configured, ); if let Some(summary) = summary { let mut lines: Vec> = vec![summary.usage_line.clone().into()]; if let Some(command) = summary.resume_command { let spans = vec![ "To continue this session, run ".into(), command.cyan(), ]; lines.push(spans.into()); } self.chat_widget.add_plain_history_lines(lines); } } Err(err) => { let path_display = path.display(); self.chat_widget.add_error_message(format!( "Failed to resume session from {path_display}: {err}" )); } } } SessionSelection::Exit | SessionSelection::StartFresh | SessionSelection::Fork(_) => {} } // Leaving alt-screen may blank the inline viewport; force a redraw either way. tui.frame_requester().schedule_frame(); } AppEvent::ForkCurrentSession => { let summary = session_summary( self.chat_widget.token_usage(), self.chat_widget.conversation_id(), ); if let Some(path) = self.chat_widget.rollout_path() { match self .server .fork_thread(usize::MAX, self.config.clone(), path.clone()) .await { Ok(forked) => { self.shutdown_current_conversation().await; 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, auth_manager: self.auth_manager.clone(), models_manager: self.server.get_models_manager(), feedback: self.feedback.clone(), is_first_run: false, model: Some(self.current_model.clone()), }; self.chat_widget = ChatWidget::new_from_existing( init, forked.thread, forked.session_configured, ); if let Some(summary) = summary { let mut lines: Vec> = vec![summary.usage_line.clone().into()]; if let Some(command) = summary.resume_command { let spans = vec![ "To continue this session, run ".into(), command.cyan(), ]; lines.push(spans.into()); } self.chat_widget.add_plain_history_lines(lines); } } Err(err) => { let path_display = path.display(); self.chat_widget.add_error_message(format!( "Failed to fork current session from {path_display}: {err}" )); } } } else { self.chat_widget .add_error_message("Current session is not ready to fork yet.".to_string()); } tui.frame_requester().schedule_frame(); } AppEvent::InsertHistoryCell(cell) => { let cell: Arc = cell.into(); if let Some(Overlay::Transcript(transcript)) = &mut self.overlay { transcript.insert_cell(cell.clone()); tui.frame_requester().schedule_frame(); } self.transcript_cells.push(cell.clone()); if self.overlay.is_some() { self.deferred_history_cells.push(cell); } else { tui.frame_requester().schedule_frame(); } } 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) => { if self.suppress_shutdown_complete && matches!(event.msg, EventMsg::ShutdownComplete) { self.suppress_shutdown_complete = false; return Ok(AppRunControl::Continue); } if let EventMsg::ListSkillsResponse(response) = &event.msg { let cwd = self.chat_widget.config_ref().cwd.clone(); let errors = errors_for_cwd(&cwd, response); emit_skill_load_warnings(&self.app_event_tx, &errors); } self.handle_backtrack_event(&event.msg); self.chat_widget.handle_codex_event(event); if self.backtrack_render_pending { tui.frame_requester().schedule_frame(); } } AppEvent::Exit(mode) => match mode { ExitMode::ShutdownFirst => self.chat_widget.submit_op(Op::Shutdown), ExitMode::Immediate => { return Ok(AppRunControl::Exit(ExitReason::UserRequested)); } }, AppEvent::FatalExitRequest(message) => { return Ok(AppRunControl::Exit(ExitReason::Fatal(message))); } 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_lines( 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::RateLimitSnapshotFetched(snapshot) => { self.chat_widget.on_rate_limit_snapshot(Some(snapshot)); } AppEvent::UpdateReasoningEffort(effort) => { self.on_update_reasoning_effort(effort); } AppEvent::UpdateModel(model) => { self.chat_widget.set_model(&model); self.current_model = model; } AppEvent::OpenReasoningPopup { model } => { self.chat_widget.open_reasoning_popup(model); } AppEvent::OpenAllModelsPopup { models } => { self.chat_widget.open_all_models_popup(models); } AppEvent::OpenFullAccessConfirmation { preset } => { self.chat_widget.open_full_access_confirmation(preset); } AppEvent::OpenWorldWritableWarningConfirmation { preset, sample_paths, extra_count, failed_scan, } => { self.chat_widget.open_world_writable_warning_confirmation( preset, sample_paths, extra_count, failed_scan, ); } AppEvent::OpenFeedbackNote { category, include_logs, } => { self.chat_widget.open_feedback_note(category, include_logs); } AppEvent::OpenFeedbackConsent { category } => { self.chat_widget.open_feedback_consent(category); } AppEvent::OpenWindowsSandboxEnablePrompt { preset } => { self.chat_widget.open_windows_sandbox_enable_prompt(preset); } AppEvent::OpenWindowsSandboxFallbackPrompt { preset, reason } => { self.chat_widget.clear_windows_sandbox_setup_status(); self.chat_widget .open_windows_sandbox_fallback_prompt(preset, reason); } AppEvent::BeginWindowsSandboxElevatedSetup { preset } => { #[cfg(target_os = "windows")] { let policy = preset.sandbox.clone(); let policy_cwd = self.config.cwd.clone(); let command_cwd = policy_cwd.clone(); let env_map: std::collections::HashMap = std::env::vars().collect(); let codex_home = self.config.codex_home.clone(); let tx = self.app_event_tx.clone(); // If the elevated setup already ran on this machine, don't prompt for // elevation again - just flip the config to use the elevated path. if codex_core::windows_sandbox::sandbox_setup_is_complete(codex_home.as_path()) { tx.send(AppEvent::EnableWindowsSandboxForAgentMode { preset, mode: WindowsSandboxEnableMode::Elevated, }); return Ok(AppRunControl::Continue); } self.chat_widget.show_windows_sandbox_setup_status(); tokio::task::spawn_blocking(move || { let result = codex_core::windows_sandbox::run_elevated_setup( &policy, policy_cwd.as_path(), command_cwd.as_path(), &env_map, codex_home.as_path(), ); let event = match result { Ok(()) => AppEvent::EnableWindowsSandboxForAgentMode { preset: preset.clone(), mode: WindowsSandboxEnableMode::Elevated, }, Err(err) => { tracing::error!( error = %err, "failed to run elevated Windows sandbox setup" ); AppEvent::OpenWindowsSandboxFallbackPrompt { preset, reason: WindowsSandboxFallbackReason::ElevationFailed, } } }; tx.send(event); }); } #[cfg(not(target_os = "windows"))] { let _ = preset; } } AppEvent::EnableWindowsSandboxForAgentMode { preset, mode } => { #[cfg(target_os = "windows")] { self.chat_widget.clear_windows_sandbox_setup_status(); let profile = self.active_profile.as_deref(); let feature_key = Feature::WindowsSandbox.key(); let elevated_key = Feature::WindowsSandboxElevated.key(); let elevated_enabled = matches!(mode, WindowsSandboxEnableMode::Elevated); match ConfigEditsBuilder::new(&self.config.codex_home) .with_profile(profile) .set_feature_enabled(feature_key, true) .set_feature_enabled(elevated_key, elevated_enabled) .apply() .await { Ok(()) => { self.config.set_windows_sandbox_globally(true); self.config .set_windows_elevated_sandbox_globally(elevated_enabled); self.chat_widget .set_feature_enabled(Feature::WindowsSandbox, true); self.chat_widget.set_feature_enabled( Feature::WindowsSandboxElevated, elevated_enabled, ); self.chat_widget.clear_forced_auto_mode_downgrade(); if let Some((sample_paths, extra_count, failed_scan)) = self.chat_widget.world_writable_warning_details() { self.app_event_tx.send( AppEvent::OpenWorldWritableWarningConfirmation { preset: Some(preset.clone()), sample_paths, extra_count, failed_scan, }, ); } else { self.app_event_tx.send(AppEvent::CodexOp( Op::OverrideTurnContext { cwd: None, approval_policy: Some(preset.approval), sandbox_policy: Some(preset.sandbox.clone()), model: None, effort: None, summary: None, collaboration_mode: None, }, )); self.app_event_tx .send(AppEvent::UpdateAskForApprovalPolicy(preset.approval)); self.app_event_tx .send(AppEvent::UpdateSandboxPolicy(preset.sandbox.clone())); self.chat_widget.add_info_message( match mode { WindowsSandboxEnableMode::Elevated => { "Enabled elevated agent sandbox.".to_string() } WindowsSandboxEnableMode::Legacy => { "Enabled non-elevated agent sandbox.".to_string() } }, None, ); } } Err(err) => { tracing::error!( error = %err, "failed to enable Windows sandbox feature" ); self.chat_widget.add_error_message(format!( "Failed to enable the Windows sandbox feature: {err}" )); } } } #[cfg(not(target_os = "windows"))] { let _ = (preset, mode); } } AppEvent::PersistModelSelection { model, effort } => { let profile = self.active_profile.as_deref(); match ConfigEditsBuilder::new(&self.config.codex_home) .with_profile(profile) .set_model(Some(model.as_str()), effort) .apply() .await { Ok(()) => { let mut message = format!("Model changed to {model}"); if let Some(label) = Self::reasoning_label_for(&model, effort) { message.push(' '); message.push_str(label); } if let Some(profile) = profile { message.push_str(" for "); message.push_str(profile); message.push_str(" profile"); } self.chat_widget.add_info_message(message, None); } Err(err) => { tracing::error!( error = %err, "failed to persist model selection" ); if let Some(profile) = profile { self.chat_widget.add_error_message(format!( "Failed to save model for profile `{profile}`: {err}" )); } else { self.chat_widget .add_error_message(format!("Failed to save default model: {err}")); } } } } AppEvent::UpdateAskForApprovalPolicy(policy) => { self.chat_widget.set_approval_policy(policy); } AppEvent::UpdateSandboxPolicy(policy) => { #[cfg(target_os = "windows")] let policy_is_workspace_write_or_ro = matches!( &policy, codex_core::protocol::SandboxPolicy::WorkspaceWrite { .. } | codex_core::protocol::SandboxPolicy::ReadOnly ); if let Err(err) = self.config.sandbox_policy.set(policy.clone()) { tracing::warn!(%err, "failed to set sandbox policy on app config"); self.chat_widget .add_error_message(format!("Failed to set sandbox policy: {err}")); return Ok(AppRunControl::Continue); } #[cfg(target_os = "windows")] if !matches!(&policy, codex_core::protocol::SandboxPolicy::ReadOnly) || codex_core::get_platform_sandbox().is_some() { self.config.forced_auto_mode_downgraded_on_windows = false; } if let Err(err) = self.chat_widget.set_sandbox_policy(policy) { tracing::warn!(%err, "failed to set sandbox policy on chat config"); self.chat_widget .add_error_message(format!("Failed to set sandbox policy: {err}")); return Ok(AppRunControl::Continue); } // If sandbox policy becomes workspace-write or read-only, run the Windows world-writable scan. #[cfg(target_os = "windows")] { // One-shot suppression if the user just confirmed continue. if self.skip_world_writable_scan_once { self.skip_world_writable_scan_once = false; return Ok(AppRunControl::Continue); } let should_check = codex_core::get_platform_sandbox().is_some() && policy_is_workspace_write_or_ro && !self.chat_widget.world_writable_warning_hidden(); if should_check { let cwd = self.config.cwd.clone(); let env_map: std::collections::HashMap = std::env::vars().collect(); let tx = self.app_event_tx.clone(); let logs_base_dir = self.config.codex_home.clone(); let sandbox_policy = self.config.sandbox_policy.get().clone(); Self::spawn_world_writable_scan( cwd, env_map, logs_base_dir, sandbox_policy, tx, ); } } } AppEvent::SkipNextWorldWritableScan => { self.skip_world_writable_scan_once = true; } AppEvent::UpdateFullAccessWarningAcknowledged(ack) => { self.chat_widget.set_full_access_warning_acknowledged(ack); } AppEvent::UpdateWorldWritableWarningAcknowledged(ack) => { self.chat_widget .set_world_writable_warning_acknowledged(ack); } AppEvent::UpdateRateLimitSwitchPromptHidden(hidden) => { self.chat_widget.set_rate_limit_switch_prompt_hidden(hidden); } AppEvent::PersistFullAccessWarningAcknowledged => { if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) .set_hide_full_access_warning(true) .apply() .await { tracing::error!( error = %err, "failed to persist full access warning acknowledgement" ); self.chat_widget.add_error_message(format!( "Failed to save full access confirmation preference: {err}" )); } } AppEvent::PersistWorldWritableWarningAcknowledged => { if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) .set_hide_world_writable_warning(true) .apply() .await { tracing::error!( error = %err, "failed to persist world-writable warning acknowledgement" ); self.chat_widget.add_error_message(format!( "Failed to save Agent mode warning preference: {err}" )); } } AppEvent::PersistRateLimitSwitchPromptHidden => { if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) .set_hide_rate_limit_model_nudge(true) .apply() .await { tracing::error!( error = %err, "failed to persist rate limit switch prompt preference" ); self.chat_widget.add_error_message(format!( "Failed to save rate limit reminder preference: {err}" )); } } AppEvent::PersistModelMigrationPromptAcknowledged { from_model, to_model, } => { if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) .record_model_migration_seen(from_model.as_str(), to_model.as_str()) .apply() .await { tracing::error!( error = %err, "failed to persist model migration prompt acknowledgement" ); self.chat_widget.add_error_message(format!( "Failed to save model migration prompt preference: {err}" )); } } AppEvent::OpenApprovalsPopup => { self.chat_widget.open_approvals_popup(); } AppEvent::OpenReviewBranchPicker(cwd) => { self.chat_widget.show_review_branch_picker(&cwd).await; } AppEvent::OpenReviewCommitPicker(cwd) => { self.chat_widget.show_review_commit_picker(&cwd).await; } AppEvent::OpenReviewCustomPrompt => { self.chat_widget.show_review_custom_prompt(); } AppEvent::FullScreenApprovalRequest(request) => match request { ApprovalRequest::ApplyPatch { cwd, changes, .. } => { let _ = tui.enter_alt_screen(); let diff_summary = DiffSummary::new(changes, cwd); self.overlay = Some(Overlay::new_static_with_renderables( vec![diff_summary.into()], "P A T C H".to_string(), )); } ApprovalRequest::Exec { command, .. } => { let _ = tui.enter_alt_screen(); let full_cmd = strip_bash_lc_and_escape(&command); let full_cmd_lines = highlight_bash_to_lines(&full_cmd); self.overlay = Some(Overlay::new_static_with_lines( full_cmd_lines, "E X E C".to_string(), )); } ApprovalRequest::McpElicitation { server_name, message, .. } => { let _ = tui.enter_alt_screen(); let paragraph = Paragraph::new(vec![ Line::from(vec!["Server: ".into(), server_name.bold()]), Line::from(""), Line::from(message), ]) .wrap(Wrap { trim: false }); self.overlay = Some(Overlay::new_static_with_renderables( vec![Box::new(paragraph)], "E L I C I T A T I O N".to_string(), )); } }, } Ok(AppRunControl::Continue) } fn reasoning_label(reasoning_effort: Option) -> &'static str { match reasoning_effort { Some(ReasoningEffortConfig::Minimal) => "minimal", Some(ReasoningEffortConfig::Low) => "low", Some(ReasoningEffortConfig::Medium) => "medium", Some(ReasoningEffortConfig::High) => "high", Some(ReasoningEffortConfig::XHigh) => "xhigh", None | Some(ReasoningEffortConfig::None) => "default", } } fn reasoning_label_for( model: &str, reasoning_effort: Option, ) -> Option<&'static str> { (!model.starts_with("codex-auto-")).then(|| Self::reasoning_label(reasoning_effort)) } pub(crate) fn token_usage(&self) -> codex_core::protocol::TokenUsage { self.chat_widget.token_usage() } fn on_update_reasoning_effort(&mut self, effort: Option) { self.chat_widget.set_reasoning_effort(effort); self.config.model_reasoning_effort = effort; } 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_cells.clone())); tui.frame_requester().schedule_frame(); } // Esc primes/advances backtracking only in normal (not working) mode // with the composer focused and empty. 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); } } KeyEvent { code: KeyCode::Char(ch), modifiers, kind: KeyEventKind::Press | KeyEventKind::Repeat, .. } if self.transcript_copy_ui.is_copy_key(ch, modifiers) => { let size = tui.terminal.last_known_screen_size; let width = size.width; let height = size.height; if width == 0 || height == 0 { return; } let chat_height = self.chat_widget.desired_height(width); if self.transcript_copy_action.copy_and_handle( tui, chat_height, &self.transcript_cells, self.transcript_selection, ) { self.transcript_selection = TranscriptSelection::default(); } } KeyEvent { code: KeyCode::PageUp, kind: KeyEventKind::Press | KeyEventKind::Repeat, .. } => { let size = tui.terminal.last_known_screen_size; let width = size.width; let height = size.height; if width > 0 && height > 0 { let chat_height = self.chat_widget.desired_height(width); if chat_height < height { let transcript_height = height.saturating_sub(chat_height); if transcript_height > 0 { let delta = -i32::from(transcript_height); self.scroll_transcript( tui, delta, usize::from(transcript_height), width, true, ); } } } } KeyEvent { code: KeyCode::PageDown, kind: KeyEventKind::Press | KeyEventKind::Repeat, .. } => { let size = tui.terminal.last_known_screen_size; let width = size.width; let height = size.height; if width > 0 && height > 0 { let chat_height = self.chat_widget.desired_height(width); if chat_height < height { let transcript_height = height.saturating_sub(chat_height); if transcript_height > 0 { let delta = i32::from(transcript_height); self.scroll_transcript( tui, delta, usize::from(transcript_height), width, true, ); } } } } KeyEvent { code: KeyCode::Home, kind: KeyEventKind::Press | KeyEventKind::Repeat, .. } => { if !self.transcript_cells.is_empty() { self.transcript_scroll = TranscriptScroll::Scrolled { cell_index: 0, line_in_cell: 0, }; tui.frame_requester().schedule_frame(); } } KeyEvent { code: KeyCode::End, kind: KeyEventKind::Press | KeyEventKind::Repeat, .. } => { self.transcript_scroll = TranscriptScroll::ToBottom; tui.frame_requester().schedule_frame(); } // Enter confirms backtrack when primed + count > 0. Otherwise pass to widget. KeyEvent { code: KeyCode::Enter, kind: KeyEventKind::Press, .. } if self.backtrack.primed && self.backtrack.nth_user_message != usize::MAX && self.chat_widget.composer_is_empty() => { if let Some(selection) = self.confirm_backtrack_from_main() { self.apply_backtrack_selection(tui, selection); } } 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. } }; } #[cfg(target_os = "windows")] fn spawn_world_writable_scan( cwd: PathBuf, env_map: std::collections::HashMap, logs_base_dir: PathBuf, sandbox_policy: codex_core::protocol::SandboxPolicy, tx: AppEventSender, ) { tokio::task::spawn_blocking(move || { let result = codex_windows_sandbox::apply_world_writable_scan_and_denies( &logs_base_dir, &cwd, &env_map, &sandbox_policy, Some(logs_base_dir.as_path()), ); if result.is_err() { // Scan failed: warn without examples. tx.send(AppEvent::OpenWorldWritableWarningConfirmation { preset: None, sample_paths: Vec::new(), extra_count: 0usize, failed_scan: true, }); } }); } } #[cfg(test)] mod tests { use super::*; use crate::app_backtrack::BacktrackState; use crate::app_backtrack::user_count; use crate::chatwidget::tests::make_chatwidget_manual_with_sender; use crate::file_search::FileSearchManager; use crate::history_cell::AgentMessageCell; use crate::history_cell::HistoryCell; use crate::history_cell::UserHistoryCell; use crate::history_cell::new_session_info; use crate::transcript_copy_ui::CopySelectionShortcut; use crate::tui::scrolling::TranscriptLineMeta; use codex_core::AuthManager; use codex_core::CodexAuth; use codex_core::ThreadManager; use codex_core::config::ConfigBuilder; use codex_core::protocol::AskForApproval; use codex_core::protocol::Event; use codex_core::protocol::EventMsg; use codex_core::protocol::SandboxPolicy; use codex_core::protocol::SessionConfiguredEvent; use codex_protocol::ThreadId; use insta::assert_snapshot; use pretty_assertions::assert_eq; use ratatui::prelude::Line; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicBool; use tempfile::tempdir; async fn make_test_app() -> App { let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await; let config = chat_widget.config_ref().clone(); let current_model = "gpt-5.2-codex".to_string(); let server = Arc::new(ThreadManager::with_models_provider( CodexAuth::from_api_key("Test API Key"), config.model_provider.clone(), )); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); App { server, app_event_tx, chat_widget, auth_manager, config, current_model, active_profile: None, file_search, transcript_cells: Vec::new(), transcript_view_cache: TranscriptViewCache::new(), transcript_scroll: TranscriptScroll::default(), transcript_selection: TranscriptSelection::default(), transcript_multi_click: TranscriptMultiClick::default(), transcript_view_top: 0, transcript_total_lines: 0, transcript_copy_ui: TranscriptCopyUi::new_with_shortcut( CopySelectionShortcut::CtrlShiftC, ), transcript_copy_action: TranscriptCopyAction::default(), transcript_scrollbar_ui: TranscriptScrollbarUi::default(), overlay: None, deferred_history_cells: Vec::new(), has_emitted_history_lines: false, enhanced_keys_supported: false, commit_anim_running: Arc::new(AtomicBool::new(false)), scroll_config: ScrollConfig::default(), scroll_state: MouseScrollState::default(), backtrack: BacktrackState::default(), backtrack_render_pending: false, feedback: codex_feedback::CodexFeedback::new(), pending_update_action: None, suppress_shutdown_complete: false, skip_world_writable_scan_once: false, } } async fn make_test_app_with_channels() -> ( App, tokio::sync::mpsc::UnboundedReceiver, tokio::sync::mpsc::UnboundedReceiver, ) { let (chat_widget, app_event_tx, rx, op_rx) = make_chatwidget_manual_with_sender().await; let config = chat_widget.config_ref().clone(); let current_model = "gpt-5.2-codex".to_string(); let server = Arc::new(ThreadManager::with_models_provider( CodexAuth::from_api_key("Test API Key"), config.model_provider.clone(), )); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); ( App { server, app_event_tx, chat_widget, auth_manager, config, current_model, active_profile: None, file_search, transcript_cells: Vec::new(), transcript_view_cache: TranscriptViewCache::new(), transcript_scroll: TranscriptScroll::default(), transcript_selection: TranscriptSelection::default(), transcript_multi_click: TranscriptMultiClick::default(), transcript_view_top: 0, transcript_total_lines: 0, transcript_copy_ui: TranscriptCopyUi::new_with_shortcut( CopySelectionShortcut::CtrlShiftC, ), transcript_copy_action: TranscriptCopyAction::default(), transcript_scrollbar_ui: TranscriptScrollbarUi::default(), overlay: None, deferred_history_cells: Vec::new(), has_emitted_history_lines: false, enhanced_keys_supported: false, commit_anim_running: Arc::new(AtomicBool::new(false)), scroll_config: ScrollConfig::default(), scroll_state: MouseScrollState::default(), backtrack: BacktrackState::default(), backtrack_render_pending: false, feedback: codex_feedback::CodexFeedback::new(), pending_update_action: None, suppress_shutdown_complete: false, skip_world_writable_scan_once: false, }, rx, op_rx, ) } fn all_model_presets() -> Vec { codex_core::models_manager::model_presets::all_model_presets().clone() } fn model_migration_copy_to_plain_text( copy: &crate::model_migration::ModelMigrationCopy, ) -> String { if let Some(markdown) = copy.markdown.as_ref() { return markdown.clone(); } let mut s = String::new(); for span in ©.heading { s.push_str(&span.content); } s.push('\n'); s.push('\n'); for line in ©.content { for span in &line.spans { s.push_str(&span.content); } s.push('\n'); } s } #[tokio::test] async fn model_migration_prompt_only_shows_for_deprecated_models() { let seen = BTreeMap::new(); assert!(should_show_model_migration_prompt( "gpt-5", "gpt-5.1", &seen, &all_model_presets() )); assert!(should_show_model_migration_prompt( "gpt-5-codex", "gpt-5.1-codex", &seen, &all_model_presets() )); assert!(should_show_model_migration_prompt( "gpt-5-codex-mini", "gpt-5.1-codex-mini", &seen, &all_model_presets() )); assert!(should_show_model_migration_prompt( "gpt-5.1-codex", "gpt-5.1-codex-max", &seen, &all_model_presets() )); assert!(!should_show_model_migration_prompt( "gpt-5.1-codex", "gpt-5.1-codex", &seen, &all_model_presets() )); } #[tokio::test] async fn model_migration_prompt_shows_for_hidden_model() { let codex_home = tempdir().expect("temp codex home"); let config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) .build() .await .expect("config"); let available_models = all_model_presets(); let current = available_models .iter() .find(|preset| preset.model == "gpt-5.1-codex") .cloned() .expect("gpt-5.1-codex preset present"); assert!( !current.show_in_picker, "expected gpt-5.1-codex to be hidden from picker for this test" ); let upgrade = current.upgrade.as_ref().expect("upgrade configured"); assert!( should_show_model_migration_prompt( ¤t.model, &upgrade.id, &config.notices.model_migrations, &available_models, ), "expected migration prompt to be eligible for hidden model" ); let target = available_models .iter() .find(|preset| preset.model == upgrade.id) .cloned() .expect("upgrade target present"); let target_description = (!target.description.is_empty()).then(|| target.description.clone()); let can_opt_out = true; let copy = migration_copy_for_models( ¤t.model, &upgrade.id, target.display_name, target_description, upgrade.migration_markdown.clone(), can_opt_out, ); assert_snapshot!( "model_migration_prompt_shows_for_hidden_model", model_migration_copy_to_plain_text(©) ); } #[tokio::test] async fn transcript_selection_copy_includes_offscreen_lines() { let mut app = make_test_app().await; app.transcript_cells = vec![Arc::new(AgentMessageCell::new( vec![ Line::from("one"), Line::from("two"), Line::from("three"), Line::from("four"), ], true, ))]; app.transcript_view_top = 2; app.transcript_selection.anchor = Some(TranscriptSelectionPoint { line_index: 0, column: 0, }); app.transcript_selection.head = Some(TranscriptSelectionPoint { line_index: 3, column: u16::MAX, }); let text = crate::transcript_copy::selection_to_copy_text_for_cells( &app.transcript_cells, app.transcript_selection, 40, ) .expect("expected text"); assert_eq!(text, "one\ntwo\nthree\nfour"); } #[tokio::test] async fn model_migration_prompt_respects_hide_flag_and_self_target() { let mut seen = BTreeMap::new(); seen.insert("gpt-5".to_string(), "gpt-5.1".to_string()); assert!(!should_show_model_migration_prompt( "gpt-5", "gpt-5.1", &seen, &all_model_presets() )); assert!(!should_show_model_migration_prompt( "gpt-5.1", "gpt-5.1", &seen, &all_model_presets() )); } #[tokio::test] async fn update_reasoning_effort_updates_config() { let mut app = make_test_app().await; app.config.model_reasoning_effort = Some(ReasoningEffortConfig::Medium); app.chat_widget .set_reasoning_effort(Some(ReasoningEffortConfig::Medium)); app.on_update_reasoning_effort(Some(ReasoningEffortConfig::High)); assert_eq!( app.config.model_reasoning_effort, Some(ReasoningEffortConfig::High) ); assert_eq!( app.chat_widget.config_ref().model_reasoning_effort, Some(ReasoningEffortConfig::High) ); } #[tokio::test] async fn backtrack_selection_with_duplicate_history_targets_unique_turn() { let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await; let user_cell = |text: &str| -> Arc { Arc::new(UserHistoryCell { message: text.to_string(), }) as Arc }; let agent_cell = |text: &str| -> Arc { Arc::new(AgentMessageCell::new( vec![Line::from(text.to_string())], true, )) as Arc }; let make_header = |is_first| { let event = SessionConfiguredEvent { session_id: ThreadId::new(), forked_from_id: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::ReadOnly, cwd: PathBuf::from("/home/user/project"), reasoning_effort: None, history_log_id: 0, history_entry_count: 0, initial_messages: None, rollout_path: PathBuf::new(), }; Arc::new(new_session_info( app.chat_widget.config_ref(), app.current_model.as_str(), event, is_first, )) as Arc }; // Simulate a transcript with duplicated history (e.g., from prior backtracks) // and an edited turn appended after a session header boundary. app.transcript_cells = vec![ make_header(true), user_cell("first question"), agent_cell("answer first"), user_cell("follow-up"), agent_cell("answer follow-up"), make_header(false), user_cell("first question"), agent_cell("answer first"), user_cell("follow-up (edited)"), agent_cell("answer edited"), ]; assert_eq!(user_count(&app.transcript_cells), 2); let base_id = ThreadId::new(); app.chat_widget.handle_codex_event(Event { id: String::new(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id: base_id, forked_from_id: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::ReadOnly, cwd: PathBuf::from("/home/user/project"), reasoning_effort: None, history_log_id: 0, history_entry_count: 0, initial_messages: None, rollout_path: PathBuf::new(), }), }); app.backtrack.base_id = Some(base_id); app.backtrack.primed = true; app.backtrack.nth_user_message = user_count(&app.transcript_cells).saturating_sub(1); let selection = app .confirm_backtrack_from_main() .expect("backtrack selection"); assert_eq!(selection.nth_user_message, 1); assert_eq!(selection.prefill, "follow-up (edited)"); app.apply_backtrack_rollback(selection); let mut rollback_turns = None; while let Ok(op) = op_rx.try_recv() { if let Op::ThreadRollback { num_turns } = op { rollback_turns = Some(num_turns); } } assert_eq!(rollback_turns, Some(1)); } #[tokio::test] async fn transcript_selection_moves_with_scroll() { use ratatui::buffer::Buffer; use ratatui::layout::Rect; let mut app = make_test_app().await; app.transcript_total_lines = 3; let area = Rect { x: 0, y: 0, width: 10, height: 2, }; // Anchor selection to logical line 1, columns 2..4. app.transcript_selection = TranscriptSelection { anchor: Some(TranscriptSelectionPoint { line_index: 1, column: 2, }), head: Some(TranscriptSelectionPoint { line_index: 1, column: 4, }), }; // First render: top of view is line 0, so line 1 maps to the second row. app.transcript_view_top = 0; let mut buf = Buffer::empty(area); for x in 2..area.width { buf[(x, 0)].set_symbol("A"); buf[(x, 1)].set_symbol("B"); } app.apply_transcript_selection(area, &mut buf); // No selection should be applied to the first row when the view is anchored at the top. for x in 0..area.width { let cell = &buf[(x, 0)]; assert!(cell.style().add_modifier.is_empty()); } // After scrolling down by one line, the same logical line should now be // rendered on the first row, and the highlight should move with it. app.transcript_view_top = 1; let mut buf_scrolled = Buffer::empty(area); for x in 2..area.width { buf_scrolled[(x, 0)].set_symbol("B"); buf_scrolled[(x, 1)].set_symbol("C"); } app.apply_transcript_selection(area, &mut buf_scrolled); // After scrolling, the selection should now be applied on the first row rather than the // second. for x in 0..area.width { let cell = &buf_scrolled[(x, 1)]; assert!(cell.style().add_modifier.is_empty()); } } #[tokio::test] async fn transcript_selection_renders_copy_affordance() { use ratatui::buffer::Buffer; use ratatui::layout::Rect; let mut app = make_test_app().await; app.transcript_total_lines = 3; app.transcript_view_top = 0; let area = Rect { x: 0, y: 0, width: 60, height: 3, }; app.transcript_selection = TranscriptSelection { anchor: Some(TranscriptSelectionPoint { line_index: 1, column: 2, }), head: Some(TranscriptSelectionPoint { line_index: 1, column: 6, }), }; let mut buf = Buffer::empty(area); for y in 0..area.height { for x in 2..area.width.saturating_sub(1) { buf[(x, y)].set_symbol("X"); } } app.apply_transcript_selection(area, &mut buf); let anchor = app.transcript_selection.anchor.expect("anchor"); let head = app.transcript_selection.head.expect("head"); app.transcript_copy_ui.render_copy_pill( area, &mut buf, (anchor.line_index, anchor.column), (head.line_index, head.column), app.transcript_view_top, app.transcript_total_lines, ); let mut s = String::new(); for y in area.y..area.bottom() { for x in area.x..area.right() { s.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); } s.push('\n'); } assert!(s.contains("copy")); assert!(s.contains("ctrl + shift + c")); assert!(app.transcript_copy_ui.hit_test(10, 2)); } #[tokio::test] async fn transcript_selection_renders_ctrl_y_copy_affordance_in_vscode_mode() { use ratatui::buffer::Buffer; use ratatui::layout::Rect; let mut app = make_test_app().await; app.transcript_copy_ui = TranscriptCopyUi::new_with_shortcut(CopySelectionShortcut::CtrlY); app.transcript_total_lines = 3; app.transcript_view_top = 0; let area = Rect { x: 0, y: 0, width: 60, height: 3, }; app.transcript_selection = TranscriptSelection { anchor: Some(TranscriptSelectionPoint { line_index: 1, column: 2, }), head: Some(TranscriptSelectionPoint { line_index: 1, column: 6, }), }; let mut buf = Buffer::empty(area); for y in 0..area.height { for x in 2..area.width.saturating_sub(1) { buf[(x, y)].set_symbol("X"); } } app.apply_transcript_selection(area, &mut buf); let anchor = app.transcript_selection.anchor.expect("anchor"); let head = app.transcript_selection.head.expect("head"); app.transcript_copy_ui.render_copy_pill( area, &mut buf, (anchor.line_index, anchor.column), (head.line_index, head.column), app.transcript_view_top, app.transcript_total_lines, ); let mut s = String::new(); for y in area.y..area.bottom() { for x in area.x..area.right() { s.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); } s.push('\n'); } assert!(s.contains("copy")); assert!(s.contains("ctrl + y")); assert!(!s.contains("ctrl + shift + c")); assert!(app.transcript_copy_ui.hit_test(10, 2)); } #[tokio::test] async fn transcript_selection_hides_copy_affordance_while_dragging() { use ratatui::buffer::Buffer; use ratatui::layout::Rect; let mut app = make_test_app().await; app.transcript_total_lines = 3; app.transcript_view_top = 0; app.transcript_copy_ui.set_dragging(true); let area = Rect { x: 0, y: 0, width: 60, height: 3, }; app.transcript_selection = TranscriptSelection { anchor: Some(TranscriptSelectionPoint { line_index: 1, column: 2, }), head: Some(TranscriptSelectionPoint { line_index: 1, column: 6, }), }; let mut buf = Buffer::empty(area); for y in 0..area.height { for x in 2..area.width.saturating_sub(1) { buf[(x, y)].set_symbol("X"); } } let anchor = app.transcript_selection.anchor.expect("anchor"); let head = app.transcript_selection.head.expect("head"); app.transcript_copy_ui.render_copy_pill( area, &mut buf, (anchor.line_index, anchor.column), (head.line_index, head.column), app.transcript_view_top, app.transcript_total_lines, ); let mut s = String::new(); for y in area.y..area.bottom() { for x in area.x..area.right() { s.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); } s.push('\n'); } assert!(!s.contains("copy")); assert!(!app.transcript_copy_ui.hit_test(10, 2)); } #[tokio::test] async fn new_session_requests_shutdown_for_previous_conversation() { let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; let conversation_id = ThreadId::new(); let event = SessionConfiguredEvent { session_id: conversation_id, forked_from_id: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::ReadOnly, cwd: PathBuf::from("/home/user/project"), reasoning_effort: None, history_log_id: 0, history_entry_count: 0, initial_messages: None, rollout_path: PathBuf::new(), }; app.chat_widget.handle_codex_event(Event { id: String::new(), msg: EventMsg::SessionConfigured(event), }); while app_event_rx.try_recv().is_ok() {} while op_rx.try_recv().is_ok() {} app.shutdown_current_conversation().await; match op_rx.try_recv() { Ok(Op::Shutdown) => {} Ok(other) => panic!("expected Op::Shutdown, got {other:?}"), Err(_) => panic!("expected shutdown op to be sent"), } } #[tokio::test] async fn session_summary_skip_zero_usage() { assert!(session_summary(TokenUsage::default(), None).is_none()); } #[tokio::test] async fn render_lines_to_ansi_pads_user_rows_to_full_width() { let line: Line<'static> = Line::from("hi"); let lines = vec![line]; let line_meta = vec![TranscriptLineMeta::CellLine { cell_index: 0, line_in_cell: 0, }]; let is_user_cell = vec![true]; let width: u16 = 10; let rendered = crate::transcript_render::render_lines_to_ansi( &lines, &line_meta, &is_user_cell, width, ); assert_eq!(rendered.len(), 1); assert!(rendered[0].contains("hi")); } #[tokio::test] async fn session_summary_includes_resume_hint() { let usage = TokenUsage { input_tokens: 10, output_tokens: 2, total_tokens: 12, ..Default::default() }; let conversation = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap(); let summary = session_summary(usage, Some(conversation)).expect("summary"); assert_eq!( summary.usage_line, "Token usage: total=12 input=10 output=2" ); assert_eq!( summary.resume_command, Some("codex resume 123e4567-e89b-12d3-a456-426614174000".to_string()) ); } }