diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index dc3f05108f9..638cba801ab 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -34,6 +34,7 @@ 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::resume_picker::SessionTarget; use crate::tui; use crate::tui::TuiEvent; use crate::update_action::UpdateAction; @@ -53,6 +54,7 @@ use codex_core::config::types::ApprovalsReviewer; use codex_core::config::types::ModelAvailabilityNuxConfig; use codex_core::config_loader::ConfigLayerStackOrdering; use codex_core::features::Feature; +use codex_core::find_thread_path_by_id_str; 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; @@ -728,6 +730,7 @@ pub(crate) struct App { primary_thread_id: Option, primary_session_configured: Option, pending_primary_events: VecDeque, + pending_async_queue_resume_barriers: usize, } #[derive(Default)] @@ -755,6 +758,28 @@ fn normalize_harness_overrides_for_cwd( } impl App { + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + fn begin_async_queue_resume_barrier(&mut self) { + self.pending_async_queue_resume_barriers += 1; + } + + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + fn finish_async_queue_resume_barrier(&mut self) { + if self.pending_async_queue_resume_barriers == 0 { + tracing::warn!("finished async queue-resume barrier with no pending barrier"); + return; + } + self.pending_async_queue_resume_barriers -= 1; + } + + fn maybe_resume_queued_inputs_after_app_events(&mut self, app_events_drained: bool) { + if !app_events_drained || self.pending_async_queue_resume_barriers != 0 { + return; + } + + self.chat_widget.maybe_resume_queued_inputs_when_idle(); + } + pub fn chatwidget_init_for_forked_or_resumed_thread( &self, tui: &mut tui::Tui, @@ -834,6 +859,104 @@ impl App { } } + async fn resume_session_target( + &mut self, + tui: &mut tui::Tui, + target_session: SessionTarget, + ) -> Result { + let current_cwd = self.config.cwd.clone(); + let resume_cwd = match crate::resolve_cwd_for_resume_or_fork( + tui, + &self.config, + ¤t_cwd, + target_session.thread_id, + &target_session.path, + CwdPromptAction::Resume, + true, + ) + .await? + { + crate::ResolveCwdOutcome::Continue(Some(cwd)) => cwd, + crate::ResolveCwdOutcome::Continue(None) => current_cwd.clone(), + crate::ResolveCwdOutcome::Exit => { + return Ok(self.handle_exit_mode(ExitMode::ShutdownFirst)); + } + }; + let mut resume_config = match self + .rebuild_config_for_resume_or_fallback(¤t_cwd, resume_cwd) + .await + { + Ok(cfg) => cfg, + Err(err) => { + self.chat_widget.add_error_message(format!( + "Failed to rebuild configuration for resume: {err}" + )); + return Ok(AppRunControl::Continue); + } + }; + self.apply_runtime_policy_overrides(&mut resume_config); + let summary = session_summary( + self.chat_widget.token_usage(), + self.chat_widget.thread_id(), + self.chat_widget.thread_name(), + ); + match self + .server + .resume_thread_from_rollout( + resume_config.clone(), + target_session.path.clone(), + self.auth_manager.clone(), + None, + ) + .await + { + Ok(resumed) => { + let input_state = self.chat_widget.capture_thread_input_state(); + self.shutdown_current_thread().await; + self.config = resume_config; + tui.set_notification_method(self.config.tui_notification_method); + self.file_search.update_search_dir(self.config.cwd.clone()); + let init = + self.chatwidget_init_for_forked_or_resumed_thread(tui, self.config.clone()); + self.chat_widget = + ChatWidget::new_from_existing(init, resumed.thread, resumed.session_configured); + self.reset_thread_event_state(); + 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); + } + self.restore_input_state_after_thread_switch(input_state); + } + Err(err) => { + let path_display = target_session.path.display(); + self.chat_widget.add_error_message(format!( + "Failed to resume session from {path_display}: {err}" + )); + } + } + Ok(AppRunControl::Continue) + } + + async fn resume_session_by_thread_id( + &mut self, + tui: &mut tui::Tui, + thread_id: ThreadId, + ) -> Result { + let Some(path) = + find_thread_path_by_id_str(&self.config.codex_home, &thread_id.to_string()).await? + else { + self.chat_widget + .add_error_message(format!("No saved session found for thread {thread_id}.")); + return Ok(AppRunControl::Continue); + }; + self.resume_session_target(tui, SessionTarget { path, thread_id }) + .await + } + fn apply_runtime_policy_overrides(&mut self, config: &mut Config) { if let Some(policy) = self.runtime_approval_policy_override.as_ref() && let Err(err) = config.permissions.approval_policy.set(*policy) @@ -1661,7 +1784,13 @@ impl App { description: Some(uuid.clone()), is_current: self.active_thread_id == Some(*thread_id), actions: vec![Box::new(move |tx| { - tx.send(AppEvent::SelectAgentThread(id)); + tx.send(AppEvent::HandleSlashCommandDraft( + crate::slash_command_invocation::SlashCommandInvocation::with_args( + crate::slash_command::SlashCommand::Agent, + [id.to_string()], + ) + .into_user_message(), + )); })], dismiss_on_select: true, search_value: Some(format!("{name} {uuid}")), @@ -1789,6 +1918,11 @@ impl App { self.sync_active_agent_label(); } + fn restore_input_state_after_thread_switch(&mut self, input_state: Option) { + self.chat_widget.restore_thread_input_state(input_state); + self.chat_widget.drain_queued_inputs_until_blocked(); + } + async fn start_fresh_session_with_summary_hint(&mut self, tui: &mut tui::Tui) { // Start a fresh in-memory session while preserving resumability via persisted rollout // history. @@ -1801,6 +1935,7 @@ impl App { self.chat_widget.thread_id(), self.chat_widget.thread_name(), ); + let input_state = self.chat_widget.capture_thread_input_state(); self.shutdown_current_thread().await; let report = self .server @@ -1840,6 +1975,7 @@ impl App { } self.chat_widget.add_plain_history_lines(lines); } + self.restore_input_state_after_thread_switch(input_state); tui.frame_requester().schedule_frame(); } @@ -1919,7 +2055,7 @@ impl App { self.chat_widget .set_queue_autosend_suppressed(/*suppressed*/ false); if resume_restored_queue { - self.chat_widget.maybe_send_next_queued_input(); + self.chat_widget.drain_queued_inputs_until_blocked(); } self.refresh_status_line(); } @@ -2191,6 +2327,7 @@ impl App { primary_thread_id: None, primary_session_configured: None, pending_primary_events: VecDeque::new(), + pending_async_queue_resume_barriers: 0, }; // On startup, if Agent mode (workspace-write) or ReadOnly is active, warn about world-writable dirs on Windows. @@ -2209,6 +2346,7 @@ impl App { .hide_world_writable_warning .unwrap_or(false); if should_check { + app.begin_async_queue_resume_barrier(); let cwd = app.config.cwd.clone(); let env_map: std::collections::HashMap = std::env::vars().collect(); let tx = app.app_event_tx.clone(); @@ -2319,6 +2457,11 @@ impl App { ) { waiting_for_initial_session_configured = false; } + // Some replayed slash commands pause queue draining until their app-side updates, + // popup flows, or async follow-up work settle. Only resume once the app-event + // queue is fully drained and no background slash-command completions are still + // pending, so later queued input cannot interleave with those updates. + app.maybe_resume_queued_inputs_after_app_events(app_event_rx.is_empty()); match control { AppRunControl::Continue => {} AppRunControl::Exit(reason) => break Ok(reason), @@ -2431,87 +2574,10 @@ impl App { .await? { SessionSelection::Resume(target_session) => { - let current_cwd = self.config.cwd.clone(); - let resume_cwd = match crate::resolve_cwd_for_resume_or_fork( - tui, - &self.config, - ¤t_cwd, - target_session.thread_id, - &target_session.path, - CwdPromptAction::Resume, - /*allow_prompt*/ true, - ) - .await? - { - crate::ResolveCwdOutcome::Continue(Some(cwd)) => cwd, - crate::ResolveCwdOutcome::Continue(None) => current_cwd.clone(), - crate::ResolveCwdOutcome::Exit => { - return Ok(AppRunControl::Exit(ExitReason::UserRequested)); - } - }; - let mut resume_config = match self - .rebuild_config_for_resume_or_fallback(¤t_cwd, resume_cwd) - .await - { - Ok(cfg) => cfg, - Err(err) => { - self.chat_widget.add_error_message(format!( - "Failed to rebuild configuration for resume: {err}" - )); - return Ok(AppRunControl::Continue); - } - }; - self.apply_runtime_policy_overrides(&mut resume_config); - let summary = session_summary( - self.chat_widget.token_usage(), - self.chat_widget.thread_id(), - self.chat_widget.thread_name(), + self.chat_widget.handle_serialized_slash_command( + ChatWidget::resume_selection_draft(&target_session), ); - match self - .server - .resume_thread_from_rollout( - resume_config.clone(), - target_session.path.clone(), - self.auth_manager.clone(), - /*parent_trace*/ None, - ) - .await - { - Ok(resumed) => { - self.shutdown_current_thread().await; - self.config = resume_config; - tui.set_notification_method(self.config.tui_notification_method); - self.file_search.update_search_dir(self.config.cwd.clone()); - let init = self.chatwidget_init_for_forked_or_resumed_thread( - tui, - self.config.clone(), - ); - self.chat_widget = ChatWidget::new_from_existing( - init, - resumed.thread, - resumed.session_configured, - ); - self.reset_thread_event_state(); - 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 = target_session.path.display(); - self.chat_widget.add_error_message(format!( - "Failed to resume session from {path_display}: {err}" - )); - } - } + self.refresh_status_line(); } SessionSelection::Exit | SessionSelection::StartFresh @@ -2521,6 +2587,12 @@ impl App { // Leaving alt-screen may blank the inline viewport; force a redraw either way. tui.frame_requester().schedule_frame(); } + AppEvent::ResumeSession(thread_id) => { + return self.resume_session_by_thread_id(tui, thread_id).await; + } + AppEvent::ResumeSessionTarget(target_session) => { + return self.resume_session_target(tui, target_session).await; + } AppEvent::ForkCurrentSession => { self.session_telemetry.counter( "codex.thread.fork", @@ -2552,6 +2624,7 @@ impl App { .await { Ok(forked) => { + let input_state = self.chat_widget.capture_thread_input_state(); self.shutdown_current_thread().await; let init = self.chatwidget_init_for_forked_or_resumed_thread( tui, @@ -2575,6 +2648,7 @@ impl App { } self.chat_widget.add_plain_history_lines(lines); } + self.restore_input_state_after_thread_switch(input_state); } Err(err) => { let path_display = path.display(); @@ -2740,6 +2814,11 @@ impl App { self.chat_widget.set_model(&model); self.refresh_status_line(); } + AppEvent::HandleSlashCommandDraft(draft) => { + self.chat_widget.handle_serialized_slash_command(draft); + self.refresh_status_line(); + } + AppEvent::BottomPaneViewCompleted => {} AppEvent::UpdateCollaborationMode(mask) => { self.chat_widget.set_collaboration_mask(mask); self.refresh_status_line(); @@ -2769,12 +2848,14 @@ impl App { } AppEvent::OpenWorldWritableWarningConfirmation { preset, + approvals_reviewer, sample_paths, extra_count, failed_scan, } => { self.chat_widget.open_world_writable_warning_confirmation( preset, + approvals_reviewer, sample_paths, extra_count, failed_scan, @@ -2794,10 +2875,17 @@ impl App { self.launch_external_editor(tui).await; } } - AppEvent::OpenWindowsSandboxEnablePrompt { preset } => { - self.chat_widget.open_windows_sandbox_enable_prompt(preset); + AppEvent::OpenWindowsSandboxEnablePrompt { + preset, + approvals_reviewer, + } => { + self.chat_widget + .open_windows_sandbox_enable_prompt(preset, approvals_reviewer); } - AppEvent::OpenWindowsSandboxFallbackPrompt { preset } => { + AppEvent::OpenWindowsSandboxFallbackPrompt { + preset, + approvals_reviewer, + } => { self.session_telemetry.counter( "codex.windows_sandbox.fallback_prompt_shown", /*inc*/ 1, @@ -2812,9 +2900,12 @@ impl App { ); } self.chat_widget - .open_windows_sandbox_fallback_prompt(preset); + .open_windows_sandbox_fallback_prompt(preset, approvals_reviewer); } - AppEvent::BeginWindowsSandboxElevatedSetup { preset } => { + AppEvent::BeginWindowsSandboxElevatedSetup { + preset, + approvals_reviewer, + } => { #[cfg(target_os = "windows")] { let policy = preset.sandbox.clone(); @@ -2832,12 +2923,14 @@ impl App { tx.send(AppEvent::EnableWindowsSandboxForAgentMode { preset, mode: WindowsSandboxEnableMode::Elevated, + approvals_reviewer, }); return Ok(AppRunControl::Continue); } self.chat_widget.show_windows_sandbox_setup_status(); self.windows_sandbox.setup_started_at = Some(Instant::now()); + self.begin_async_queue_resume_barrier(); let session_telemetry = self.session_telemetry.clone(); tokio::task::spawn_blocking(move || { let result = codex_core::windows_sandbox::run_elevated_setup( @@ -2854,9 +2947,10 @@ impl App { 1, &[], ); - AppEvent::EnableWindowsSandboxForAgentMode { + AppEvent::WindowsSandboxElevatedSetupCompleted { preset: preset.clone(), - mode: WindowsSandboxEnableMode::Elevated, + approvals_reviewer, + setup_succeeded: true, } } Err(err) => { @@ -2888,7 +2982,11 @@ impl App { error = %err, "failed to run elevated Windows sandbox setup" ); - AppEvent::OpenWindowsSandboxFallbackPrompt { preset } + AppEvent::WindowsSandboxElevatedSetupCompleted { + preset, + approvals_reviewer, + setup_succeeded: false, + } } }; tx.send(event); @@ -2896,10 +2994,40 @@ impl App { } #[cfg(not(target_os = "windows"))] { - let _ = preset; + let _ = (preset, approvals_reviewer); } } - AppEvent::BeginWindowsSandboxLegacySetup { preset } => { + AppEvent::WindowsSandboxElevatedSetupCompleted { + preset, + approvals_reviewer, + setup_succeeded, + } => { + #[cfg(target_os = "windows")] + { + self.finish_async_queue_resume_barrier(); + let event = if setup_succeeded { + AppEvent::EnableWindowsSandboxForAgentMode { + preset, + mode: WindowsSandboxEnableMode::Elevated, + approvals_reviewer, + } + } else { + AppEvent::OpenWindowsSandboxFallbackPrompt { + preset, + approvals_reviewer, + } + }; + self.app_event_tx.send(event); + } + #[cfg(not(target_os = "windows"))] + { + let _ = (preset, approvals_reviewer, setup_succeeded); + } + } + AppEvent::BeginWindowsSandboxLegacySetup { + preset, + approvals_reviewer, + } => { #[cfg(target_os = "windows")] { let policy = preset.sandbox.clone(); @@ -2909,36 +3037,70 @@ impl App { std::env::vars().collect(); let codex_home = self.config.codex_home.clone(); let tx = self.app_event_tx.clone(); - let session_telemetry = self.session_telemetry.clone(); - self.chat_widget.show_windows_sandbox_setup_status(); + self.begin_async_queue_resume_barrier(); tokio::task::spawn_blocking(move || { - if let Err(err) = codex_core::windows_sandbox::run_legacy_setup_preflight( + let preset_for_error = preset.clone(); + let result = codex_core::windows_sandbox::run_legacy_setup_preflight( &policy, policy_cwd.as_path(), command_cwd.as_path(), &env_map, codex_home.as_path(), - ) { - session_telemetry.counter( - "codex.windows_sandbox.legacy_setup_preflight_failed", - 1, - &[], - ); - tracing::warn!( - error = %err, - "failed to preflight non-admin Windows sandbox setup" - ); - } - tx.send(AppEvent::EnableWindowsSandboxForAgentMode { - preset, - mode: WindowsSandboxEnableMode::Legacy, - }); + ); + let event = match result { + Ok(()) => AppEvent::WindowsSandboxLegacySetupCompleted { + preset, + approvals_reviewer, + error: None, + }, + Err(err) => { + tracing::error!( + error = %err, + "failed to run legacy Windows sandbox setup preflight" + ); + AppEvent::WindowsSandboxLegacySetupCompleted { + preset: preset_for_error, + approvals_reviewer, + error: Some(err.to_string()), + } + } + }; + tx.send(event); }); } #[cfg(not(target_os = "windows"))] { - let _ = preset; + let _ = (preset, approvals_reviewer); + } + } + AppEvent::WindowsSandboxLegacySetupCompleted { + preset, + approvals_reviewer, + error, + } => { + #[cfg(target_os = "windows")] + { + self.finish_async_queue_resume_barrier(); + match error { + None => { + self.app_event_tx + .send(AppEvent::EnableWindowsSandboxForAgentMode { + preset, + mode: WindowsSandboxEnableMode::Legacy, + approvals_reviewer, + }); + } + Some(err) => { + self.chat_widget.add_error_message(format!( + "Failed to enable the Windows sandbox feature: {err}" + )); + } + } + } + #[cfg(not(target_os = "windows"))] + { + let _ = (preset, approvals_reviewer, error); } } AppEvent::BeginWindowsSandboxGrantReadRoot { path } => { @@ -2998,7 +3160,11 @@ impl App { )); } }, - AppEvent::EnableWindowsSandboxForAgentMode { preset, mode } => { + AppEvent::EnableWindowsSandboxForAgentMode { + preset, + mode, + approvals_reviewer, + } => { #[cfg(target_os = "windows")] { self.chat_widget.clear_windows_sandbox_setup_status(); @@ -3054,6 +3220,7 @@ impl App { self.app_event_tx.send( AppEvent::OpenWorldWritableWarningConfirmation { preset: Some(preset.clone()), + approvals_reviewer: Some(approvals_reviewer), sample_paths, extra_count, failed_scan, @@ -3064,7 +3231,7 @@ impl App { Op::OverrideTurnContext { cwd: None, approval_policy: Some(preset.approval), - approvals_reviewer: Some(self.config.approvals_reviewer), + approvals_reviewer: Some(approvals_reviewer), sandbox_policy: Some(preset.sandbox.clone()), windows_sandbox_level: Some(windows_sandbox_level), model: None, @@ -3079,6 +3246,8 @@ impl App { .send(AppEvent::UpdateAskForApprovalPolicy(preset.approval)); self.app_event_tx .send(AppEvent::UpdateSandboxPolicy(preset.sandbox.clone())); + self.app_event_tx + .send(AppEvent::UpdateApprovalsReviewer(approvals_reviewer)); let _ = mode; self.chat_widget.add_plain_history_lines(vec![ Line::from(vec!["• ".dim(), "Sandbox ready".into()]), @@ -3103,7 +3272,7 @@ impl App { } #[cfg(not(target_os = "windows"))] { - let _ = (preset, mode); + let _ = (preset, mode, approvals_reviewer); } } AppEvent::PersistModelSelection { model, effort } => { @@ -3323,6 +3492,7 @@ impl App { && policy_is_workspace_write_or_ro && !self.chat_widget.world_writable_warning_hidden(); if should_check { + self.begin_async_queue_resume_barrier(); let cwd = self.config.cwd.clone(); let env_map: std::collections::HashMap = std::env::vars().collect(); @@ -3495,15 +3665,16 @@ impl App { AppEvent::OpenApprovalsPopup => { self.chat_widget.open_approvals_popup(); } + AppEvent::WorldWritableScanCompleted => { + #[cfg(target_os = "windows")] + self.finish_async_queue_resume_barrier(); + } AppEvent::OpenAgentPicker => { self.open_agent_picker().await; } AppEvent::SelectAgentThread(thread_id) => { self.select_agent_thread(tui, thread_id).await?; } - AppEvent::OpenSkillsList => { - self.chat_widget.open_skills_list(); - } AppEvent::OpenManageSkillsPopup => { self.chat_widget.open_manage_skills_popup(); } @@ -4186,11 +4357,13 @@ impl App { // Scan failed: warn without examples. tx.send(AppEvent::OpenWorldWritableWarningConfirmation { preset: None, + approvals_reviewer: None, sample_paths: Vec::new(), extra_count: 0usize, failed_scan: true, }); } + tx.send(AppEvent::WorldWritableScanCompleted); }); } } @@ -4201,6 +4374,7 @@ mod tests { use crate::app_backtrack::BacktrackSelection; use crate::app_backtrack::BacktrackState; use crate::app_backtrack::user_count; + use crate::chatwidget::UserMessage; use crate::chatwidget::tests::make_chatwidget_manual_with_sender; use crate::chatwidget::tests::set_chatgpt_auth; use crate::file_search::FileSearchManager; @@ -4701,6 +4875,60 @@ mod tests { } } + #[tokio::test] + async fn thread_switch_restores_and_drains_queued_follow_up() { + let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + let session_configured = Event { + id: "session-configured".to_string(), + msg: EventMsg::SessionConfigured(SessionConfiguredEvent { + session_id: thread_id, + forked_from_id: None, + thread_name: None, + model: "gpt-test".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: ApprovalsReviewer::User, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + cwd: PathBuf::from("/tmp/project"), + reasoning_effort: None, + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: Some(PathBuf::new()), + }), + }; + app.chat_widget + .apply_external_edit("queued follow-up".to_string()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + let input_state = app + .chat_widget + .capture_thread_input_state() + .expect("expected queued follow-up state"); + + let (chat_widget, _app_event_tx, _rx, mut new_op_rx) = + make_chatwidget_manual_with_sender().await; + app.chat_widget = chat_widget; + app.chat_widget.handle_codex_event(session_configured); + while new_op_rx.try_recv().is_ok() {} + + app.restore_input_state_after_thread_switch(Some(input_state)); + + match next_user_turn_op(&mut new_op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "queued follow-up".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected queued follow-up submission, got {other:?}"), + } + } + #[tokio::test] async fn replay_only_thread_keeps_restored_queue_visible() { let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; @@ -5944,10 +6172,12 @@ guardian_approval = true app.chat_widget .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_matches!( - app_event_rx.try_recv(), - Ok(AppEvent::SelectAgentThread(selected_thread_id)) if selected_thread_id == thread_id - ); + match app_event_rx.try_recv() { + Ok(AppEvent::HandleSlashCommandDraft(draft)) => { + assert_eq!(draft, UserMessage::from(format!("/agent {thread_id}"))); + } + other => panic!("expected serialized agent slash draft, got {other:?}"), + } Ok(()) } @@ -6404,6 +6634,7 @@ guardian_approval = true primary_thread_id: None, primary_session_configured: None, pending_primary_events: VecDeque::new(), + pending_async_queue_resume_barriers: 0, } } @@ -6464,6 +6695,7 @@ guardian_approval = true primary_thread_id: None, primary_session_configured: None, pending_primary_events: VecDeque::new(), + pending_async_queue_resume_barriers: 0, }, rx, op_rx, @@ -7478,6 +7710,208 @@ guardian_approval = true } } + #[tokio::test] + async fn queued_personality_selection_resumes_followup_after_app_events_drain() { + let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; + app.chat_widget.set_model("gpt-5.2-codex"); + app.chat_widget + .set_feature_enabled(Feature::Personality, true); + app.chat_widget.handle_codex_event(Event { + id: "configured".into(), + msg: EventMsg::SessionConfigured(SessionConfiguredEvent { + session_id: ThreadId::new(), + forked_from_id: None, + thread_name: None, + model: "gpt-5.2-codex".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: ApprovalsReviewer::User, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + cwd: PathBuf::from("/home/user/project"), + reasoning_effort: Some(ReasoningEffortConfig::default()), + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: None, + }), + }); + app.chat_widget.handle_codex_event(Event { + id: "turn-started".into(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), + }); + while app_event_rx.try_recv().is_ok() {} + while op_rx.try_recv().is_ok() {} + + app.chat_widget + .handle_serialized_slash_command(UserMessage::from("/personality pragmatic")); + app.chat_widget + .set_composer_text("followup".to_string(), Vec::new(), Vec::new()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + + app.chat_widget.handle_codex_event(Event { + id: "turn-complete".into(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-1".to_string(), + last_agent_message: None, + }), + }); + + assert_eq!( + app.chat_widget.queued_user_message_texts(), + vec!["followup".to_string()] + ); + assert!( + op_rx.try_recv().is_err(), + "queued follow-up should not submit before app events drain" + ); + + loop { + match app_event_rx.try_recv() { + Ok(AppEvent::CodexOp(Op::OverrideTurnContext { + personality: Some(Personality::Pragmatic), + .. + })) => continue, + Ok(AppEvent::UpdatePersonality(Personality::Pragmatic)) => { + app.on_update_personality(Personality::Pragmatic); + break; + } + Ok(AppEvent::PersistPersonalitySelection { + personality: Personality::Pragmatic, + }) => continue, + Ok(_) => continue, + Err(TryRecvError::Empty) => panic!("expected personality update events"), + Err(TryRecvError::Disconnected) => panic!("expected personality update events"), + } + } + + app.maybe_resume_queued_inputs_after_app_events(true); + + match next_user_turn_op(&mut op_rx) { + Op::UserTurn { + items, + personality: Some(Personality::Pragmatic), + .. + } => assert_eq!( + items, + vec![UserInput::Text { + text: "followup".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected Op::UserTurn with pragmatic personality, got {other:?}"), + } + } + + #[tokio::test] + async fn queued_followup_waits_for_pending_async_resume_barrier() { + let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; + app.chat_widget.set_model("gpt-5.2-codex"); + app.chat_widget + .set_feature_enabled(Feature::Personality, true); + app.chat_widget.handle_codex_event(Event { + id: "configured".into(), + msg: EventMsg::SessionConfigured(SessionConfiguredEvent { + session_id: ThreadId::new(), + forked_from_id: None, + thread_name: None, + model: "gpt-5.2-codex".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: ApprovalsReviewer::User, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + cwd: PathBuf::from("/home/user/project"), + reasoning_effort: Some(ReasoningEffortConfig::default()), + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: None, + }), + }); + app.chat_widget.handle_codex_event(Event { + id: "turn-started".into(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), + }); + while app_event_rx.try_recv().is_ok() {} + while op_rx.try_recv().is_ok() {} + + app.chat_widget + .handle_serialized_slash_command(UserMessage::from("/personality pragmatic")); + app.chat_widget + .set_composer_text("followup".to_string(), Vec::new(), Vec::new()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + + app.chat_widget.handle_codex_event(Event { + id: "turn-complete".into(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-1".to_string(), + last_agent_message: None, + }), + }); + + loop { + match app_event_rx.try_recv() { + Ok(AppEvent::CodexOp(Op::OverrideTurnContext { + personality: Some(Personality::Pragmatic), + .. + })) => continue, + Ok(AppEvent::UpdatePersonality(Personality::Pragmatic)) => { + app.on_update_personality(Personality::Pragmatic); + break; + } + Ok(AppEvent::PersistPersonalitySelection { + personality: Personality::Pragmatic, + }) => continue, + Ok(_) => continue, + Err(TryRecvError::Empty) => panic!("expected personality update events"), + Err(TryRecvError::Disconnected) => panic!("expected personality update events"), + } + } + + app.pending_async_queue_resume_barriers = 1; + app.maybe_resume_queued_inputs_after_app_events(true); + + assert_eq!( + app.chat_widget.queued_user_message_texts(), + vec!["followup".to_string()] + ); + assert!( + op_rx.try_recv().is_err(), + "queued follow-up should stay queued while async replay barriers are pending" + ); + + app.pending_async_queue_resume_barriers = 0; + app.maybe_resume_queued_inputs_after_app_events(true); + + match next_user_turn_op(&mut op_rx) { + Op::UserTurn { + items, + personality: Some(Personality::Pragmatic), + .. + } => assert_eq!( + items, + vec![UserInput::Text { + text: "followup".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected Op::UserTurn with pragmatic personality, got {other:?}"), + } + } + #[tokio::test] async fn shutdown_first_exit_returns_immediate_exit_when_shutdown_submit_fails() { let mut app = make_test_app().await; diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index e2ed046690b..32dc92806ee 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -20,6 +20,7 @@ use codex_utils_approval_presets::ApprovalPreset; use crate::bottom_pane::ApprovalRequest; use crate::bottom_pane::StatusLineItem; +use crate::chatwidget::UserMessage; use crate::history_cell::HistoryCell; use codex_core::config::types::ApprovalsReviewer; @@ -97,6 +98,12 @@ pub(crate) enum AppEvent { /// Open the resume picker inside the running TUI session. OpenResumePicker, + /// Resume a saved session by thread id. + ResumeSession(ThreadId), + + /// Resume a saved session using the exact picker-selected rollout target. + ResumeSessionTarget(crate::resume_picker::SessionTarget), + /// Fork the current session into a new thread. ForkCurrentSession, @@ -182,6 +189,14 @@ pub(crate) enum AppEvent { /// Update the current model slug in the running app and widget. UpdateModel(String), + /// Evaluate a serialized built-in slash-command draft. If a task is currently running, the + /// draft is queued and replayed later through the same path as queued composer input. + HandleSlashCommandDraft(UserMessage), + + /// Notify the app that an interactive bottom-pane view finished, so queued replay can resume + /// once the UI is idle again. + BottomPaneViewCompleted, + /// Update the active collaboration mask in the running app and widget. UpdateCollaborationMode(CollaborationModeMask), @@ -253,6 +268,7 @@ pub(crate) enum AppEvent { #[cfg_attr(not(target_os = "windows"), allow(dead_code))] OpenWorldWritableWarningConfirmation { preset: Option, + approvals_reviewer: Option, /// Up to 3 sample world-writable directories to display in the warning. sample_paths: Vec, /// If there are more than `sample_paths`, this carries the remaining count. @@ -265,24 +281,44 @@ pub(crate) enum AppEvent { #[cfg_attr(not(target_os = "windows"), allow(dead_code))] OpenWindowsSandboxEnablePrompt { preset: ApprovalPreset, + approvals_reviewer: ApprovalsReviewer, }, /// Open the Windows sandbox fallback prompt after declining or failing elevation. #[cfg_attr(not(target_os = "windows"), allow(dead_code))] OpenWindowsSandboxFallbackPrompt { preset: ApprovalPreset, + approvals_reviewer: ApprovalsReviewer, }, /// Begin the elevated Windows sandbox setup flow. #[cfg_attr(not(target_os = "windows"), allow(dead_code))] BeginWindowsSandboxElevatedSetup { preset: ApprovalPreset, + approvals_reviewer: ApprovalsReviewer, + }, + + /// Result of the elevated Windows sandbox setup flow. + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + WindowsSandboxElevatedSetupCompleted { + preset: ApprovalPreset, + approvals_reviewer: ApprovalsReviewer, + setup_succeeded: bool, }, /// Begin the non-elevated Windows sandbox setup flow. #[cfg_attr(not(target_os = "windows"), allow(dead_code))] BeginWindowsSandboxLegacySetup { preset: ApprovalPreset, + approvals_reviewer: ApprovalsReviewer, + }, + + /// Result of the non-elevated Windows sandbox setup flow. + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + WindowsSandboxLegacySetupCompleted { + preset: ApprovalPreset, + approvals_reviewer: ApprovalsReviewer, + error: Option, }, /// Begin a non-elevated grant of read access for an additional directory. @@ -298,11 +334,16 @@ pub(crate) enum AppEvent { error: Option, }, + /// Result of the asynchronous Windows world-writable scan. + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + WorldWritableScanCompleted, + /// Enable the Windows sandbox feature and switch to Agent mode. #[cfg_attr(not(target_os = "windows"), allow(dead_code))] EnableWindowsSandboxForAgentMode { preset: ApprovalPreset, mode: WindowsSandboxEnableMode, + approvals_reviewer: ApprovalsReviewer, }, /// Update the Windows sandbox feature mode without changing approval presets. @@ -361,9 +402,6 @@ pub(crate) enum AppEvent { /// Re-open the approval presets popup. OpenApprovalsPopup, - /// Open the skills list popup. - OpenSkillsList, - /// Open the skills enable/disable picker. OpenManageSkillsPopup, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 6aa250b5290..ea24350b826 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -50,8 +50,8 @@ //! //! The numeric auto-submit path used by the slash popup performs the same pending-paste expansion //! and attachment pruning, and clears pending paste state on success. -//! Slash commands with arguments (like `/plan` and `/review`) reuse the same preparation path so -//! pasted content and text elements are preserved when extracting args. +//! Slash commands with arguments (like `/model`, `/plan`, and `/review`) reuse the same +//! preparation path so pasted content and text elements are preserved when extracting args. //! //! # Remote Image Rows (Up/Down/Delete) //! @@ -572,23 +572,6 @@ impl ChatComposer { self.sync_popups(); } - pub(crate) fn take_mention_bindings(&mut self) -> Vec { - let elements = self.current_mention_elements(); - let mut ordered = Vec::new(); - for (id, mention) in elements { - if let Some(binding) = self.mention_bindings.remove(&id) - && binding.mention == mention - { - ordered.push(MentionBinding { - mention: binding.mention, - path: binding.path, - }); - } - } - self.mention_bindings.clear(); - ordered - } - pub fn set_collaboration_modes_enabled(&mut self, enabled: bool) { self.collaboration_modes_enabled = enabled; } @@ -2532,9 +2515,6 @@ impl ChatComposer { && let Some(cmd) = slash_commands::find_builtin_command(name, self.builtin_command_flags()) { - if self.reject_slash_command_if_unavailable(cmd) { - return Some(InputResult::None); - } self.textarea.set_text_clearing_elements(""); Some(InputResult::Command(cmd)) } else { @@ -2560,13 +2540,6 @@ impl ChatComposer { let cmd = slash_commands::find_builtin_command(name, self.builtin_command_flags())?; - if !cmd.supports_inline_args() { - return None; - } - if self.reject_slash_command_if_unavailable(cmd) { - return Some(InputResult::None); - } - let mut args_elements = Self::slash_command_args_elements(rest, rest_offset, &self.textarea.text_elements()); let trimmed_rest = rest.trim(); @@ -2580,10 +2553,10 @@ impl ChatComposer { /// Expand pending placeholders and extract normalized inline-command args. /// - /// Inline-arg commands are initially dispatched using the raw draft so command rejection does - /// not consume user input. Once a command is accepted, this helper performs the usual - /// submission preparation (paste expansion, element trimming) and rebases element ranges from - /// full-text offsets to command-arg offsets. + /// Inline-arg commands are initially dispatched using the raw draft so command-specific + /// handling can decide whether to consume the input. Once a command is accepted, this helper + /// performs the usual submission preparation (paste expansion, element trimming) and rebases + /// element ranges from full-text offsets to command-arg offsets. pub(crate) fn prepare_inline_args_submission( &mut self, record_history: bool, @@ -2600,20 +2573,6 @@ impl ChatComposer { Some((trimmed_rest.to_string(), args_elements)) } - fn reject_slash_command_if_unavailable(&self, cmd: SlashCommand) -> bool { - if !self.is_task_running || cmd.available_during_task() { - return false; - } - let message = format!( - "'/{}' is disabled while a task is in progress.", - cmd.command() - ); - self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( - history_cell::new_error_event(message), - ))); - true - } - /// Translate full-text element ranges into command-argument ranges. /// /// `rest_offset` is the byte offset where `rest` begins in the full text. @@ -6432,6 +6391,69 @@ mod tests { }); } + #[test] + fn slash_popup_help_first_for_root_ui() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + type_chars_humanlike(&mut composer, &['/']); + + let mut terminal = match Terminal::new(TestBackend::new(60, 8)) { + Ok(t) => t, + Err(e) => panic!("Failed to create terminal: {e}"), + }; + terminal + .draw(|f| composer.render(f.area(), f.buffer_mut())) + .unwrap_or_else(|e| panic!("Failed to draw composer: {e}")); + + if cfg!(target_os = "windows") { + insta::with_settings!({ snapshot_suffix => "windows" }, { + insta::assert_snapshot!("slash_popup_root", terminal.backend()); + }); + } else { + insta::assert_snapshot!("slash_popup_root", terminal.backend()); + } + } + + #[test] + fn slash_popup_help_first_for_root_logic() { + use super::super::command_popup::CommandItem; + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + type_chars_humanlike(&mut composer, &['/']); + + match &composer.active_popup { + ActivePopup::Command(popup) => match popup.selected_item() { + Some(CommandItem::Builtin(cmd)) => { + assert_eq!(cmd.command(), "help") + } + Some(CommandItem::UserPrompt(_)) => { + panic!("unexpected prompt selected for '/'") + } + None => panic!("no selected command for '/'"), + }, + _ => panic!("slash popup not active after typing '/'"), + } + } + #[test] fn slash_popup_model_first_for_mo_ui() { use ratatui::Terminal; @@ -6688,7 +6710,7 @@ mod tests { } #[test] - fn slash_command_disabled_while_task_running_keeps_text() { + fn slash_command_while_task_running_still_dispatches() { use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; @@ -6710,24 +6732,16 @@ mod tests { let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!(InputResult::None, result); + assert_eq!( + InputResult::CommandWithArgs( + SlashCommand::Review, + "these changes".to_string(), + Vec::new(), + ), + result + ); assert_eq!("/review these changes", composer.textarea.text()); - - let mut found_error = false; - while let Ok(event) = rx.try_recv() { - if let AppEvent::InsertHistoryCell(cell) = event { - let message = cell - .display_lines(80) - .into_iter() - .map(|line| line.to_string()) - .collect::>() - .join("\n"); - assert!(message.contains("disabled while a task is in progress")); - found_error = true; - break; - } - } - assert!(found_error, "expected error history cell to be sent"); + assert!(rx.try_recv().is_err(), "no error should be emitted"); } #[test] @@ -7636,7 +7650,7 @@ mod tests { composer.take_recent_submission_mention_bindings(), mention_bindings ); - assert!(composer.take_mention_bindings().is_empty()); + assert!(composer.mention_bindings().is_empty()); } #[test] diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index 85088496899..5c8ea75df7e 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -12,12 +12,6 @@ use crate::render::RectExt; use crate::slash_command::SlashCommand; use codex_protocol::custom_prompts::CustomPrompt; use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; -use std::collections::HashSet; - -// Hide alias commands in the default popup list so each unique action appears once. -// `quit` is an alias of `exit`, so we skip `quit` here. -// `approvals` is an alias of `permissions`. -const ALIAS_COMMANDS: &[SlashCommand] = &[SlashCommand::Quit, SlashCommand::Approvals]; /// A selectable item in the popup: either a built-in command or a user prompt. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -29,7 +23,8 @@ pub(crate) enum CommandItem { pub(crate) struct CommandPopup { command_filter: String, - builtins: Vec<(&'static str, SlashCommand)>, + builtins: Vec, + reserved_builtin_names: std::collections::HashSet, prompts: Vec, state: ScrollState, } @@ -62,30 +57,27 @@ impl From for slash_commands::BuiltinCommandFlags { impl CommandPopup { pub(crate) fn new(mut prompts: Vec, flags: CommandPopupFlags) -> Self { // Keep built-in availability in sync with the composer. - let builtins: Vec<(&'static str, SlashCommand)> = - slash_commands::builtins_for_input(flags.into()) - .into_iter() - .filter(|(name, _)| !name.starts_with("debug")) - .collect(); + let builtin_flags = flags.into(); + let builtins = slash_commands::visible_builtins_for_input(builtin_flags) + .into_iter() + .filter(|cmd| !cmd.command().starts_with("debug")) + .collect(); // Exclude prompts that collide with builtin command names and sort by name. - let exclude: HashSet = builtins.iter().map(|(n, _)| (*n).to_string()).collect(); - prompts.retain(|p| !exclude.contains(&p.name)); + let reserved_builtin_names = + slash_commands::reserved_builtin_names_for_input(builtin_flags); + prompts.retain(|p| !reserved_builtin_names.contains(&p.name)); prompts.sort_by(|a, b| a.name.cmp(&b.name)); Self { command_filter: String::new(), builtins, + reserved_builtin_names, prompts, state: ScrollState::new(), } } pub(crate) fn set_prompts(&mut self, mut prompts: Vec) { - let exclude: HashSet = self - .builtins - .iter() - .map(|(n, _)| (*n).to_string()) - .collect(); - prompts.retain(|p| !exclude.contains(&p.name)); + prompts.retain(|p| !self.reserved_builtin_names.contains(&p.name)); prompts.sort_by(|a, b| a.name.cmp(&b.name)); self.prompts = prompts; } @@ -142,8 +134,8 @@ impl CommandPopup { let mut out: Vec<(CommandItem, Option>)> = Vec::new(); if filter.is_empty() { // Built-ins first, in presentation order. - for (_, cmd) in self.builtins.iter() { - if ALIAS_COMMANDS.contains(cmd) { + for cmd in self.builtins.iter() { + if !cmd.show_in_command_popup() { continue; } out.push((CommandItem::Builtin(*cmd), None)); @@ -162,6 +154,29 @@ impl CommandPopup { let prompt_prefix_len = PROMPTS_CMD_PREFIX.chars().count() + 1; let indices_for = |offset| Some((offset..offset + filter_chars).collect()); + for cmd in self.builtins.iter() { + if cmd.command() == filter_lower.as_str() { + exact.push((CommandItem::Builtin(*cmd), indices_for(0))); + continue; + } + if cmd.command().starts_with(&filter_lower) { + prefix.push((CommandItem::Builtin(*cmd), indices_for(0))); + continue; + } + // Keep the popup searchable by accepted aliases, but keep rendering the + // canonical command name so the list stays deduplicated and stable. + if cmd.command_aliases().contains(&filter_lower.as_str()) { + exact.push((CommandItem::Builtin(*cmd), None)); + continue; + } + if cmd + .command_aliases() + .iter() + .any(|alias| alias.starts_with(&filter_lower)) + { + prefix.push((CommandItem::Builtin(*cmd), None)); + } + } let mut push_match = |item: CommandItem, display: &str, name: Option<&str>, name_offset: usize| { let display_lower = display.to_lowercase(); @@ -182,10 +197,6 @@ impl CommandPopup { prefix.push((item, indices_for(offset))); } }; - - for (_, cmd) in self.builtins.iter() { - push_match(CommandItem::Builtin(*cmd), cmd.command(), None, 0); - } // Support both search styles: // - Typing "name" should surface "/prompts:name" results. // - Typing "prompts:name" should also work. @@ -340,6 +351,20 @@ mod tests { } } + #[test] + fn help_is_first_suggestion_for_root_popup() { + let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); + popup.on_composer_text_change("/".to_string()); + let matches = popup.filtered_items(); + match matches.first() { + Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "help"), + Some(CommandItem::UserPrompt(_)) => { + panic!("unexpected prompt ranked before '/help' for '/'") + } + None => panic!("expected at least one match for '/'"), + } + } + #[test] fn filtered_commands_keep_presentation_order_for_prefix() { let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); @@ -353,7 +378,7 @@ mod tests { CommandItem::UserPrompt(_) => None, }) .collect(); - assert_eq!(cmds, vec!["model", "mention", "mcp"]); + assert_eq!(cmds, vec!["model", "mention", "mcp", "subagents"]); } #[test] @@ -411,6 +436,31 @@ mod tests { ); } + #[test] + fn prompt_name_collision_with_builtin_alias_is_ignored() { + let popup = CommandPopup::new( + vec![CustomPrompt { + name: "multi-agents".to_string(), + path: "/tmp/multi-agents.md".to_string().into(), + content: "should be ignored".to_string(), + description: None, + argument_hint: None, + }], + CommandPopupFlags::default(), + ); + let items = popup.filtered_items(); + let has_collision_prompt = items.into_iter().any(|it| match it { + CommandItem::UserPrompt(i) => popup + .prompt(i) + .is_some_and(|prompt| prompt.name == "multi-agents"), + CommandItem::Builtin(_) => false, + }); + assert!( + !has_collision_prompt, + "prompt with builtin alias should be ignored" + ); + } + #[test] fn prompt_description_uses_frontmatter_metadata() { let popup = CommandPopup::new( @@ -479,6 +529,32 @@ mod tests { assert!(items.contains(&CommandItem::Builtin(SlashCommand::Quit))); } + #[test] + fn multi_agents_alias_matches_subagents_entry() { + let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); + popup.on_composer_text_change("/multi".to_string()); + assert_eq!( + popup.selected_item(), + Some(CommandItem::Builtin(SlashCommand::MultiAgents)) + ); + + let cmds: Vec<&str> = popup + .filtered_items() + .into_iter() + .filter_map(|item| match item { + CommandItem::Builtin(cmd) => Some(cmd.command()), + CommandItem::UserPrompt(_) => None, + }) + .collect(); + assert_eq!(cmds, vec!["subagents"]); + + popup.on_composer_text_change("/multi-agents".to_string()); + assert_eq!( + popup.selected_item(), + Some(CommandItem::Builtin(SlashCommand::MultiAgents)) + ); + } + #[test] fn collab_command_hidden_when_collaboration_modes_disabled() { let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); diff --git a/codex-rs/tui/src/bottom_pane/experimental_features_view.rs b/codex-rs/tui/src/bottom_pane/experimental_features_view.rs index 8a81f1f98d9..a462bae31db 100644 --- a/codex-rs/tui/src/bottom_pane/experimental_features_view.rs +++ b/codex-rs/tui/src/bottom_pane/experimental_features_view.rs @@ -17,10 +17,10 @@ use crate::render::Insets; use crate::render::RectExt as _; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; +use crate::slash_command::SlashCommand; +use crate::slash_command_invocation::SlashCommandInvocation; use crate::style::user_message_style; -use codex_core::features::Feature; - use super::CancellationEvent; use super::bottom_pane_view::BottomPaneView; use super::popup_consts::MAX_POPUP_ROWS; @@ -30,7 +30,7 @@ use super::selection_popup_common::measure_rows_height; use super::selection_popup_common::render_rows; pub(crate) struct ExperimentalFeatureItem { - pub feature: Feature, + pub key: String, pub name: String, pub description: String, pub enabled: bool, @@ -198,15 +198,16 @@ impl BottomPaneView for ExperimentalFeaturesView { } fn on_ctrl_c(&mut self) -> CancellationEvent { - // Save the updates if !self.features.is_empty() { - let updates = self - .features - .iter() - .map(|item| (item.feature, item.enabled)) - .collect(); - self.app_event_tx - .send(AppEvent::UpdateFeatureFlags { updates }); + let invocation = SlashCommandInvocation::with_args( + SlashCommand::Experimental, + self.features.iter().map(|item| { + format!("{}={}", item.key, if item.enabled { "on" } else { "off" }) + }), + ); + self.app_event_tx.send(AppEvent::HandleSlashCommandDraft( + invocation.into_user_message(), + )); } self.complete = true; diff --git a/codex-rs/tui/src/bottom_pane/feedback_view.rs b/codex-rs/tui/src/bottom_pane/feedback_view.rs index 98667f8f189..55bb3e55a99 100644 --- a/codex-rs/tui/src/bottom_pane/feedback_view.rs +++ b/codex-rs/tui/src/bottom_pane/feedback_view.rs @@ -21,6 +21,8 @@ use crate::app_event::FeedbackCategory; use crate::app_event_sender::AppEventSender; use crate::history_cell; use crate::render::renderable::Renderable; +use crate::slash_command::SlashCommand; +use crate::slash_command_invocation::SlashCommandInvocation; use codex_protocol::protocol::SessionSource; use super::CancellationEvent; @@ -485,8 +487,17 @@ fn make_feedback_item( description: &str, category: FeedbackCategory, ) -> super::SelectionItem { + let token = match category { + FeedbackCategory::Bug => "bug", + FeedbackCategory::BadResult => "bad-result", + FeedbackCategory::GoodResult => "good-result", + FeedbackCategory::SafetyCheck => "safety-check", + FeedbackCategory::Other => "other", + }; let action: super::SelectionAction = Box::new(move |_sender: &AppEventSender| { - app_event_tx.send(AppEvent::OpenFeedbackConsent { category }); + app_event_tx.send(AppEvent::HandleSlashCommandDraft( + SlashCommandInvocation::with_args(SlashCommand::Feedback, [token]).into_user_message(), + )); }); super::SelectionItem { name: name.to_string(), diff --git a/codex-rs/tui/src/bottom_pane/help_view.rs b/codex-rs/tui/src/bottom_pane/help_view.rs new file mode 100644 index 00000000000..79c11b1daed --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/help_view.rs @@ -0,0 +1,495 @@ +use std::cell::Cell; + +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Constraint; +use ratatui::layout::Layout; +use ratatui::layout::Rect; +use ratatui::style::Style; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; +use unicode_width::UnicodeWidthStr; + +use crate::bottom_pane::BuiltinCommandFlags; +use crate::bottom_pane::CancellationEvent; +use crate::bottom_pane::bottom_pane_view::BottomPaneView; +use crate::bottom_pane::popup_consts::MAX_POPUP_ROWS; +use crate::bottom_pane::selection_popup_common::render_menu_surface; +use crate::bottom_pane::visible_builtins_for_input; +use crate::key_hint; +use crate::wrapping::RtOptions; +use crate::wrapping::word_wrap_lines; + +const HELP_VIEW_MIN_BODY_ROWS: u16 = 6; + +#[derive(Clone, Copy)] +enum HelpRowWrap { + None, + Note, + Description, + Usage, +} + +#[derive(Clone)] +struct HelpRow { + plain_text: String, + line: Line<'static>, + wrap: HelpRowWrap, +} + +#[derive(Default)] +struct HelpSearch { + active_query: String, + input: Option, + selected_match: usize, +} + +pub(crate) struct SlashHelpView { + complete: bool, + rows: Vec, + scroll_top: Cell, + follow_selected_match: Cell, + search: HelpSearch, +} + +impl SlashHelpView { + pub(crate) fn new(flags: BuiltinCommandFlags) -> Self { + Self { + complete: false, + rows: Self::build_document(flags), + scroll_top: Cell::new(0), + follow_selected_match: Cell::new(false), + search: HelpSearch::default(), + } + } + + fn visible_body_rows(area_height: u16) -> usize { + area_height + .saturating_sub(3) + .max(HELP_VIEW_MIN_BODY_ROWS) + .into() + } + + fn build_document(flags: BuiltinCommandFlags) -> Vec { + let mut rows = vec![ + HelpRow { + plain_text: "Slash Commands".to_string(), + line: Line::from("Slash Commands".bold()), + wrap: HelpRowWrap::None, + }, + HelpRow { + plain_text: String::new(), + line: Line::from(""), + wrap: HelpRowWrap::None, + }, + HelpRow { + plain_text: "Type / to open the command popup. For commands with both a picker and an arg form, bare /command opens the picker and /command ... runs directly.".to_string(), + line: Line::from( + "Type / to open the command popup. For commands with both a picker and an arg form, bare /command opens the picker and /command ... runs directly." + .dim(), + ), + wrap: HelpRowWrap::Note, + }, + HelpRow { + plain_text: "Args use shell-style quoting; quote values with spaces.".to_string(), + line: Line::from("Args use shell-style quoting; quote values with spaces.".dim()), + wrap: HelpRowWrap::Note, + }, + HelpRow { + plain_text: String::new(), + line: Line::from(""), + wrap: HelpRowWrap::None, + }, + ]; + + for cmd in visible_builtins_for_input(flags) { + rows.push(HelpRow { + plain_text: format!("/{}", cmd.command()), + line: Line::from(format!("/{}", cmd.command()).cyan().bold()), + wrap: HelpRowWrap::None, + }); + rows.push(HelpRow { + plain_text: format!(" {}", cmd.description()), + line: Line::from(format!(" {}", cmd.description()).dim()), + wrap: HelpRowWrap::Description, + }); + rows.push(HelpRow { + plain_text: " Usage:".to_string(), + line: Line::from(" Usage:".dim()), + wrap: HelpRowWrap::None, + }); + for form in cmd.help_forms() { + let plain_text = if form.is_empty() { + format!("/{}", cmd.command()) + } else { + format!("/{} {}", cmd.command(), form) + }; + rows.push(HelpRow { + plain_text: plain_text.clone(), + line: Line::from(plain_text.cyan()), + wrap: HelpRowWrap::Usage, + }); + } + rows.push(HelpRow { + plain_text: String::new(), + line: Line::from(""), + wrap: HelpRowWrap::None, + }); + } + + while rows.last().is_some_and(|row| row.plain_text.is_empty()) { + rows.pop(); + } + + rows + } + + fn scroll_by(&mut self, delta: isize) { + self.scroll_top + .set(self.scroll_top.get().saturating_add_signed(delta)); + self.follow_selected_match.set(false); + } + + fn matching_logical_rows(rows: &[HelpRow], query: &str) -> Vec { + let query = query.to_ascii_lowercase(); + rows.iter() + .enumerate() + .filter_map(|(idx, row)| { + row.plain_text + .to_ascii_lowercase() + .contains(query.as_str()) + .then_some(idx) + }) + .collect() + } + + fn current_query(&self) -> Option<&str> { + if let Some(input) = self.search.input.as_deref() { + return (!input.is_empty()).then_some(input); + } + (!self.search.active_query.is_empty()).then_some(self.search.active_query.as_str()) + } + + fn search_indicator( + &self, + rows: &[HelpRow], + total_rows: usize, + visible_rows: usize, + scroll_top: usize, + ) -> String { + let start_row = if total_rows == 0 { 0 } else { scroll_top + 1 }; + let end_row = (scroll_top + visible_rows).min(total_rows); + let viewport = format!("{start_row}-{end_row}/{total_rows}"); + let Some(query) = self.current_query() else { + return viewport; + }; + let match_count = Self::matching_logical_rows(rows, query).len(); + if self.search.input.is_some() { + return format!( + "{} match{} | {viewport}", + match_count, + if match_count == 1 { "" } else { "es" } + ); + } + if match_count == 0 { + return format!("0/0 | {viewport}"); + } + let current_match = self.search.selected_match.min(match_count - 1) + 1; + format!("{current_match}/{match_count} | {viewport}") + } + + fn footer_line(&self) -> Line<'static> { + if let Some(input) = self.search.input.as_deref() { + return Line::from(vec![ + "Search: ".dim(), + format!("/{input}").cyan(), + " | ".dim(), + key_hint::plain(KeyCode::Enter).into(), + " apply | ".dim(), + key_hint::plain(KeyCode::Esc).into(), + " cancel".dim(), + ]); + } + + let mut spans = vec![ + key_hint::plain(KeyCode::Up).into(), + "/".into(), + key_hint::plain(KeyCode::Down).into(), + " scroll | [".dim(), + key_hint::ctrl(KeyCode::Char('p')).into(), + " / ".dim(), + key_hint::ctrl(KeyCode::Char('n')).into(), + "] page | ".dim(), + "/ search".dim(), + ]; + if !self.search.active_query.is_empty() { + spans.push(" | ".dim()); + spans.push("n/p match".dim()); + } + spans.extend([ + " | ".dim(), + key_hint::plain(KeyCode::Esc).into(), + " close".dim(), + ]); + Line::from(spans) + } + + fn wrap_rows(rows: &[HelpRow], width: u16) -> (Vec>, Vec, Vec) { + let width = width.max(24); + let note_opts = RtOptions::new(width as usize) + .initial_indent(Line::from("")) + .subsequent_indent(Line::from("")); + let description_opts = RtOptions::new(width as usize) + .initial_indent(Line::from("")) + .subsequent_indent(Line::from(" ")); + let usage_opts = RtOptions::new(width as usize) + .initial_indent(Line::from(" ")) + .subsequent_indent(Line::from(" ")); + + let mut wrapped_rows = Vec::new(); + let mut row_starts = Vec::with_capacity(rows.len()); + let mut row_ends = Vec::with_capacity(rows.len()); + + for row in rows { + row_starts.push(wrapped_rows.len()); + let wrapped = match row.wrap { + HelpRowWrap::None => vec![row.line.clone()], + HelpRowWrap::Note => word_wrap_lines([row.line.clone()], note_opts.clone()), + HelpRowWrap::Description => { + word_wrap_lines([row.line.clone()], description_opts.clone()) + } + HelpRowWrap::Usage => word_wrap_lines([row.line.clone()], usage_opts.clone()), + }; + wrapped_rows.extend(wrapped); + row_ends.push(wrapped_rows.len()); + } + + (wrapped_rows, row_starts, row_ends) + } + + fn move_to_match(&mut self, delta: isize) { + if self.search.input.is_some() || self.search.active_query.is_empty() { + return; + } + + let matches = Self::matching_logical_rows(&self.rows, &self.search.active_query); + if matches.is_empty() { + return; + } + + let next = (self.search.selected_match as isize + delta).rem_euclid(matches.len() as isize); + self.search.selected_match = next as usize; + self.follow_selected_match.set(true); + } +} + +impl BottomPaneView for SlashHelpView { + fn handle_key_event(&mut self, key_event: KeyEvent) { + if let Some(input) = self.search.input.as_mut() { + match key_event { + KeyEvent { + code: KeyCode::Esc, .. + } => { + self.search.input = None; + } + KeyEvent { + code: KeyCode::Enter, + .. + } => { + self.search.active_query = self.search.input.take().unwrap_or_default(); + self.search.selected_match = 0; + self.follow_selected_match + .set(!self.search.active_query.is_empty()); + } + KeyEvent { + code: KeyCode::Backspace, + .. + } => { + input.pop(); + } + KeyEvent { + code: KeyCode::Char(c), + modifiers, + .. + } if !modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => { + input.push(c); + } + _ => {} + } + return; + } + + match key_event { + KeyEvent { + code: KeyCode::Char('/'), + modifiers: KeyModifiers::NONE, + .. + } => { + self.search.active_query.clear(); + self.search.selected_match = 0; + self.follow_selected_match.set(false); + self.search.input = Some(String::new()); + } + KeyEvent { + code: KeyCode::Up, .. + } + | KeyEvent { + code: KeyCode::Char('k'), + modifiers: KeyModifiers::NONE, + .. + } => self.scroll_by(-1), + KeyEvent { + code: KeyCode::Down, + .. + } + | KeyEvent { + code: KeyCode::Char('j'), + modifiers: KeyModifiers::NONE, + .. + } => self.scroll_by(1), + KeyEvent { + code: KeyCode::PageUp, + .. + } + | KeyEvent { + code: KeyCode::Char('p'), + modifiers: KeyModifiers::CONTROL, + .. + } => self.scroll_by(-(MAX_POPUP_ROWS as isize)), + KeyEvent { + code: KeyCode::PageDown, + .. + } + | KeyEvent { + code: KeyCode::Char('n'), + modifiers: KeyModifiers::CONTROL, + .. + } => self.scroll_by(MAX_POPUP_ROWS as isize), + KeyEvent { + code: KeyCode::Esc, .. + } if !self.search.active_query.is_empty() => { + self.search.active_query.clear(); + self.search.selected_match = 0; + self.follow_selected_match.set(false); + } + KeyEvent { + code: KeyCode::Char('q'), + modifiers: KeyModifiers::NONE, + .. + } => { + self.on_ctrl_c(); + } + KeyEvent { + code: KeyCode::Char('n'), + modifiers: KeyModifiers::NONE, + .. + } => self.move_to_match(1), + KeyEvent { + code: KeyCode::Char('p'), + modifiers: KeyModifiers::NONE, + .. + } + | KeyEvent { + code: KeyCode::Char('N'), + modifiers: KeyModifiers::SHIFT, + .. + } => self.move_to_match(-1), + _ => {} + } + } + + fn is_complete(&self) -> bool { + self.complete + } + + fn on_ctrl_c(&mut self) -> CancellationEvent { + self.complete = true; + CancellationEvent::Handled + } + + fn prefer_esc_to_handle_key_event(&self) -> bool { + self.search.input.is_some() || !self.search.active_query.is_empty() + } +} + +impl crate::render::renderable::Renderable for SlashHelpView { + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.is_empty() { + return; + } + + let content_area = render_menu_surface(area, buf); + let [header_area, body_area, footer_area] = Layout::vertical([ + Constraint::Length(2), + Constraint::Fill(1), + Constraint::Length(2), + ]) + .areas(content_area); + let [_footer_gap_area, footer_line_area] = + Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(footer_area); + + let (lines, row_starts, row_ends) = Self::wrap_rows(&self.rows, body_area.width); + let header_lines = lines.iter().take(2).cloned().collect::>(); + let mut body_lines = lines.iter().skip(2).cloned().collect::>(); + let visible_rows = Self::visible_body_rows(body_area.height); + let max_scroll = body_lines.len().saturating_sub(visible_rows); + let mut scroll_top = self.scroll_top.get().min(max_scroll); + + if self.search.input.is_none() + && !self.search.active_query.is_empty() + && let Some(selected_row_idx) = + Self::matching_logical_rows(&self.rows, &self.search.active_query) + .get(self.search.selected_match) + .copied() + { + let start = row_starts[selected_row_idx].saturating_sub(2); + let end = row_ends[selected_row_idx].saturating_sub(2); + if self.follow_selected_match.get() || start < scroll_top { + scroll_top = start; + } else if end > scroll_top + visible_rows { + scroll_top = end.saturating_sub(visible_rows); + } + scroll_top = scroll_top.min(max_scroll); + self.scroll_top.set(scroll_top); + self.follow_selected_match.set(false); + for line in body_lines.iter_mut().take(end).skip(start) { + *line = line.clone().patch_style(Style::new().reversed()); + } + } + + self.scroll_top.set(scroll_top); + + Paragraph::new(header_lines).render(header_area, buf); + Paragraph::new(body_lines.clone()) + .scroll((scroll_top as u16, 0)) + .render(body_area, buf); + + let footer_line = self.footer_line(); + Paragraph::new(footer_line.clone()).render(footer_line_area, buf); + let indicator = + self.search_indicator(&self.rows, body_lines.len(), visible_rows, scroll_top); + let indicator_width = UnicodeWidthStr::width(indicator.as_str()) as u16; + let footer_width = footer_line.width() as u16; + if footer_width + indicator_width + 2 <= footer_line_area.width { + Paragraph::new(indicator.dim()).render( + Rect::new( + footer_line_area.x + footer_line_area.width - indicator_width, + footer_line_area.y, + indicator_width, + footer_line_area.height, + ), + buf, + ); + } + } + + fn desired_height(&self, width: u16) -> u16 { + let (wrapped_rows, _, _) = Self::wrap_rows(&self.rows, width.saturating_sub(4)); + let content_rows = wrapped_rows.len() as u16; + content_rows.max(HELP_VIEW_MIN_BODY_ROWS + 4) + } +} diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index ee0a9207e23..c26ffa52ae2 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -15,6 +15,7 @@ //! hint. The pane schedules redraws so those hints can expire even when the UI is otherwise idle. use std::path::PathBuf; +use crate::app_event::AppEvent; use crate::app_event::ConnectorsSnapshot; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::pending_input_preview::PendingInputPreview; @@ -31,6 +32,7 @@ use codex_core::features::Features; use codex_core::plugins::PluginCapabilitySummary; use codex_core::skills::model::SkillMetadata; use codex_file_search::FileMatch; +use codex_protocol::protocol::Op; use codex_protocol::request_user_input::RequestUserInputEvent; use codex_protocol::user_input::TextElement; use crossterm::event::KeyCode; @@ -79,6 +81,7 @@ pub mod custom_prompt_view; mod experimental_features_view; mod file_search_popup; mod footer; +mod help_view; mod list_selection_view; mod prompt_args; mod skill_popup; @@ -95,6 +98,7 @@ pub(crate) use feedback_view::FeedbackAudience; pub(crate) use feedback_view::feedback_disabled_params; pub(crate) use feedback_view::feedback_selection_params; pub(crate) use feedback_view::feedback_upload_consent_params; +pub(crate) use help_view::SlashHelpView; pub(crate) use skills_toggle_view::SkillsToggleItem; pub(crate) use skills_toggle_view::SkillsToggleView; pub(crate) use status_line_setup::StatusLineItem; @@ -137,18 +141,20 @@ pub(crate) enum CancellationEvent { NotHandled, } -use crate::bottom_pane::prompt_args::parse_slash_name; +use crate::status_indicator_widget::StatusDetailsCapitalization; +use crate::status_indicator_widget::StatusIndicatorWidget; pub(crate) use chat_composer::ChatComposer; pub(crate) use chat_composer::ChatComposerConfig; pub(crate) use chat_composer::InputResult; use codex_protocol::custom_prompts::CustomPrompt; - -use crate::status_indicator_widget::StatusDetailsCapitalization; -use crate::status_indicator_widget::StatusIndicatorWidget; pub(crate) use experimental_features_view::ExperimentalFeatureItem; pub(crate) use experimental_features_view::ExperimentalFeaturesView; pub(crate) use list_selection_view::SelectionAction; pub(crate) use list_selection_view::SelectionItem; +pub(crate) use prompt_args::parse_slash_name; +pub(crate) use slash_commands::BuiltinCommandFlags; +pub(crate) use slash_commands::find_builtin_command; +pub(crate) use slash_commands::visible_builtins_for_input; /// Pane displayed in the lower half of the chat UI. /// @@ -263,22 +269,10 @@ impl BottomPane { self.request_redraw(); } - pub fn take_mention_bindings(&mut self) -> Vec { - self.composer.take_mention_bindings() - } - pub fn take_recent_submission_mention_bindings(&mut self) -> Vec { self.composer.take_recent_submission_mention_bindings() } - /// Clear pending attachments and mention bindings e.g. when a slash command doesn't submit text. - pub(crate) fn drain_pending_submission_state(&mut self) { - let _ = self.take_recent_submission_images_with_placeholders(); - let _ = self.take_remote_image_urls(); - let _ = self.take_recent_submission_mention_bindings(); - let _ = self.take_mention_bindings(); - } - pub fn set_collaboration_modes_enabled(&mut self, enabled: bool) { self.composer.set_collaboration_modes_enabled(enabled); self.request_redraw(); @@ -421,25 +415,15 @@ impl BottomPane { self.request_redraw(); InputResult::None } else { - let is_agent_command = self - .composer_text() - .lines() - .next() - .and_then(parse_slash_name) - .is_some_and(|(name, _, _)| name == "agent"); - - // If a task is running and a status line is visible, allow Esc to - // send an interrupt even while the composer has focus. - // When a popup is active, prefer dismissing it over interrupting the task. + // If a task is running, allow Esc to send an interrupt when no popup is active. + // Final-message streaming can temporarily hide the status widget, but that should not + // disable interrupt. if key_event.code == KeyCode::Esc && matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat) && self.is_task_running - && !is_agent_command && !self.composer.popup_active() - && let Some(status) = &self.status { - // Send Op::Interrupt - status.interrupt(); + self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt)); self.request_redraw(); return InputResult::None; } @@ -723,7 +707,6 @@ impl BottomPane { if !was_running { if self.status.is_none() { self.status = Some(StatusIndicatorWidget::new( - self.app_event_tx.clone(), self.frame_requester.clone(), self.animations_enabled, )); @@ -750,7 +733,6 @@ impl BottomPane { pub(crate) fn ensure_status_indicator(&mut self) { if self.status.is_none() { self.status = Some(StatusIndicatorWidget::new( - self.app_event_tx.clone(), self.frame_requester.clone(), self.animations_enabled, )); @@ -1029,6 +1011,7 @@ impl BottomPane { fn on_active_view_complete(&mut self) { self.resume_status_timer_after_modal(); self.set_composer_input_enabled(/*enabled*/ true, /*placeholder*/ None); + self.app_event_tx.send(AppEvent::BottomPaneViewCompleted); } fn pause_status_timer_for_modal(&mut self) { @@ -1641,29 +1624,6 @@ mod tests { assert!(snapshot.contains("[Image #2]")); } - #[test] - fn drain_pending_submission_state_clears_remote_image_urls() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let mut pane = BottomPane::new(BottomPaneParams { - app_event_tx: tx, - frame_requester: FrameRequester::test_dummy(), - has_input_focus: true, - enhanced_keys_supported: false, - placeholder_text: "Ask Codex to do anything".to_string(), - disable_paste_burst: false, - animations_enabled: true, - skills: Some(Vec::new()), - }); - - pane.set_remote_image_urls(vec!["https://example.com/one.png".to_string()]); - assert_eq!(pane.remote_image_urls().len(), 1); - - pane.drain_pending_submission_state(); - - assert!(pane.remote_image_urls().is_empty()); - } - #[test] fn esc_with_skill_popup_does_not_interrupt_task() { let (tx_raw, mut rx) = unbounded_channel::(); @@ -1749,7 +1709,7 @@ mod tests { } #[test] - fn esc_with_agent_command_without_popup_does_not_interrupt_task() { + fn esc_with_agent_command_without_popup_interrupts_task() { let (tx_raw, mut rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let mut pane = BottomPane::new(BottomPaneParams { @@ -1765,8 +1725,8 @@ mod tests { pane.set_task_running(true); - // Repro: `/agent ` hides the popup (cursor past command name). Esc should - // keep editing command text instead of interrupting the running task. + // `/agent ` hides the popup once the cursor moves past the command name. + // Without an active popup, Esc should interrupt even though the composer has text. pane.insert_str("/agent "); assert!( !pane.composer.popup_active(), @@ -1775,12 +1735,10 @@ mod tests { pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - while let Ok(ev) = rx.try_recv() { - assert!( - !matches!(ev, AppEvent::CodexOp(Op::Interrupt)), - "expected Esc to not send Op::Interrupt while typing `/agent`" - ); - } + assert!( + matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt))), + "expected Esc to send Op::Interrupt while typing `/agent` with no popup" + ); assert_eq!(pane.composer_text(), "/agent "); } @@ -1857,6 +1815,59 @@ mod tests { ); } + #[test] + fn esc_with_nonempty_composer_interrupts_task_when_no_popup() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + pane.set_task_running(true); + pane.insert_str("still editing"); + + pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert!( + matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt))), + "expected Esc to send Op::Interrupt while composer has text and no popup is active" + ); + assert_eq!(pane.composer_text(), "still editing"); + } + + #[test] + fn esc_interrupts_running_task_when_status_indicator_hidden() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + pane.set_task_running(true); + pane.hide_status_indicator(); + + pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert!( + matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt))), + "expected Esc to send Op::Interrupt even when the status indicator is hidden" + ); + } + #[test] fn esc_routes_to_handle_key_event_when_requested() { #[derive(Default)] diff --git a/codex-rs/tui/src/bottom_pane/slash_commands.rs b/codex-rs/tui/src/bottom_pane/slash_commands.rs index 15b70f232c2..4835e54cb56 100644 --- a/codex-rs/tui/src/bottom_pane/slash_commands.rs +++ b/codex-rs/tui/src/bottom_pane/slash_commands.rs @@ -3,12 +3,13 @@ //! The same sandbox- and feature-gating rules are used by both the composer //! and the command popup. Centralizing them here keeps those call sites small //! and ensures they stay in sync. -use std::str::FromStr; +use std::collections::HashSet; use codex_utils_fuzzy_match::fuzzy_match; use crate::slash_command::SlashCommand; use crate::slash_command::built_in_slash_commands; +use crate::slash_command::visible_built_in_slash_commands; #[derive(Clone, Copy, Debug, Default)] pub(crate) struct BuiltinCommandFlags { @@ -21,30 +22,64 @@ pub(crate) struct BuiltinCommandFlags { pub(crate) allow_elevate_sandbox: bool, } +fn command_enabled_for_input(cmd: SlashCommand, flags: BuiltinCommandFlags) -> bool { + if !flags.allow_elevate_sandbox && cmd == SlashCommand::ElevateSandbox { + return false; + } + if !flags.collaboration_modes_enabled + && matches!(cmd, SlashCommand::Collab | SlashCommand::Plan) + { + return false; + } + if !flags.connectors_enabled && cmd == SlashCommand::Apps { + return false; + } + if !flags.fast_command_enabled && cmd == SlashCommand::Fast { + return false; + } + if !flags.personality_command_enabled && cmd == SlashCommand::Personality { + return false; + } + if !flags.realtime_conversation_enabled && cmd == SlashCommand::Realtime { + return false; + } + if !flags.audio_device_selection_enabled && cmd == SlashCommand::Settings { + return false; + } + true +} + /// Return the built-ins that should be visible/usable for the current input. pub(crate) fn builtins_for_input(flags: BuiltinCommandFlags) -> Vec<(&'static str, SlashCommand)> { built_in_slash_commands() .into_iter() - .filter(|(_, cmd)| flags.allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox) - .filter(|(_, cmd)| { - flags.collaboration_modes_enabled - || !matches!(*cmd, SlashCommand::Collab | SlashCommand::Plan) - }) - .filter(|(_, cmd)| flags.connectors_enabled || *cmd != SlashCommand::Apps) - .filter(|(_, cmd)| flags.fast_command_enabled || *cmd != SlashCommand::Fast) - .filter(|(_, cmd)| flags.personality_command_enabled || *cmd != SlashCommand::Personality) - .filter(|(_, cmd)| flags.realtime_conversation_enabled || *cmd != SlashCommand::Realtime) - .filter(|(_, cmd)| flags.audio_device_selection_enabled || *cmd != SlashCommand::Settings) + .filter(|(_, cmd)| command_enabled_for_input(*cmd, flags)) + .collect() +} + +/// Return the visible built-ins once each, in popup presentation order. +pub(crate) fn visible_builtins_for_input(flags: BuiltinCommandFlags) -> Vec { + visible_built_in_slash_commands() + .into_iter() + .filter(|cmd| command_enabled_for_input(*cmd, flags)) .collect() } /// Find a single built-in command by exact name, after applying the gating rules. pub(crate) fn find_builtin_command(name: &str, flags: BuiltinCommandFlags) -> Option { - let cmd = SlashCommand::from_str(name).ok()?; builtins_for_input(flags) .into_iter() - .any(|(_, visible_cmd)| visible_cmd == cmd) - .then_some(cmd) + .find(|(command_name, _)| *command_name == name) + .map(|(_, cmd)| cmd) +} + +/// Return every builtin name that should be reserved against custom prompt collisions. +pub(crate) fn reserved_builtin_names_for_input(flags: BuiltinCommandFlags) -> HashSet { + visible_builtins_for_input(flags) + .into_iter() + .flat_map(SlashCommand::all_command_names) + .map(str::to_string) + .collect() } /// Whether any visible built-in fuzzily matches the provided prefix. @@ -101,6 +136,29 @@ mod tests { ); } + #[test] + fn multi_agents_alias_still_resolves_for_dispatch() { + assert_eq!( + find_builtin_command("multi-agents", all_enabled_flags()), + Some(SlashCommand::MultiAgents) + ); + assert_eq!( + find_builtin_command("subagents", all_enabled_flags()), + Some(SlashCommand::MultiAgents) + ); + assert_eq!(SlashCommand::MultiAgents.command(), "subagents"); + } + + #[test] + fn visible_builtins_keep_multi_agents_deduplicated() { + let builtins = visible_builtins_for_input(all_enabled_flags()); + let multi_agents: Vec<_> = builtins + .into_iter() + .filter(|cmd| *cmd == SlashCommand::MultiAgents) + .collect(); + assert_eq!(multi_agents, vec![SlashCommand::MultiAgents]); + } + #[test] fn fast_command_is_hidden_when_disabled() { let mut flags = all_enabled_flags(); diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_root.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_root.snap new file mode 100644 index 00000000000..7fa61b4ba7d --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_root.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› / " +" " +" /help show slash command help " +" /model choose what model and reasoning effort to " +" use " +" /permissions choose what Codex is allowed to do " +" /experimental toggle experimental features " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_root@windows.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_root@windows.snap new file mode 100644 index 00000000000..2eded5bf4d6 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_root@windows.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› / " +" " +" /help show slash command help " +" /model choose what model and reasoning " +" effort to use " +" /permissions choose what Codex is allowed to do " +" /sandbox-add-read-dir let sandbox read a directory: / " diff --git a/codex-rs/tui/src/bottom_pane/status_line_setup.rs b/codex-rs/tui/src/bottom_pane/status_line_setup.rs index 58c7ff7f13e..e3b28a83870 100644 --- a/codex-rs/tui/src/bottom_pane/status_line_setup.rs +++ b/codex-rs/tui/src/bottom_pane/status_line_setup.rs @@ -34,6 +34,8 @@ use crate::bottom_pane::bottom_pane_view::BottomPaneView; use crate::bottom_pane::multi_select_picker::MultiSelectItem; use crate::bottom_pane::multi_select_picker::MultiSelectPicker; use crate::render::renderable::Renderable; +use crate::slash_command::SlashCommand; +use crate::slash_command_invocation::SlashCommandInvocation; /// Available items that can be displayed in the status line. /// @@ -231,12 +233,14 @@ impl StatusLineSetupView { .enable_ordering() .on_preview(move |items| preview_data.line_for_items(items)) .on_confirm(|ids, app_event| { - let items = ids - .iter() - .map(|id| id.parse::()) - .collect::, _>>() - .unwrap_or_default(); - app_event.send(AppEvent::StatusLineSetup { items }); + let invocation = if ids.is_empty() { + SlashCommandInvocation::with_args(SlashCommand::Statusline, ["none"]) + } else { + SlashCommandInvocation::with_args(SlashCommand::Statusline, ids) + }; + app_event.send(AppEvent::HandleSlashCommandDraft( + invocation.into_user_message(), + )); }) .on_cancel(|app_event| { app_event.send(AppEvent::StatusLineSetupCancelled); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index dbf318c6168..9c86221c367 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -29,21 +29,38 @@ use std::collections::BTreeMap; use std::collections::HashMap; use std::collections::HashSet; use std::collections::VecDeque; +use std::ffi::OsString; use std::path::Path; use std::path::PathBuf; +use std::str::FromStr; use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; use std::time::Duration; use std::time::Instant; +use base64::Engine; +#[cfg(unix)] +use std::os::unix::ffi::OsStrExt; +#[cfg(unix)] +use std::os::unix::ffi::OsStringExt; +#[cfg(windows)] +use std::os::windows::ffi::OsStrExt; +#[cfg(windows)] +use std::os::windows::ffi::OsStringExt; + use self::realtime::PendingSteerCompareKey; use crate::app_event::RealtimeAudioDeviceKind; +use crate::app_event::WindowsSandboxEnableMode; #[cfg(all(not(target_os = "linux"), feature = "voice-input"))] use crate::audio_device::list_realtime_audio_device_names; +use crate::bottom_pane::BuiltinCommandFlags; use crate::bottom_pane::StatusLineItem; use crate::bottom_pane::StatusLinePreviewData; use crate::bottom_pane::StatusLineSetupView; +use crate::bottom_pane::find_builtin_command; +use crate::slash_command::SlashCommandExecutionKind; +use crate::slash_command_invocation::SlashCommandInvocation; use crate::status::RateLimitWindowDisplay; use crate::status::format_directory_display; use crate::status::format_tokens_compact; @@ -148,6 +165,7 @@ use codex_protocol::protocol::WebSearchBeginEvent; use codex_protocol::protocol::WebSearchEndEvent; use codex_protocol::request_permissions::RequestPermissionsEvent; use codex_protocol::request_user_input::RequestUserInputEvent; +use codex_protocol::user_input::ByteRange; use codex_protocol::user_input::TextElement; use codex_protocol::user_input::UserInput; use codex_utils_sleep_inhibitor::SleepInhibitor; @@ -215,8 +233,6 @@ fn queued_message_edit_binding_for_terminal(terminal_name: TerminalName) -> KeyB use crate::app_event::AppEvent; use crate::app_event::ConnectorsSnapshot; use crate::app_event::ExitMode; -#[cfg(target_os = "windows")] -use crate::app_event::WindowsSandboxEnableMode; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::ApprovalRequest; use crate::bottom_pane::BottomPane; @@ -237,6 +253,7 @@ use crate::bottom_pane::SelectionAction; use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionViewParams; use crate::bottom_pane::custom_prompt_view::CustomPromptView; +use crate::bottom_pane::parse_slash_name; use crate::bottom_pane::popup_consts::standard_popup_hint_line; use crate::clipboard_paste::paste_image_to_temp_png; use crate::clipboard_text; @@ -714,6 +731,7 @@ pub(crate) struct ChatWidget { // Set when commentary output completes; once stream queues go idle we restore the status row. pending_status_indicator_restore: bool, suppress_queue_autosend: bool, + resume_queued_inputs_when_idle: bool, thread_id: Option, thread_name: Option, forked_from: Option, @@ -725,7 +743,9 @@ pub(crate) struct ChatWidget { // When resuming an existing session (selected via resume picker), avoid an // immediate redraw on SessionConfigured to prevent a gratuitous UI flicker. suppress_session_configured_redraw: bool, - // User messages queued while a turn is in progress + // User messages queued while a turn is in progress. Some entries are serialized slash-command + // drafts and are replayed through the slash-command evaluator instead of being submitted + // directly as user turns. queued_user_messages: VecDeque, // Steers already submitted to core but not yet committed into history. // @@ -866,6 +886,20 @@ impl ThreadComposerState { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum QueueReplayControl { + Continue, + ResumeWhenIdle, + Stop, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ModelSelectionScope { + Global, + PlanOnly, + AllModes, +} + #[derive(Debug, Clone, PartialEq)] pub(crate) struct ThreadInputState { composer: Option, @@ -1779,7 +1813,7 @@ impl ChatWidget { self.saw_plan_item_this_turn = false; } // If there is a queued user message, send exactly one now to begin the next turn. - self.maybe_send_next_queued_input(); + self.drain_queued_inputs_until_blocked(); // Emit a notification when the turn completes (suppressed if focused). self.notify(Notification::AgentTurnComplete { response: last_agent_message.unwrap_or_default(), @@ -2086,7 +2120,7 @@ impl ChatWidget { self.add_to_history(history_cell::new_warning_event(message)); self.request_redraw(); - self.maybe_send_next_queued_input(); + self.drain_queued_inputs_until_blocked(); } fn on_error(&mut self, message: String) { @@ -2096,7 +2130,7 @@ impl ChatWidget { self.request_redraw(); // After an error ends the turn, try sending the next queued input. - self.maybe_send_next_queued_input(); + self.drain_queued_inputs_until_blocked(); } fn on_warning(&mut self, message: impl Into) { @@ -2168,7 +2202,7 @@ impl ChatWidget { self.mcp_startup_status = None; self.update_task_running_state(); - self.maybe_send_next_queued_input(); + self.drain_queued_inputs_until_blocked(); self.request_redraw(); } @@ -2180,6 +2214,7 @@ impl ChatWidget { self.finalize_turn(); let send_pending_steers_immediately = self.submit_pending_steers_after_interrupt; self.submit_pending_steers_after_interrupt = false; + let mut started_turn_after_interrupt = false; if reason != TurnAbortReason::ReviewEnded { if send_pending_steers_immediately { self.add_to_history(history_cell::new_info_event( @@ -2203,29 +2238,31 @@ impl ChatWidget { .collect(); if !pending_steers.is_empty() { self.submit_user_message(merge_user_messages(pending_steers)); - } else if let Some(combined) = self.drain_pending_messages_for_restore() { + started_turn_after_interrupt = true; + } else if let Some(combined) = self.drain_restorable_messages_for_restore() { self.restore_user_message_to_composer(combined); } - } else if let Some(combined) = self.drain_pending_messages_for_restore() { + } else if let Some(combined) = self.drain_restorable_messages_for_restore() { self.restore_user_message_to_composer(combined); } self.refresh_pending_input_preview(); + if !started_turn_after_interrupt { + self.drain_queued_inputs_until_blocked(); + } self.request_redraw(); } - /// Merge pending steers, queued drafts, and the current composer state into a single message. + /// Merge pending steers, queued user-message drafts, and the current composer state into a + /// single message. /// /// Each pending message numbers attachments from `[Image #1]` relative to its own remote /// images. When we concatenate multiple messages after interrupt, we must renumber local-image /// placeholders in a stable order and rebase text element byte ranges so the restored composer - /// state stays aligned with the merged attachment list. Returns `None` when there is nothing to - /// restore. - fn drain_pending_messages_for_restore(&mut self) -> Option { - if self.pending_steers.is_empty() && self.queued_user_messages.is_empty() { - return None; - } - + /// state stays aligned with the merged attachment list. Slash commands are fully serializable + /// again, so queued slash drafts are restored alongside ordinary queued follow-ups instead of + /// being replayed separately after the interrupt. + fn drain_restorable_messages_for_restore(&mut self) -> Option { let existing_message = UserMessage { text: self.bottom_pane.composer_text(), text_elements: self.bottom_pane.composer_text_elements(), @@ -2234,16 +2271,22 @@ impl ChatWidget { mention_bindings: self.bottom_pane.composer_mention_bindings(), }; + let has_existing_message = !existing_message.text.is_empty() + || !existing_message.local_images.is_empty() + || !existing_message.remote_image_urls.is_empty(); + let has_pending_user_messages = + !self.pending_steers.is_empty() || !self.queued_user_messages.is_empty(); + if !has_pending_user_messages { + return None; + } + let mut to_merge: Vec = self .pending_steers .drain(..) .map(|steer| steer.user_message) .collect(); to_merge.extend(self.queued_user_messages.drain(..)); - if !existing_message.text.is_empty() - || !existing_message.local_images.is_empty() - || !existing_message.remote_image_urls.is_empty() - { + if has_existing_message { to_merge.push(existing_message); } @@ -3619,6 +3662,7 @@ impl ChatWidget { retry_status_header: None, pending_status_indicator_restore: false, suppress_queue_autosend: false, + resume_queued_inputs_when_idle: false, thread_id: None, thread_name: None, forked_from: None, @@ -3807,6 +3851,7 @@ impl ChatWidget { retry_status_header: None, pending_status_indicator_restore: false, suppress_queue_autosend: false, + resume_queued_inputs_when_idle: false, thread_id: None, thread_name: None, forked_from: None, @@ -3987,6 +4032,7 @@ impl ChatWidget { retry_status_header: None, pending_status_indicator_restore: false, suppress_queue_autosend: false, + resume_queued_inputs_when_idle: false, thread_id: None, thread_name: None, forked_from: None, @@ -4126,8 +4172,8 @@ impl ChatWidget { && self.queued_message_edit_binding.is_press(key_event) && !self.queued_user_messages.is_empty() { - if let Some(user_message) = self.queued_user_messages.pop_back() { - self.restore_user_message_to_composer(user_message); + if let Some(queued_message) = self.queued_user_messages.pop_back() { + self.restore_user_message_to_composer(queued_message); self.refresh_pending_input_preview(); self.request_redraw(); } @@ -4136,11 +4182,10 @@ impl ChatWidget { if matches!(key_event.code, KeyCode::Esc) && matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat) - && !self.pending_steers.is_empty() && self.bottom_pane.is_task_running() && self.bottom_pane.no_modal_or_popup_active() { - self.submit_pending_steers_after_interrupt = true; + self.submit_pending_steers_after_interrupt = !self.pending_steers.is_empty(); if !self.submit_op(Op::Interrupt) { self.submit_pending_steers_after_interrupt = false; } @@ -4187,6 +4232,9 @@ impl ChatWidget { else { return; }; + if self.reject_unavailable_builtin_slash_command(&user_message) { + return; + } let should_submit_now = self.is_session_configured() && !self.is_plan_streaming_in_tui(); if should_submit_now { @@ -4222,6 +4270,9 @@ impl ChatWidget { else { return; }; + if self.reject_unavailable_builtin_slash_command(&user_message) { + return; + } self.queue_user_message(user_message); } InputResult::Command(cmd) => { @@ -4299,42 +4350,64 @@ impl ChatWidget { false } - fn dispatch_command(&mut self, cmd: SlashCommand) { - if !cmd.available_during_task() && self.bottom_pane.is_task_running() { - let message = format!( - "'/{}' is disabled while a task is in progress.", - cmd.command() - ); - self.add_to_history(history_cell::new_error_event(message)); - self.bottom_pane.drain_pending_submission_state(); - self.request_redraw(); - return; + /// Dispatch a built-in slash command for both live input and queued replay. + /// + /// Live callers usually ignore the return value, but queued replay uses it to decide whether + /// draining can continue after this command. `Continue` means the command only changed local + /// state synchronously inside `ChatWidget`. `ResumeWhenIdle` means queued replay should pause + /// until app-side work or popup interaction finishes. `Stop` means it submitted or queued + /// work, changed session/navigation state, or otherwise hit a boundary where queued draining + /// must stop entirely. Commands that require interactive UI are resolved before queueing and + /// should not open that UI during replay. + fn dispatch_command(&mut self, cmd: SlashCommand) -> QueueReplayControl { + if self.bottom_pane.is_task_running() + && !matches!(cmd.execution_kind(), SlashCommandExecutionKind::Immediate) + && !cmd.requires_interaction() + { + self.queue_user_message(SlashCommandInvocation::bare(cmd).into_user_message()); + // This busy-path queueing only happens for live command dispatch. Queued replay + // executes slash drafts only while idle, and handle_serialized_slash_command() queues + // instead of dispatching when a task is already running, so this Stop result is not + // material to replay behavior. + return QueueReplayControl::Stop; } match cmd { + SlashCommand::Help => { + self.bottom_pane + .show_view(Box::new(crate::bottom_pane::SlashHelpView::new( + self.builtin_command_flags(), + ))); + QueueReplayControl::Continue + } SlashCommand::Feedback => { if !self.config.feedback_enabled { let params = crate::bottom_pane::feedback_disabled_params(); self.bottom_pane.show_selection_view(params); self.request_redraw(); - return; + return QueueReplayControl::Stop; } // Step 1: pick a category (UI built in feedback_view) let params = crate::bottom_pane::feedback_selection_params(self.app_event_tx.clone()); self.bottom_pane.show_selection_view(params); self.request_redraw(); + QueueReplayControl::Stop } SlashCommand::New => { self.app_event_tx.send(AppEvent::NewSession); + QueueReplayControl::Stop } SlashCommand::Clear => { self.app_event_tx.send(AppEvent::ClearUi); + QueueReplayControl::Stop } SlashCommand::Resume => { self.app_event_tx.send(AppEvent::OpenResumePicker); + QueueReplayControl::Stop } SlashCommand::Fork => { self.app_event_tx.send(AppEvent::ForkCurrentSession); + QueueReplayControl::Stop } SlashCommand::Init => { let init_target = self.config.cwd.join(DEFAULT_PROJECT_DOC_FILENAME); @@ -4343,25 +4416,30 @@ impl ChatWidget { "{DEFAULT_PROJECT_DOC_FILENAME} already exists here. Skipping /init to avoid overwriting it." ); self.add_info_message(message, /*hint*/ None); - return; + return QueueReplayControl::Stop; } const INIT_PROMPT: &str = include_str!("../prompt_for_init_command.md"); self.submit_user_message(INIT_PROMPT.to_string().into()); + QueueReplayControl::Stop } SlashCommand::Compact => { self.clear_token_usage(); self.app_event_tx.send(AppEvent::CodexOp(Op::Compact)); + QueueReplayControl::Stop } SlashCommand::Review => { self.open_review_popup(); + QueueReplayControl::Stop } SlashCommand::Rename => { self.session_telemetry .counter("codex.thread.rename", /*inc*/ 1, &[]); self.show_rename_prompt(); + QueueReplayControl::Stop } SlashCommand::Model => { self.open_model_popup(); + QueueReplayControl::Stop } SlashCommand::Fast => { let next_tier = if matches!(self.config.service_tier, Some(ServiceTier::Fast)) { @@ -4370,25 +4448,29 @@ impl ChatWidget { Some(ServiceTier::Fast) }; self.set_service_tier_selection(next_tier); + QueueReplayControl::Continue } SlashCommand::Realtime => { if !self.realtime_conversation_enabled() { - return; + return QueueReplayControl::Stop; } if self.realtime_conversation.is_live() { self.request_realtime_conversation_close(/*info_message*/ None); } else { self.start_realtime_conversation(); } + QueueReplayControl::Stop } SlashCommand::Settings => { if !self.realtime_audio_device_selection_enabled() { - return; + return QueueReplayControl::Stop; } self.open_realtime_audio_popup(); + QueueReplayControl::Stop } SlashCommand::Personality => { self.open_personality_popup(); + QueueReplayControl::Stop } SlashCommand::Plan => { if !self.collaboration_modes_enabled() { @@ -4396,15 +4478,17 @@ impl ChatWidget { "Collaboration modes are disabled.".to_string(), Some("Enable collaboration modes to use /plan.".to_string()), ); - return; + return QueueReplayControl::Stop; } if let Some(mask) = collaboration_modes::plan_mask(self.models_manager.as_ref()) { self.set_collaboration_mask(mask); + QueueReplayControl::Continue } else { self.add_info_message( "Plan mode unavailable right now.".to_string(), /*hint*/ None, ); + QueueReplayControl::Stop } } SlashCommand::Collab => { @@ -4413,18 +4497,22 @@ impl ChatWidget { "Collaboration modes are disabled.".to_string(), Some("Enable collaboration modes to use /collab.".to_string()), ); - return; + return QueueReplayControl::Stop; } self.open_collaboration_modes_popup(); + QueueReplayControl::Stop } SlashCommand::Agent | SlashCommand::MultiAgents => { self.app_event_tx.send(AppEvent::OpenAgentPicker); + QueueReplayControl::Stop } SlashCommand::Approvals => { self.open_permissions_popup(); + QueueReplayControl::Stop } SlashCommand::Permissions => { self.open_permissions_popup(); + QueueReplayControl::Stop } SlashCommand::ElevateSandbox => { #[cfg(target_os = "windows")] @@ -4437,7 +4525,7 @@ impl ChatWidget { { // This command should not be visible/recognized outside degraded mode, // but guard anyway in case something dispatches it directly. - return; + return QueueReplayControl::Stop; } let Some(preset) = builtin_approval_presets() @@ -4449,7 +4537,7 @@ impl ChatWidget { self.add_error_message( "Internal error: missing the 'auto' approval preset.".to_string(), ); - return; + return QueueReplayControl::Stop; }; if let Err(err) = self @@ -4459,7 +4547,7 @@ impl ChatWidget { .can_set(&preset.approval) { self.add_error_message(err.to_string()); - return; + return QueueReplayControl::Stop; } self.session_telemetry.counter( @@ -4468,24 +4556,31 @@ impl ChatWidget { &[], ); self.app_event_tx - .send(AppEvent::BeginWindowsSandboxElevatedSetup { preset }); + .send(AppEvent::BeginWindowsSandboxElevatedSetup { + preset, + approvals_reviewer: self.config.approvals_reviewer, + }); } #[cfg(not(target_os = "windows"))] { let _ = &self.session_telemetry; // Not supported; on non-Windows this command should never be reachable. }; + QueueReplayControl::Stop } SlashCommand::SandboxReadRoot => { self.add_error_message( "Usage: /sandbox-add-read-dir ".to_string(), ); + QueueReplayControl::Stop } SlashCommand::Experimental => { self.open_experimental_popup(); + QueueReplayControl::Stop } SlashCommand::Quit | SlashCommand::Exit => { self.request_quit_without_confirmation(); + QueueReplayControl::Stop } SlashCommand::Logout => { if let Err(e) = codex_core::auth::logout( @@ -4495,6 +4590,7 @@ impl ChatWidget { tracing::error!("failed to logout: {e}"); } self.request_quit_without_confirmation(); + QueueReplayControl::Stop } // SlashCommand::Undo => { // self.app_event_tx.send(AppEvent::CodexOp(Op::Undo)); @@ -4515,6 +4611,7 @@ impl ChatWidget { }; tx.send(AppEvent::DiffResult(text)); }); + QueueReplayControl::Continue } SlashCommand::Copy => { let Some(text) = self.last_copyable_output.as_deref() else { @@ -4523,7 +4620,7 @@ impl ChatWidget { .to_string(), /*hint*/ None, ); - return; + return QueueReplayControl::Continue; }; let copy_result = clipboard_text::copy_text_to_clipboard(text); @@ -4543,42 +4640,55 @@ impl ChatWidget { self.add_error_message(format!("Failed to copy to clipboard: {err}")) } } + QueueReplayControl::Continue } SlashCommand::Mention => { self.insert_str("@"); + QueueReplayControl::Stop } SlashCommand::Skills => { self.open_skills_menu(); + QueueReplayControl::Stop } SlashCommand::Status => { self.add_status_output(); + QueueReplayControl::Continue } SlashCommand::DebugConfig => { self.add_debug_config_output(); + QueueReplayControl::Continue } SlashCommand::Statusline => { self.open_status_line_setup(); + QueueReplayControl::Stop } SlashCommand::Theme => { self.open_theme_picker(); + QueueReplayControl::Stop } SlashCommand::Ps => { self.add_ps_output(); + QueueReplayControl::Continue } SlashCommand::Stop => { self.clean_background_terminals(); + QueueReplayControl::Continue } SlashCommand::MemoryDrop => { self.submit_op(Op::DropMemories); + QueueReplayControl::Stop } SlashCommand::MemoryUpdate => { self.submit_op(Op::UpdateMemories); + QueueReplayControl::Stop } SlashCommand::Mcp => { self.add_mcp_output(); + QueueReplayControl::Continue } SlashCommand::Apps => { self.add_connectors_output(); + QueueReplayControl::Continue } SlashCommand::Rollout => { if let Some(path) = self.rollout_path() { @@ -4592,6 +4702,7 @@ impl ChatWidget { /*hint*/ None, ); } + QueueReplayControl::Continue } SlashCommand::TestApproval => { use codex_protocol::protocol::EventMsg; @@ -4630,6 +4741,7 @@ impl ChatWidget { grant_root: Some(PathBuf::from("/tmp")), }), })); + QueueReplayControl::Stop } } } @@ -4638,32 +4750,452 @@ impl ChatWidget { &mut self, cmd: SlashCommand, args: String, - _text_elements: Vec, + text_elements: Vec, ) { - if !cmd.supports_inline_args() { - self.dispatch_command(cmd); + let trimmed = args.trim(); + let should_queue = self.bottom_pane.is_task_running() + && !matches!(cmd.execution_kind(), SlashCommandExecutionKind::Immediate); + if trimmed.is_empty() { + if should_queue && !cmd.requires_interaction() { + self.queue_current_inline_bare_slash_command(cmd); + } else { + self.set_composer_text(String::new(), Vec::new(), Vec::new()); + self.set_remote_image_urls(Vec::new()); + self.dispatch_command(cmd); + } return; } - if !cmd.available_during_task() && self.bottom_pane.is_task_running() { - let message = format!( - "'/{}' is disabled while a task is in progress.", - cmd.command() + + if matches!(cmd, SlashCommand::Plan) && !should_queue { + let draft = Self::inline_slash_command_draft( + cmd, + UserMessage { + text: args, + local_images: self.bottom_pane.composer_local_images(), + remote_image_urls: self.bottom_pane.remote_image_urls(), + text_elements, + mention_bindings: self.bottom_pane.composer_mention_bindings(), + }, ); - self.add_to_history(history_cell::new_error_event(message)); - self.request_redraw(); + let pending_pastes = self.bottom_pane.composer_pending_pastes(); + self.dispatch_command(cmd); + if self.active_mode_kind() != ModeKind::Plan { + self.restore_user_message_to_composer(draft); + self.bottom_pane.set_composer_pending_pastes(pending_pastes); + return; + } + + let Some((prepared_args, prepared_elements)) = + self.bottom_pane.prepare_inline_args_submission(true) + else { + return; + }; + let args_message = + self.take_prepared_submission_user_message(prepared_args, prepared_elements); + self.submit_plan_user_message(args_message); return; } - let trimmed = args.trim(); + let record_history = matches!(cmd, SlashCommand::Plan); + let Some((prepared_args, prepared_elements)) = self + .bottom_pane + .prepare_inline_args_submission(record_history) + else { + return; + }; + let args_message = + self.take_prepared_submission_user_message(prepared_args, prepared_elements); + if should_queue { + self.queue_user_message(Self::inline_slash_command_draft(cmd, args_message)); + return; + } + let _ = self.execute_slash_command_with_args(cmd, args_message); + } + + fn execute_slash_command_with_args( + &mut self, + cmd: SlashCommand, + args_message: UserMessage, + ) -> QueueReplayControl { match cmd { + SlashCommand::Help => { + self.bottom_pane + .show_view(Box::new(crate::bottom_pane::SlashHelpView::new( + self.builtin_command_flags(), + ))); + QueueReplayControl::Continue + } + SlashCommand::Approvals | SlashCommand::Permissions => { + let args = match SlashCommandInvocation::parse_args( + &args_message.text, + "Usage: /approvals [--smart-approvals] [--confirm-full-access] [--remember-full-access] [--confirm-world-writable] [--remember-world-writable] [--enable-windows-sandbox=elevated|legacy]", + ) { + Ok(args) => args, + Err(message) => { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + message, + ); + } + }; + if args.is_empty() { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Usage: /approvals [--smart-approvals] [--confirm-full-access] [--remember-full-access] [--confirm-world-writable] [--remember-world-writable] [--enable-windows-sandbox=elevated|legacy]".to_string(), + ); + } + let preset_id = args[0].as_str(); + let Some(preset) = builtin_approval_presets() + .into_iter() + .find(|preset| preset.id == preset_id) + else { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + format!("Unknown approval preset: {preset_id}"), + ); + }; + + let mut confirm_full_access = false; + let mut remember_full_access = false; + let mut confirm_world_writable = false; + let mut remember_world_writable = false; + let mut smart_approvals = false; + let mut windows_sandbox_mode = None; + for token in args.iter().skip(1) { + match token.as_str() { + "--smart-approvals" => smart_approvals = true, + "--confirm-full-access" => confirm_full_access = true, + "--remember-full-access" => remember_full_access = true, + "--confirm-world-writable" => confirm_world_writable = true, + "--remember-world-writable" => remember_world_writable = true, + "--enable-windows-sandbox=elevated" => { + windows_sandbox_mode = Some(WindowsSandboxEnableMode::Elevated); + } + "--enable-windows-sandbox=legacy" => { + windows_sandbox_mode = Some(WindowsSandboxEnableMode::Legacy); + } + _ => { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + format!("Unrecognized /approvals option: {token}"), + ); + } + } + } + if smart_approvals && preset.id != "auto" { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Smart Approvals is only available for Default permissions.".to_string(), + ); + } + if smart_approvals && !self.config.features.enabled(Feature::GuardianApproval) { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Smart Approvals is not enabled in this session.".to_string(), + ); + } + let approvals_reviewer = if smart_approvals { + ApprovalsReviewer::GuardianSubagent + } else { + ApprovalsReviewer::User + }; + #[cfg(not(target_os = "windows"))] + let _ = windows_sandbox_mode; + + if remember_full_access && !confirm_full_access { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "--remember-full-access requires --confirm-full-access".to_string(), + ); + } + if remember_world_writable && !confirm_world_writable { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "--remember-world-writable requires --confirm-world-writable".to_string(), + ); + } + + #[cfg(target_os = "windows")] + let label = if preset.id == "auto" + && matches!( + WindowsSandboxLevel::from_config(&self.config), + WindowsSandboxLevel::RestrictedToken + ) { + "Default (non-admin sandbox)".to_string() + } else { + preset.label.to_string() + }; + #[cfg(not(target_os = "windows"))] + let label = preset.label.to_string(); + let label = if smart_approvals { + "Smart Approvals".to_string() + } else { + label + }; + + if preset.id == "full-access" + && !confirm_full_access + && !self + .config + .notices + .hide_full_access_warning + .unwrap_or(false) + { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Full access requires confirmation. Re-run with --confirm-full-access." + .to_string(), + ); + } + + #[cfg(target_os = "windows")] + { + if preset.id == "auto" + && WindowsSandboxLevel::from_config(&self.config) + == WindowsSandboxLevel::Disabled + { + let Some(mode) = windows_sandbox_mode else { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Default permissions require Windows sandbox setup. Re-run with --enable-windows-sandbox=elevated or --enable-windows-sandbox=legacy.".to_string(), + ); + }; + match mode { + WindowsSandboxEnableMode::Elevated => { + self.app_event_tx.send( + AppEvent::BeginWindowsSandboxElevatedSetup { + preset, + approvals_reviewer, + }, + ); + } + WindowsSandboxEnableMode::Legacy => { + self.app_event_tx + .send(AppEvent::BeginWindowsSandboxLegacySetup { + preset, + approvals_reviewer, + }); + } + } + return QueueReplayControl::Stop; + } + if preset.id == "auto" + && self.world_writable_warning_details().is_some() + && !confirm_world_writable + { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Default permissions require confirming the Windows sandbox warning. Re-run with --confirm-world-writable.".to_string(), + ); + } + if confirm_world_writable { + self.app_event_tx.send(AppEvent::SkipNextWorldWritableScan); + if remember_world_writable { + self.app_event_tx + .send(AppEvent::UpdateWorldWritableWarningAcknowledged(true)); + self.app_event_tx + .send(AppEvent::PersistWorldWritableWarningAcknowledged); + } + } + } + + if confirm_full_access { + self.app_event_tx + .send(AppEvent::UpdateFullAccessWarningAcknowledged(true)); + if remember_full_access { + self.app_event_tx + .send(AppEvent::PersistFullAccessWarningAcknowledged); + } + } + + let sandbox = preset.sandbox.clone(); + self.app_event_tx + .send(AppEvent::CodexOp(Op::OverrideTurnContext { + cwd: None, + approval_policy: Some(preset.approval), + approvals_reviewer: Some(approvals_reviewer), + sandbox_policy: Some(sandbox.clone()), + windows_sandbox_level: None, + model: None, + effort: None, + summary: None, + service_tier: None, + collaboration_mode: None, + personality: None, + })); + self.app_event_tx + .send(AppEvent::UpdateAskForApprovalPolicy(preset.approval)); + self.app_event_tx + .send(AppEvent::UpdateSandboxPolicy(sandbox)); + self.app_event_tx + .send(AppEvent::UpdateApprovalsReviewer(approvals_reviewer)); + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_info_event( + format!("Permissions updated to {label}"), + /*hint*/ None, + ), + ))); + QueueReplayControl::ResumeWhenIdle + } + SlashCommand::Agent | SlashCommand::MultiAgents => { + let args = match SlashCommandInvocation::parse_args( + &args_message.text, + "Usage: /agent ", + ) { + Ok(args) => args, + Err(message) => { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + message, + ); + } + }; + if args.len() != 1 { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Usage: /agent ".to_string(), + ); + } + match ThreadId::from_string(&args[0]) { + Ok(thread_id) => { + self.app_event_tx + .send(AppEvent::SelectAgentThread(thread_id)); + QueueReplayControl::Stop + } + Err(_) => self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Usage: /agent ".to_string(), + ), + } + } + SlashCommand::Collab => { + let args = match SlashCommandInvocation::parse_args( + &args_message.text, + "Usage: /collab ", + ) { + Ok(args) => args, + Err(message) => { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + message, + ); + } + }; + if args.len() != 1 { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Usage: /collab ".to_string(), + ); + } + let mode = args[0].to_ascii_lowercase(); + let Some(mask) = collaboration_modes::presets_for_tui(self.models_manager.as_ref()) + .into_iter() + .find(|mask| mask.name.eq_ignore_ascii_case(&mode)) + else { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Usage: /collab ".to_string(), + ); + }; + self.app_event_tx + .send(AppEvent::UpdateCollaborationMode(mask.clone())); + self.set_collaboration_mask(mask); + QueueReplayControl::Continue + } + SlashCommand::Experimental => { + let tokens = match SlashCommandInvocation::parse_args( + &args_message.text, + "Usage: /experimental =on|off ...", + ) { + Ok(tokens) => tokens, + Err(message) => { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + message, + ); + } + }; + let mut updates = Vec::new(); + for token in tokens { + let Some((key, value)) = token.split_once('=') else { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Usage: /experimental =on|off ...".to_string(), + ); + }; + let Some(spec) = FEATURES.iter().find(|spec| spec.key == key) else { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + format!("Unknown experimental feature: {key}"), + ); + }; + let enabled = match value { + "on" => true, + "off" => false, + _ => { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + format!("Experimental feature {key} must be set to on or off."), + ); + } + }; + updates.push((spec.id, enabled)); + } + self.app_event_tx + .send(AppEvent::UpdateFeatureFlags { updates }); + QueueReplayControl::ResumeWhenIdle + } SlashCommand::Fast => { - if trimmed.is_empty() { - self.dispatch_command(cmd); - return; + let args = match SlashCommandInvocation::parse_args( + &args_message.text, + "Usage: /fast [on|off|status]", + ) { + Ok(args) => args, + Err(message) => { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + message, + ); + } + }; + if args.len() != 1 { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Usage: /fast [on|off|status]".to_string(), + ); } - match trimmed.to_ascii_lowercase().as_str() { - "on" => self.set_service_tier_selection(Some(ServiceTier::Fast)), - "off" => self.set_service_tier_selection(/*service_tier*/ None), + match args[0].to_ascii_lowercase().as_str() { + "on" => { + self.set_service_tier_selection(Some(ServiceTier::Fast)); + QueueReplayControl::Continue + } + "off" => { + self.set_service_tier_selection(None); + QueueReplayControl::Continue + } "status" => { let status = if matches!(self.config.service_tier, Some(ServiceTier::Fast)) { @@ -4675,94 +5207,960 @@ impl ChatWidget { format!("Fast mode is {status}."), /*hint*/ None, ); + QueueReplayControl::Continue } - _ => { - self.add_error_message("Usage: /fast [on|off|status]".to_string()); - } + _ => self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Usage: /fast [on|off|status]".to_string(), + ), } } - SlashCommand::Rename if !trimmed.is_empty() => { - self.session_telemetry - .counter("codex.thread.rename", /*inc*/ 1, &[]); - let Some((prepared_args, _prepared_elements)) = self - .bottom_pane - .prepare_inline_args_submission(/*record_history*/ false) - else { - return; + SlashCommand::Feedback => { + if !self.config.feedback_enabled { + let params = crate::bottom_pane::feedback_disabled_params(); + self.bottom_pane.show_selection_view(params); + self.request_redraw(); + return QueueReplayControl::Stop; + } + let args = match SlashCommandInvocation::parse_args( + &args_message.text, + "Usage: /feedback ", + ) { + Ok(args) => args, + Err(message) => { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + message, + ); + } }; - let Some(name) = codex_core::util::normalize_thread_name(&prepared_args) else { - self.add_error_message("Thread name cannot be empty.".to_string()); - return; + if args.len() != 1 { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Usage: /feedback " + .to_string(), + ); + } + let category = match args[0].to_ascii_lowercase().as_str() { + "bad-result" => crate::app_event::FeedbackCategory::BadResult, + "good-result" => crate::app_event::FeedbackCategory::GoodResult, + "bug" => crate::app_event::FeedbackCategory::Bug, + "safety-check" => crate::app_event::FeedbackCategory::SafetyCheck, + "other" => crate::app_event::FeedbackCategory::Other, + _ => { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Usage: /feedback " + .to_string(), + ); + } }; - let cell = Self::rename_confirmation_cell(&name, self.thread_id); - self.add_boxed_history(Box::new(cell)); - self.request_redraw(); self.app_event_tx - .send(AppEvent::CodexOp(Op::SetThreadName { name })); - self.bottom_pane.drain_pending_submission_state(); + .send(AppEvent::OpenFeedbackConsent { category }); + QueueReplayControl::ResumeWhenIdle } - SlashCommand::Plan if !trimmed.is_empty() => { - self.dispatch_command(cmd); - if self.active_mode_kind() != ModeKind::Plan { - return; + SlashCommand::Model => match SlashCommandInvocation::parse_args( + &args_message.text, + "Usage: /model [default|none|minimal|low|medium|high|xhigh] [plan-only|all-modes]", + ) { + Ok(args) => match Self::parse_model_selection_args(&args) { + Ok((model, effort, scope)) => { + self.apply_model_selection(model, effort, scope); + QueueReplayControl::Continue + } + Err(message) => { + self.restore_invalid_inline_slash_command(cmd, args_message, message) + } + }, + Err(message) => { + self.restore_invalid_inline_slash_command(cmd, args_message, message) } - let Some((prepared_args, prepared_elements)) = self - .bottom_pane - .prepare_inline_args_submission(/*record_history*/ true) - else { - return; - }; - let local_images = self - .bottom_pane - .take_recent_submission_images_with_placeholders(); - let remote_image_urls = self.take_remote_image_urls(); - let user_message = UserMessage { - text: prepared_args, - local_images, - remote_image_urls, - text_elements: prepared_elements, - mention_bindings: self.bottom_pane.take_recent_submission_mention_bindings(), + }, + SlashCommand::Personality => { + let args = match SlashCommandInvocation::parse_args( + &args_message.text, + "Usage: /personality ", + ) { + Ok(args) => args, + Err(message) => { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + message, + ); + } }; - if self.is_session_configured() { - self.reasoning_buffer.clear(); - self.full_reasoning_buffer.clear(); - self.set_status_header(String::from("Working")); - self.submit_user_message(user_message); - } else { - self.queue_user_message(user_message); + if args.len() != 1 { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Usage: /personality ".to_string(), + ); + } + let personality = match args[0].to_ascii_lowercase().as_str() { + "none" => Personality::None, + "friendly" => Personality::Friendly, + "pragmatic" => Personality::Pragmatic, + _ => { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Usage: /personality ".to_string(), + ); + } + }; + if !self.current_model_supports_personality() { + let current_model = self.current_model(); + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + format!( + "Current model ({current_model}) doesn't support personalities. Try /model to pick a different model." + ), + ); } + self.app_event_tx + .send(AppEvent::CodexOp(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + approvals_reviewer: None, + sandbox_policy: None, + model: None, + effort: None, + summary: None, + service_tier: None, + collaboration_mode: None, + windows_sandbox_level: None, + personality: Some(personality), + })); + self.app_event_tx + .send(AppEvent::UpdatePersonality(personality)); + self.app_event_tx + .send(AppEvent::PersistPersonalitySelection { personality }); + QueueReplayControl::ResumeWhenIdle } - SlashCommand::Review if !trimmed.is_empty() => { - let Some((prepared_args, _prepared_elements)) = self - .bottom_pane - .prepare_inline_args_submission(/*record_history*/ false) - else { - return; + SlashCommand::Rename => { + self.session_telemetry + .counter("codex.thread.rename", /*inc*/ 1, &[]); + let args = match SlashCommandInvocation::parse_args( + &args_message.text, + "Thread name cannot be empty.", + ) { + Ok(args) => args, + Err(message) => { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + message, + ); + } }; - self.submit_op(Op::Review { - review_request: ReviewRequest { - target: ReviewTarget::Custom { - instructions: prepared_args, - }, - user_facing_hint: None, - }, - }); - self.bottom_pane.drain_pending_submission_state(); + let Some(name) = codex_core::util::normalize_thread_name(&args.join(" ")) else { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Thread name cannot be empty.".to_string(), + ); + }; + let cell = Self::rename_confirmation_cell(&name, self.thread_id); + self.add_boxed_history(Box::new(cell)); + self.request_redraw(); + self.app_event_tx + .send(AppEvent::CodexOp(Op::SetThreadName { name })); + QueueReplayControl::Continue } - SlashCommand::SandboxReadRoot if !trimmed.is_empty() => { - let Some((prepared_args, _prepared_elements)) = self - .bottom_pane - .prepare_inline_args_submission(/*record_history*/ false) - else { - return; + SlashCommand::Plan => { + self.dispatch_command(cmd); + if self.active_mode_kind() == ModeKind::Plan { + self.submit_plan_user_message(args_message); + } else { + self.restore_user_message_to_composer(Self::inline_slash_command_draft( + cmd, + args_message, + )); + } + QueueReplayControl::Stop + } + SlashCommand::Review => match SlashCommandInvocation::parse_args( + &args_message.text, + "Usage: /review [uncommitted|branch |commit [title]|]", + ) { + Ok(args) => match Self::parse_review_request(&args) { + Ok(review_request) => { + self.submit_op(Op::Review { review_request }); + QueueReplayControl::Stop + } + Err(message) => { + self.restore_invalid_inline_slash_command(cmd, args_message, message) + } + }, + Err(message) => { + self.restore_invalid_inline_slash_command(cmd, args_message, message) + } + }, + SlashCommand::Resume => { + let args = match SlashCommandInvocation::parse_args( + &args_message.text, + "Usage: /resume [--path ]", + ) { + Ok(args) => args, + Err(message) => { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + message, + ); + } }; + if !(args.len() == 1 + || (args.len() == 3 && matches!(args[1].as_str(), "--path" | "--path-base64"))) + { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Usage: /resume [--path ]".to_string(), + ); + } + match ThreadId::from_string(&args[0]) { + Ok(thread_id) => { + if let Some(path) = args.get(2) { + let path = match args[1].as_str() { + "--path" => PathBuf::from(path), + "--path-base64" => { + let Ok(bytes) = + base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(path) + else { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Invalid encoded rollout path for /resume.".to_string(), + ); + }; + #[cfg(unix)] + let path = PathBuf::from(OsString::from_vec(bytes)); + #[cfg(windows)] + let path = { + let mut chunks = bytes.chunks_exact(2); + if !chunks.remainder().is_empty() { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Invalid encoded rollout path for /resume." + .to_string(), + ); + } + let wide = chunks + .by_ref() + .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) + .collect::>(); + PathBuf::from(OsString::from_wide(&wide)) + }; + path + } + _ => unreachable!("validated resume path flag"), + }; + self.app_event_tx.send(AppEvent::ResumeSessionTarget( + crate::resume_picker::SessionTarget { path, thread_id }, + )); + } else { + self.app_event_tx.send(AppEvent::ResumeSession(thread_id)); + } + QueueReplayControl::Stop + } + Err(_) => self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Usage: /resume [--path ]".to_string(), + ), + } + } + SlashCommand::SandboxReadRoot => { self.app_event_tx .send(AppEvent::BeginWindowsSandboxGrantReadRoot { - path: prepared_args, + path: args_message.text, }); - self.bottom_pane.drain_pending_submission_state(); + QueueReplayControl::Stop + } + SlashCommand::Settings => { + let args = match SlashCommandInvocation::parse_args( + &args_message.text, + "Usage: /settings [default|]", + ) { + Ok(args) => args, + Err(message) => { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + message, + ); + } + }; + let Some(kind_name) = args.first() else { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Usage: /settings [default|]".to_string(), + ); + }; + let kind = match kind_name.to_ascii_lowercase().as_str() { + "microphone" => RealtimeAudioDeviceKind::Microphone, + "speaker" => RealtimeAudioDeviceKind::Speaker, + _ => { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Usage: /settings [default|]" + .to_string(), + ); + } + }; + let name = match args.get(1..).map(|rest| rest.join(" ")) { + None => None, + Some(device_name) if device_name.is_empty() || device_name == "default" => None, + Some(device_name) => Some(device_name), + }; + self.app_event_tx + .send(AppEvent::PersistRealtimeAudioDeviceSelection { kind, name }); + QueueReplayControl::ResumeWhenIdle + } + SlashCommand::Skills => { + let args = match SlashCommandInvocation::parse_args( + &args_message.text, + "Usage: /skills ", + ) { + Ok(args) => args, + Err(message) => { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + message, + ); + } + }; + if args.len() != 1 { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Usage: /skills ".to_string(), + ); + } + match args[0].to_ascii_lowercase().as_str() { + "list" => { + self.open_skills_list(); + QueueReplayControl::ResumeWhenIdle + } + "manage" => { + self.app_event_tx.send(AppEvent::OpenManageSkillsPopup); + QueueReplayControl::ResumeWhenIdle + } + _ => self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Usage: /skills ".to_string(), + ), + } + } + SlashCommand::Statusline => { + let item_ids = match SlashCommandInvocation::parse_args( + &args_message.text, + "Usage: /statusline ... | /statusline none", + ) { + Ok(item_ids) => item_ids, + Err(message) => { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + message, + ); + } + }; + let items = if item_ids.len() == 1 && item_ids[0].eq_ignore_ascii_case("none") { + Vec::new() + } else { + match item_ids + .iter() + .map(|item_id| item_id.parse::()) + .collect::, _>>() + { + Ok(items) => items, + Err(_) => { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Usage: /statusline ... | /statusline none".to_string(), + ); + } + } + }; + self.app_event_tx.send(AppEvent::StatusLineSetup { items }); + QueueReplayControl::ResumeWhenIdle + } + SlashCommand::Theme => { + let args = match SlashCommandInvocation::parse_args( + &args_message.text, + "Usage: /theme ", + ) { + Ok(args) => args, + Err(message) => { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + message, + ); + } + }; + if args.len() != 1 + || crate::render::highlight::resolve_theme_by_name( + &args[0], + Some(&self.config.codex_home), + ) + .is_none() + { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Usage: /theme ".to_string(), + ); + } + self.app_event_tx.send(AppEvent::SyntaxThemeSelected { + name: args[0].clone(), + }); + QueueReplayControl::ResumeWhenIdle + } + SlashCommand::New + | SlashCommand::Fork + | SlashCommand::Init + | SlashCommand::Compact + | SlashCommand::Diff + | SlashCommand::Copy + | SlashCommand::Mention + | SlashCommand::Status + | SlashCommand::DebugConfig + | SlashCommand::Mcp + | SlashCommand::Apps + | SlashCommand::Logout + | SlashCommand::Quit + | SlashCommand::Exit + | SlashCommand::Rollout + | SlashCommand::Ps + | SlashCommand::Stop + | SlashCommand::Clear + | SlashCommand::Realtime + | SlashCommand::TestApproval + | SlashCommand::MemoryDrop + | SlashCommand::MemoryUpdate + | SlashCommand::ElevateSandbox => self.restore_invalid_inline_slash_command( + cmd, + args_message, + format!("`/{}` does not accept inline arguments.", cmd.command()), + ), + } + } + + fn restore_invalid_inline_slash_command( + &mut self, + cmd: SlashCommand, + args_message: UserMessage, + message: String, + ) -> QueueReplayControl { + self.add_error_message(message); + self.restore_user_message_to_composer(Self::inline_slash_command_draft(cmd, args_message)); + QueueReplayControl::Stop + } + + fn take_prepared_submission_user_message( + &mut self, + text: String, + text_elements: Vec, + ) -> UserMessage { + UserMessage { + text, + local_images: self + .bottom_pane + .take_recent_submission_images_with_placeholders(), + remote_image_urls: self.take_remote_image_urls(), + text_elements, + mention_bindings: self.bottom_pane.take_recent_submission_mention_bindings(), + } + } + + fn inline_slash_command_draft(cmd: SlashCommand, args_message: UserMessage) -> UserMessage { + let prefix = format!("/{}", cmd.command()); + let UserMessage { + text, + local_images, + remote_image_urls, + text_elements, + mention_bindings, + } = args_message; + + if text.is_empty() { + return UserMessage { + text: prefix, + local_images, + remote_image_urls, + text_elements, + mention_bindings, + }; + } + + let mut draft_text = format!("{prefix} "); + let offset = draft_text.len(); + draft_text.push_str(&text); + let text_elements = text_elements + .into_iter() + .map(|element| { + let start = element.byte_range.start + offset; + let end = element.byte_range.end + offset; + element.map_range(|_| ByteRange { start, end }) + }) + .collect(); + + UserMessage { + text: draft_text, + local_images, + remote_image_urls, + text_elements, + mention_bindings, + } + } + + fn approval_preset_draft_for_reviewer( + preset_id: &str, + approvals_reviewer: ApprovalsReviewer, + flags: &[&str], + ) -> UserMessage { + let mut args = vec![preset_id.to_string()]; + if approvals_reviewer == ApprovalsReviewer::GuardianSubagent { + args.push("--smart-approvals".to_string()); + } + args.extend(flags.iter().map(|flag| (*flag).to_string())); + SlashCommandInvocation::with_args(SlashCommand::Approvals, args).into_user_message() + } + + fn approval_preset_draft(preset_id: &str, flags: &[&str]) -> UserMessage { + Self::approval_preset_draft_for_reviewer(preset_id, ApprovalsReviewer::User, flags) + } + + #[cfg(all(not(target_os = "linux"), feature = "voice-input"))] + fn settings_device_draft(kind: RealtimeAudioDeviceKind, name: Option<&str>) -> UserMessage { + let kind_name = match kind { + RealtimeAudioDeviceKind::Microphone => "microphone", + RealtimeAudioDeviceKind::Speaker => "speaker", + }; + let device_name = name.unwrap_or("default"); + SlashCommandInvocation::with_args(SlashCommand::Settings, [kind_name, device_name]) + .into_user_message() + } + + fn queue_current_inline_bare_slash_command(&mut self, cmd: SlashCommand) { + let Some((prepared_args, prepared_elements)) = + self.bottom_pane.prepare_inline_args_submission(false) + else { + return; + }; + let args_message = + self.take_prepared_submission_user_message(prepared_args, prepared_elements); + self.queue_user_message(Self::inline_slash_command_draft(cmd, args_message)); + } + + fn model_selection_draft( + model: &str, + effort: Option, + scope: ModelSelectionScope, + ) -> UserMessage { + let mut args = vec![model.to_string()]; + if let Some(token) = Self::model_reasoning_effort_token(effort) { + args.push(token.to_string()); + } + if let Some(token) = Self::model_selection_scope_token(scope) { + args.push(token.to_string()); + } + SlashCommandInvocation::with_args(SlashCommand::Model, args).into_user_message() + } + + fn apply_model_selection( + &mut self, + model: String, + effort: Option, + scope: ModelSelectionScope, + ) { + match scope { + ModelSelectionScope::Global => { + self.set_model(&model); + self.set_reasoning_effort(effort); + self.app_event_tx.send(AppEvent::UpdateModel(model.clone())); + self.app_event_tx + .send(AppEvent::UpdateReasoningEffort(effort)); + self.app_event_tx + .send(AppEvent::PersistModelSelection { model, effort }); + } + ModelSelectionScope::PlanOnly => { + self.set_model(&model); + self.set_plan_mode_reasoning_effort(effort); + self.app_event_tx.send(AppEvent::UpdateModel(model)); + self.app_event_tx + .send(AppEvent::UpdatePlanModeReasoningEffort(effort)); + self.app_event_tx + .send(AppEvent::PersistPlanModeReasoningEffort(effort)); + } + ModelSelectionScope::AllModes => { + self.set_model(&model); + self.set_reasoning_effort(effort); + self.set_plan_mode_reasoning_effort(effort); + self.app_event_tx.send(AppEvent::UpdateModel(model.clone())); + self.app_event_tx + .send(AppEvent::UpdateReasoningEffort(effort)); + self.app_event_tx + .send(AppEvent::UpdatePlanModeReasoningEffort(effort)); + self.app_event_tx + .send(AppEvent::PersistPlanModeReasoningEffort(effort)); + self.app_event_tx + .send(AppEvent::PersistModelSelection { model, effort }); + } + } + } + + fn review_request_draft(review_request: &ReviewRequest) -> UserMessage { + match &review_request.target { + ReviewTarget::UncommittedChanges => { + SlashCommandInvocation::with_args(SlashCommand::Review, ["uncommitted"]) + .into_user_message() + } + ReviewTarget::BaseBranch { branch } => { + SlashCommandInvocation::with_args(SlashCommand::Review, ["branch", branch.as_str()]) + .into_user_message() + } + ReviewTarget::Commit { sha, title } => { + let mut args = vec!["commit".to_string(), sha.clone()]; + if let Some(title) = title.as_deref().map(str::trim) + && !title.is_empty() + { + args.push(title.to_string()); + } + SlashCommandInvocation::with_args(SlashCommand::Review, args).into_user_message() + } + ReviewTarget::Custom { instructions } => { + SlashCommandInvocation::with_args(SlashCommand::Review, [instructions.as_str()]) + .into_user_message() + } + } + } + + pub(crate) fn resume_selection_draft( + target_session: &crate::resume_picker::SessionTarget, + ) -> UserMessage { + let (path_flag, path_value) = if let Some(path) = target_session.path.to_str() { + ("--path".to_string(), path.to_string()) + } else { + #[cfg(unix)] + let bytes = target_session.path.as_os_str().as_bytes().to_vec(); + #[cfg(windows)] + let bytes = target_session + .path + .as_os_str() + .encode_wide() + .flat_map(u16::to_le_bytes) + .collect::>(); + + ( + "--path-base64".to_string(), + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes), + ) + }; + SlashCommandInvocation::with_args( + SlashCommand::Resume, + [target_session.thread_id.to_string(), path_flag, path_value], + ) + .into_user_message() + } + + pub(crate) fn handle_serialized_slash_command(&mut self, draft: UserMessage) { + let Some((cmd, _, _)) = self.parse_builtin_slash_command(&draft.text) else { + if self.reject_unavailable_builtin_slash_command(&draft) { + return; + } + self.add_error_message(format!("Failed to handle slash command: {}", draft.text)); + self.restore_user_message_to_composer(draft); + return; + }; + if !matches!(cmd.execution_kind(), SlashCommandExecutionKind::Immediate) + && (self.bottom_pane.is_task_running() || !self.queued_user_messages.is_empty()) + { + self.queue_user_message(draft); + return; + } + let _ = self.execute_serialized_slash_command(draft); + } + + fn submit_plan_user_message(&mut self, user_message: UserMessage) { + if self.is_session_configured() { + self.reasoning_buffer.clear(); + self.full_reasoning_buffer.clear(); + self.set_status_header(String::from("Working")); + self.submit_user_message(user_message); + } else { + self.queue_user_message(user_message); + } + } + + fn parse_builtin_slash_command<'a>( + &self, + text: &'a str, + ) -> Option<(SlashCommand, &'a str, usize)> { + let (name, rest, rest_offset) = parse_slash_name(text)?; + let cmd = find_builtin_command(name, self.builtin_command_flags())?; + Some((cmd, rest, rest_offset)) + } + + fn builtin_command_flags(&self) -> BuiltinCommandFlags { + BuiltinCommandFlags { + collaboration_modes_enabled: self.collaboration_modes_enabled(), + connectors_enabled: self.connectors_enabled(), + fast_command_enabled: self.fast_mode_enabled(), + personality_command_enabled: self.config.features.enabled(Feature::Personality), + realtime_conversation_enabled: self.realtime_conversation_enabled(), + audio_device_selection_enabled: self.realtime_audio_device_selection_enabled(), + allow_elevate_sandbox: { + #[cfg(target_os = "windows")] + { + codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED + && matches!( + WindowsSandboxLevel::from_config(&self.config), + WindowsSandboxLevel::RestrictedToken + ) + } + #[cfg(not(target_os = "windows"))] + { + false + } + }, + } + } + + fn is_known_slash_draft(&self, draft: &UserMessage) -> bool { + let Some((name, _, _)) = parse_slash_name(&draft.text) else { + return false; + }; + !name.contains('/') && SlashCommand::from_str(name).is_ok() + } + + fn reject_unavailable_builtin_slash_command(&mut self, user_message: &UserMessage) -> bool { + let Some((name, _, _)) = parse_slash_name(&user_message.text) else { + return false; + }; + if name.contains('/') + || self + .parse_builtin_slash_command(&user_message.text) + .is_some() + || SlashCommand::from_str(name).is_err() + { + return false; + } + + self.add_error_message(format!("/{name} is not available in this session.")); + self.restore_user_message_to_composer(user_message.clone()); + true + } + + fn execute_serialized_slash_command(&mut self, draft: UserMessage) -> QueueReplayControl { + let preview = draft.text.clone(); + let Some((cmd, rest, rest_offset)) = self.parse_builtin_slash_command(&preview) else { + if self.reject_unavailable_builtin_slash_command(&draft) { + return QueueReplayControl::Stop; + } + self.add_error_message(format!("Failed to replay queued slash command: {preview}")); + self.restore_user_message_to_composer(draft); + return QueueReplayControl::Stop; + }; + if rest.trim().is_empty() { + if cmd.requires_interaction() { + self.add_error_message(format!( + "Failed to replay queued slash command requiring interaction: {preview}" + )); + self.restore_user_message_to_composer(draft); + return QueueReplayControl::Stop; } - _ => self.dispatch_command(cmd), + let replay_control = self.dispatch_command(cmd); + if replay_control == QueueReplayControl::Continue + && self.bottom_pane.no_modal_or_popup_active() + { + return QueueReplayControl::Continue; + } + return replay_control; + } + let args_message = Self::slash_command_args_message_from_draft(draft, rest_offset); + self.execute_slash_command_with_args(cmd, args_message) + } + + pub(crate) fn maybe_resume_queued_inputs_when_idle(&mut self) { + if !self.resume_queued_inputs_when_idle + || self.suppress_queue_autosend + || self.bottom_pane.is_task_running() + || !self.bottom_pane.no_modal_or_popup_active() + { + return; + } + + self.resume_queued_inputs_when_idle = false; + self.drain_queued_inputs_until_blocked(); + } + + fn slash_command_args_message_from_draft( + draft: UserMessage, + rest_offset: usize, + ) -> UserMessage { + let UserMessage { + text, + local_images, + remote_image_urls, + text_elements, + mention_bindings, + } = draft; + let rest = &text[rest_offset..]; + let trimmed_start = rest.len() - rest.trim_start().len(); + let trimmed_rest = rest.trim(); + let args_start = rest_offset + trimmed_start; + let args_end = args_start + trimmed_rest.len(); + let text_elements = text_elements + .into_iter() + .filter_map(|element| { + if element.byte_range.end <= args_start || element.byte_range.start >= args_end { + return None; + } + let start = element.byte_range.start.saturating_sub(args_start); + let end = element.byte_range.end.min(args_end) - args_start; + (start < end).then_some(element.map_range(|_| ByteRange { start, end })) + }) + .collect(); + + UserMessage { + text: trimmed_rest.to_string(), + local_images, + remote_image_urls, + text_elements, + mention_bindings, + } + } + + fn parse_review_request(args: &[String]) -> Result { + const REVIEW_USAGE: &str = + "Usage: /review [uncommitted|branch |commit [title]|]"; + let target = if args.len() == 1 + && matches!( + args[0].to_ascii_lowercase().as_str(), + "uncommitted" | "current" | "current changes" | "current-changes" + ) { + ReviewTarget::UncommittedChanges + } else if let Some(keyword) = args.first() { + match keyword.to_ascii_lowercase().as_str() { + "branch" if args.len() > 1 => ReviewTarget::BaseBranch { + branch: args[1..].join(" "), + }, + "branch" => { + return Err(REVIEW_USAGE.to_string()); + } + "commit" if args.len() > 1 => { + let sha = args[1].clone(); + let title = (!args[2..].is_empty()).then(|| args[2..].join(" ")); + ReviewTarget::Commit { sha, title } + } + "commit" => { + return Err(REVIEW_USAGE.to_string()); + } + _ => ReviewTarget::Custom { + instructions: args.join(" "), + }, + } + } else { + return Err(REVIEW_USAGE.to_string()); + }; + + Ok(ReviewRequest { + target, + user_facing_hint: None, + }) + } + + fn parse_model_selection_args( + args: &[String], + ) -> Result<(String, Option, ModelSelectionScope), String> { + const MODEL_USAGE: &str = "Usage: /model [default|none|minimal|low|medium|high|xhigh] [plan-only|all-modes]"; + let Some(model) = args.first() else { + return Err(MODEL_USAGE.to_string()); + }; + + let mut effort = None; + let mut saw_effort = false; + let mut scope = ModelSelectionScope::Global; + let mut saw_scope = false; + for token in &args[1..] { + if let Some(parsed_effort) = Self::parse_model_reasoning_effort_token(token) { + if saw_effort { + return Err(MODEL_USAGE.to_string()); + } + saw_effort = true; + effort = parsed_effort; + continue; + } + if let Some(parsed_scope) = Self::parse_model_scope_token(token) { + if saw_scope { + return Err(MODEL_USAGE.to_string()); + } + saw_scope = true; + scope = parsed_scope; + continue; + } + return Err(MODEL_USAGE.to_string()); + } + + Ok((model.clone(), effort, scope)) + } + + fn parse_model_reasoning_effort_token(token: &str) -> Option> { + match token.to_ascii_lowercase().as_str() { + "default" => Some(None), + "none" => Some(Some(ReasoningEffortConfig::None)), + "minimal" => Some(Some(ReasoningEffortConfig::Minimal)), + "low" => Some(Some(ReasoningEffortConfig::Low)), + "medium" => Some(Some(ReasoningEffortConfig::Medium)), + "high" => Some(Some(ReasoningEffortConfig::High)), + "xhigh" => Some(Some(ReasoningEffortConfig::XHigh)), + _ => None, + } + } + + fn parse_model_scope_token(token: &str) -> Option { + match token.to_ascii_lowercase().as_str() { + "plan-only" => Some(ModelSelectionScope::PlanOnly), + "all-modes" => Some(ModelSelectionScope::AllModes), + "global" => Some(ModelSelectionScope::Global), + _ => None, + } + } + + fn model_reasoning_effort_token(effort: Option) -> Option<&'static str> { + match effort { + Some(ReasoningEffortConfig::None) => Some("none"), + Some(ReasoningEffortConfig::Minimal) => Some("minimal"), + Some(ReasoningEffortConfig::Low) => Some("low"), + Some(ReasoningEffortConfig::Medium) => Some("medium"), + Some(ReasoningEffortConfig::High) => Some("high"), + Some(ReasoningEffortConfig::XHigh) => Some("xhigh"), + None => None, + } + } + + fn model_selection_scope_token(scope: ModelSelectionScope) -> Option<&'static str> { + match scope { + ModelSelectionScope::Global => None, + ModelSelectionScope::PlanOnly => Some("plan-only"), + ModelSelectionScope::AllModes => Some("all-modes"), } } @@ -5642,18 +7040,44 @@ impl ChatWidget { } } - // If idle and there are queued inputs, submit exactly one to start the next turn. - pub(crate) fn maybe_send_next_queued_input(&mut self) { + // If idle and there are queued inputs, dispatch queued work in order until a turn starts or + // a popup takes focus. + pub(crate) fn drain_queued_inputs_until_blocked(&mut self) { if self.suppress_queue_autosend { return; } if self.bottom_pane.is_task_running() { return; } - if let Some(user_message) = self.queued_user_messages.pop_front() { - self.submit_user_message(user_message); + if !self.bottom_pane.no_modal_or_popup_active() { + self.resume_queued_inputs_when_idle = !self.queued_user_messages.is_empty(); + self.refresh_pending_input_preview(); + return; } - // Update the list to reflect the remaining queued messages (if any). + let mut resume_when_idle = false; + while !self.bottom_pane.is_task_running() { + let Some(queued_message) = self.queued_user_messages.pop_front() else { + break; + }; + let replay_control = if self.is_known_slash_draft(&queued_message) { + self.execute_serialized_slash_command(queued_message) + } else { + self.submit_user_message(queued_message); + QueueReplayControl::Stop + }; + if replay_control == QueueReplayControl::Stop { + break; + } + if replay_control == QueueReplayControl::ResumeWhenIdle + || !self.bottom_pane.no_modal_or_popup_active() + { + resume_when_idle = true; + break; + } + } + self.resume_queued_inputs_when_idle = resume_when_idle + && !self.bottom_pane.is_task_running() + && !self.queued_user_messages.is_empty(); self.refresh_pending_input_preview(); } @@ -5662,7 +7086,7 @@ impl ChatWidget { let queued_messages: Vec = self .queued_user_messages .iter() - .map(|m| m.text.clone()) + .map(|message| message.text.clone()) .collect(); let pending_steers: Vec = self .pending_steers @@ -6298,21 +7722,13 @@ impl ChatWidget { let name = Self::personality_label(personality).to_string(); let description = Some(Self::personality_description(personality).to_string()); let actions: Vec = vec![Box::new(move |tx| { - tx.send(AppEvent::CodexOp(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - model: None, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - windows_sandbox_level: None, - personality: Some(personality), - })); - tx.send(AppEvent::UpdatePersonality(personality)); - tx.send(AppEvent::PersistPersonalitySelection { personality }); + tx.send(AppEvent::HandleSlashCommandDraft( + SlashCommandInvocation::with_args( + SlashCommand::Personality, + [Self::personality_label(personality).to_ascii_lowercase()], + ) + .into_user_message(), + )); })]; SelectionItem { name, @@ -6406,7 +7822,9 @@ impl ChatWidget { description: Some("Use your operating system default device.".to_string()), is_current: current_selection.is_none(), actions: vec![Box::new(move |tx| { - tx.send(AppEvent::PersistRealtimeAudioDeviceSelection { kind, name: None }); + tx.send(AppEvent::HandleSlashCommandDraft( + Self::settings_device_draft(kind, None), + )); })], dismiss_on_select: true, ..Default::default() @@ -6428,10 +7846,9 @@ impl ChatWidget { items.extend(device_names.into_iter().map(|device_name| { let persisted_name = device_name.clone(); let actions: Vec = vec![Box::new(move |tx| { - tx.send(AppEvent::PersistRealtimeAudioDeviceSelection { - kind, - name: Some(persisted_name.clone()), - }); + tx.send(AppEvent::HandleSlashCommandDraft( + Self::settings_device_draft(kind, Some(persisted_name.as_str())), + )); })]; SelectionItem { is_current: current_selection.as_deref() == Some(device_name.as_str()), @@ -6698,8 +8115,15 @@ impl ChatWidget { .map(|mask| { let name = mask.name.clone(); let is_current = current_kind == mask.mode; + let command_name = name.to_ascii_lowercase(); let actions: Vec = vec![Box::new(move |tx| { - tx.send(AppEvent::UpdateCollaborationMode(mask.clone())); + tx.send(AppEvent::HandleSlashCommandDraft( + SlashCommandInvocation::with_args( + SlashCommand::Collab, + [command_name.clone()], + ) + .into_user_message(), + )); })]; SelectionItem { name, @@ -6734,12 +8158,13 @@ impl ChatWidget { return; } - tx.send(AppEvent::UpdateModel(model_for_action.clone())); - tx.send(AppEvent::UpdateReasoningEffort(effort_for_action)); - tx.send(AppEvent::PersistModelSelection { - model: model_for_action.clone(), - effort: effort_for_action, - }); + tx.send(AppEvent::HandleSlashCommandDraft( + Self::model_selection_draft( + &model_for_action, + effort_for_action, + ModelSelectionScope::Global, + ), + )); })] } @@ -6806,20 +8231,15 @@ impl ChatWidget { let plan_only_actions: Vec = vec![Box::new({ let model = model.clone(); move |tx| { - tx.send(AppEvent::UpdateModel(model.clone())); - tx.send(AppEvent::UpdatePlanModeReasoningEffort(effort)); - tx.send(AppEvent::PersistPlanModeReasoningEffort(effort)); + tx.send(AppEvent::HandleSlashCommandDraft( + Self::model_selection_draft(&model, effort, ModelSelectionScope::PlanOnly), + )); } })]; let all_modes_actions: Vec = vec![Box::new(move |tx| { - tx.send(AppEvent::UpdateModel(model.clone())); - tx.send(AppEvent::UpdateReasoningEffort(effort)); - tx.send(AppEvent::UpdatePlanModeReasoningEffort(effort)); - tx.send(AppEvent::PersistPlanModeReasoningEffort(effort)); - tx.send(AppEvent::PersistModelSelection { - model: model.clone(), - effort, - }); + tx.send(AppEvent::HandleSlashCommandDraft( + Self::model_selection_draft(&model, effort, ModelSelectionScope::AllModes), + )); })]; self.bottom_pane.show_selection_view(SelectionViewParams { @@ -6907,7 +8327,13 @@ impl ChatWidget { effort: selected_effort, }); } else { - self.apply_model_and_effort(selected_model, selected_effort); + self.app_event_tx.send(AppEvent::HandleSlashCommandDraft( + Self::model_selection_draft( + selected_model.as_str(), + selected_effort, + ModelSelectionScope::Global, + ), + )); } return; } @@ -6982,12 +8408,13 @@ impl ChatWidget { effort: choice_effort, }); } else { - tx.send(AppEvent::UpdateModel(model_for_action.clone())); - tx.send(AppEvent::UpdateReasoningEffort(choice_effort)); - tx.send(AppEvent::PersistModelSelection { - model: model_for_action.clone(), - effort: choice_effort, - }); + tx.send(AppEvent::HandleSlashCommandDraft( + Self::model_selection_draft( + &model_for_action, + choice_effort, + ModelSelectionScope::Global, + ), + )); } })]; @@ -7027,22 +8454,6 @@ impl ChatWidget { } } - fn apply_model_and_effort_without_persist( - &self, - model: String, - effort: Option, - ) { - self.app_event_tx.send(AppEvent::UpdateModel(model)); - self.app_event_tx - .send(AppEvent::UpdateReasoningEffort(effort)); - } - - fn apply_model_and_effort(&self, model: String, effort: Option) { - self.apply_model_and_effort_without_persist(model.clone(), effort); - self.app_event_tx - .send(AppEvent::PersistModelSelection { model, effort }); - } - /// Open the permissions popup (alias for /permissions). pub(crate) fn open_approvals_popup(&mut self) { self.open_permissions_popup(); @@ -7130,15 +8541,18 @@ impl ChatWidget { ) { vec![Box::new(move |tx| { - tx.send(AppEvent::EnableWindowsSandboxForAgentMode { - preset: preset_clone.clone(), - mode: WindowsSandboxEnableMode::Elevated, - }); + tx.send(AppEvent::HandleSlashCommandDraft( + Self::approval_preset_draft( + preset_clone.id, + &["--enable-windows-sandbox=elevated"], + ), + )); })] } else { vec![Box::new(move |tx| { tx.send(AppEvent::OpenWindowsSandboxEnablePrompt { preset: preset_clone.clone(), + approvals_reviewer: ApprovalsReviewer::User, }); })] } @@ -7149,36 +8563,22 @@ impl ChatWidget { vec![Box::new(move |tx| { tx.send(AppEvent::OpenWorldWritableWarningConfirmation { preset: Some(preset_clone.clone()), + approvals_reviewer: Some(ApprovalsReviewer::User), sample_paths: sample_paths.clone(), extra_count, failed_scan, }); })] } else { - Self::approval_preset_actions( - preset.approval, - preset.sandbox.clone(), - base_name.clone(), - ApprovalsReviewer::User, - ) + Self::approval_preset_actions(preset.id, &[]) } } #[cfg(not(target_os = "windows"))] { - Self::approval_preset_actions( - preset.approval, - preset.sandbox.clone(), - base_name.clone(), - ApprovalsReviewer::User, - ) + Self::approval_preset_actions(preset.id, &[]) } } else { - Self::approval_preset_actions( - preset.approval, - preset.sandbox.clone(), - base_name.clone(), - ApprovalsReviewer::User, - ) + Self::approval_preset_actions(preset.id, &[]) }; if preset.id == "auto" { items.push(SelectionItem { @@ -7193,6 +8593,64 @@ impl ChatWidget { }); if guardian_approval_enabled { + let guardian_preset = preset.clone(); + let guardian_actions: Vec = { + #[cfg(target_os = "windows")] + { + if WindowsSandboxLevel::from_config(&self.config) + == WindowsSandboxLevel::Disabled + { + let preset_clone = guardian_preset.clone(); + if codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED + && codex_core::windows_sandbox::sandbox_setup_is_complete( + self.config.codex_home.as_path(), + ) + { + Self::approval_preset_actions_for_reviewer( + guardian_preset.id, + ApprovalsReviewer::GuardianSubagent, + &["--enable-windows-sandbox=elevated"], + ) + } else { + vec![Box::new(move |tx| { + tx.send(AppEvent::OpenWindowsSandboxEnablePrompt { + preset: preset_clone.clone(), + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + }); + })] + } + } else if let Some((sample_paths, extra_count, failed_scan)) = + self.world_writable_warning_details() + { + let preset_clone = guardian_preset.clone(); + vec![Box::new(move |tx| { + tx.send(AppEvent::OpenWorldWritableWarningConfirmation { + preset: Some(preset_clone.clone()), + approvals_reviewer: Some( + ApprovalsReviewer::GuardianSubagent, + ), + sample_paths: sample_paths.clone(), + extra_count, + failed_scan, + }); + })] + } else { + Self::approval_preset_actions_for_reviewer( + guardian_preset.id, + ApprovalsReviewer::GuardianSubagent, + &[], + ) + } + } + #[cfg(not(target_os = "windows"))] + { + Self::approval_preset_actions_for_reviewer( + guardian_preset.id, + ApprovalsReviewer::GuardianSubagent, + &[], + ) + } + }; items.push(SelectionItem { name: "Guardian Approvals".to_string(), description: Some( @@ -7205,12 +8663,7 @@ impl ChatWidget { current_sandbox, &preset, ), - actions: Self::approval_preset_actions( - preset.approval, - preset.sandbox.clone(), - "Guardian Approvals".to_string(), - ApprovalsReviewer::GuardianSubagent, - ), + actions: guardian_actions, dismiss_on_select: true, disabled_reason: approval_disabled_reason .or_else(|| guardian_disabled_reason(true)), @@ -7260,7 +8713,7 @@ impl ChatWidget { let name = spec.stage.experimental_menu_name()?; let description = spec.stage.experimental_menu_description()?; Some(ExperimentalFeatureItem { - feature: spec.id, + key: spec.key.to_string(), name: name.to_string(), description: description.to_string(), enabled: self.config.features.enabled(spec.id), @@ -7273,35 +8726,25 @@ impl ChatWidget { } fn approval_preset_actions( - approval: AskForApproval, - sandbox: SandboxPolicy, - label: String, + preset_id: &'static str, + flags: &'static [&'static str], + ) -> Vec { + vec![Box::new(move |tx| { + tx.send(AppEvent::HandleSlashCommandDraft( + Self::approval_preset_draft(preset_id, flags), + )); + })] + } + + fn approval_preset_actions_for_reviewer( + preset_id: &'static str, approvals_reviewer: ApprovalsReviewer, + flags: &'static [&'static str], ) -> Vec { vec![Box::new(move |tx| { - let sandbox_clone = sandbox.clone(); - tx.send(AppEvent::CodexOp(Op::OverrideTurnContext { - cwd: None, - approval_policy: Some(approval), - approvals_reviewer: Some(approvals_reviewer), - sandbox_policy: Some(sandbox_clone.clone()), - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, - })); - tx.send(AppEvent::UpdateAskForApprovalPolicy(approval)); - tx.send(AppEvent::UpdateSandboxPolicy(sandbox_clone)); - tx.send(AppEvent::UpdateApprovalsReviewer(approvals_reviewer)); - tx.send(AppEvent::InsertHistoryCell(Box::new( - history_cell::new_info_event( - format!("Permissions updated to {label}"), - /*hint*/ None, - ), - ))); + tx.send(AppEvent::HandleSlashCommandDraft( + Self::approval_preset_draft_for_reviewer(preset_id, approvals_reviewer, flags), + )); })] } @@ -7375,9 +8818,6 @@ impl ChatWidget { preset: ApprovalPreset, return_to_permissions: bool, ) { - let selected_name = preset.label.to_string(); - let approval = preset.approval; - let sandbox = preset.sandbox; let mut header_children: Vec> = Vec::new(); let title_line = Line::from("Enable full access?").bold(); let info_line = Line::from(vec![ @@ -7392,26 +8832,11 @@ impl ChatWidget { )); let header = ColumnRenderable::with(header_children); - let mut accept_actions = Self::approval_preset_actions( - approval, - sandbox.clone(), - selected_name.clone(), - ApprovalsReviewer::User, + let accept_actions = Self::approval_preset_actions(preset.id, &["--confirm-full-access"]); + let accept_and_remember_actions = Self::approval_preset_actions( + preset.id, + &["--confirm-full-access", "--remember-full-access"], ); - accept_actions.push(Box::new(|tx| { - tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true)); - })); - - let mut accept_and_remember_actions = Self::approval_preset_actions( - approval, - sandbox, - selected_name, - ApprovalsReviewer::User, - ); - accept_and_remember_actions.push(Box::new(|tx| { - tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true)); - tx.send(AppEvent::PersistFullAccessWarningAcknowledged); - })); let deny_actions: Vec = vec![Box::new(move |tx| { if return_to_permissions { @@ -7457,14 +8882,11 @@ impl ChatWidget { pub(crate) fn open_world_writable_warning_confirmation( &mut self, preset: Option, + approvals_reviewer: Option, sample_paths: Vec, extra_count: usize, failed_scan: bool, ) { - let (approval, sandbox) = match &preset { - Some(p) => (Some(p.approval), Some(p.sandbox.clone())), - None => (None, None), - }; let mut header_children: Vec> = Vec::new(); let describe_policy = |policy: &SandboxPolicy| match policy { SandboxPolicy::WorkspaceWrite { .. } => "Agent mode", @@ -7508,36 +8930,29 @@ impl ChatWidget { // Build actions ensuring acknowledgement happens before applying the new sandbox policy, // so downstream policy-change hooks don't re-trigger the warning. - let mut accept_actions: Vec = Vec::new(); - // Suppress the immediate re-scan only when a preset will be applied (i.e., via /approvals or - // /permissions), to avoid duplicate warnings from the ensuing policy change. - if preset.is_some() { - accept_actions.push(Box::new(|tx| { - tx.send(AppEvent::SkipNextWorldWritableScan); - })); - } - if let (Some(approval), Some(sandbox)) = (approval, sandbox.clone()) { - accept_actions.extend(Self::approval_preset_actions( - approval, - sandbox, - mode_label.to_string(), - ApprovalsReviewer::User, - )); - } - - let mut accept_and_remember_actions: Vec = Vec::new(); - accept_and_remember_actions.push(Box::new(|tx| { - tx.send(AppEvent::UpdateWorldWritableWarningAcknowledged(true)); - tx.send(AppEvent::PersistWorldWritableWarningAcknowledged); - })); - if let (Some(approval), Some(sandbox)) = (approval, sandbox) { - accept_and_remember_actions.extend(Self::approval_preset_actions( - approval, - sandbox, - mode_label.to_string(), - ApprovalsReviewer::User, - )); - } + let accept_actions = preset + .as_ref() + .map(|preset| { + Self::approval_preset_actions_for_reviewer( + preset.id, + approvals_reviewer.unwrap_or(ApprovalsReviewer::User), + &["--confirm-world-writable"], + ) + }) + .unwrap_or_default(); + let accept_and_remember_actions: Vec = + if let Some(preset) = preset.as_ref() { + Self::approval_preset_actions_for_reviewer( + preset.id, + approvals_reviewer.unwrap_or(ApprovalsReviewer::User), + &["--confirm-world-writable", "--remember-world-writable"], + ) + } else { + vec![Box::new(|tx| { + tx.send(AppEvent::UpdateWorldWritableWarningAcknowledged(true)); + tx.send(AppEvent::PersistWorldWritableWarningAcknowledged); + })] + }; let items = vec![ SelectionItem { @@ -7568,6 +8983,7 @@ impl ChatWidget { pub(crate) fn open_world_writable_warning_confirmation( &mut self, _preset: Option, + _approvals_reviewer: Option, _sample_paths: Vec, _extra_count: usize, _failed_scan: bool, @@ -7575,7 +8991,11 @@ impl ChatWidget { } #[cfg(target_os = "windows")] - pub(crate) fn open_windows_sandbox_enable_prompt(&mut self, preset: ApprovalPreset) { + pub(crate) fn open_windows_sandbox_enable_prompt( + &mut self, + preset: ApprovalPreset, + approvals_reviewer: ApprovalsReviewer, + ) { use ratatui_macros::line; if !codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED { @@ -7596,9 +9016,9 @@ impl ChatWidget { name: "Enable experimental sandbox".to_string(), description: None, actions: vec![Box::new(move |tx| { - tx.send(AppEvent::EnableWindowsSandboxForAgentMode { + tx.send(AppEvent::BeginWindowsSandboxLegacySetup { preset: preset_clone.clone(), - mode: WindowsSandboxEnableMode::Legacy, + approvals_reviewer, }); })], dismiss_on_select: true, @@ -7646,9 +9066,13 @@ impl ChatWidget { description: None, actions: vec![Box::new(move |tx| { accept_otel.counter("codex.windows_sandbox.elevated_prompt_accept", 1, &[]); - tx.send(AppEvent::BeginWindowsSandboxElevatedSetup { - preset: preset.clone(), - }); + tx.send(AppEvent::HandleSlashCommandDraft( + Self::approval_preset_draft_for_reviewer( + preset.id, + approvals_reviewer, + &["--enable-windows-sandbox=elevated"], + ), + )); })], dismiss_on_select: true, ..Default::default() @@ -7658,9 +9082,13 @@ impl ChatWidget { description: None, actions: vec![Box::new(move |tx| { legacy_otel.counter("codex.windows_sandbox.elevated_prompt_use_legacy", 1, &[]); - tx.send(AppEvent::BeginWindowsSandboxLegacySetup { - preset: legacy_preset.clone(), - }); + tx.send(AppEvent::HandleSlashCommandDraft( + Self::approval_preset_draft_for_reviewer( + legacy_preset.id, + approvals_reviewer, + &["--enable-windows-sandbox=legacy"], + ), + )); })], dismiss_on_select: true, ..Default::default() @@ -7687,10 +9115,19 @@ impl ChatWidget { } #[cfg(not(target_os = "windows"))] - pub(crate) fn open_windows_sandbox_enable_prompt(&mut self, _preset: ApprovalPreset) {} + pub(crate) fn open_windows_sandbox_enable_prompt( + &mut self, + _preset: ApprovalPreset, + _approvals_reviewer: ApprovalsReviewer, + ) { + } #[cfg(target_os = "windows")] - pub(crate) fn open_windows_sandbox_fallback_prompt(&mut self, preset: ApprovalPreset) { + pub(crate) fn open_windows_sandbox_fallback_prompt( + &mut self, + preset: ApprovalPreset, + approvals_reviewer: ApprovalsReviewer, + ) { use ratatui_macros::line; let mut lines = Vec::new(); @@ -7720,9 +9157,13 @@ impl ChatWidget { let preset = elevated_preset; move |tx| { otel.counter("codex.windows_sandbox.fallback_retry_elevated", 1, &[]); - tx.send(AppEvent::BeginWindowsSandboxElevatedSetup { - preset: preset.clone(), - }); + tx.send(AppEvent::HandleSlashCommandDraft( + Self::approval_preset_draft_for_reviewer( + preset.id, + approvals_reviewer, + &["--enable-windows-sandbox=elevated"], + ), + )); } })], dismiss_on_select: true, @@ -7736,9 +9177,13 @@ impl ChatWidget { let preset = legacy_preset; move |tx| { otel.counter("codex.windows_sandbox.fallback_use_legacy", 1, &[]); - tx.send(AppEvent::BeginWindowsSandboxLegacySetup { - preset: preset.clone(), - }); + tx.send(AppEvent::HandleSlashCommandDraft( + Self::approval_preset_draft_for_reviewer( + preset.id, + approvals_reviewer, + &["--enable-windows-sandbox=legacy"], + ), + )); } })], dismiss_on_select: true, @@ -7766,7 +9211,12 @@ impl ChatWidget { } #[cfg(not(target_os = "windows"))] - pub(crate) fn open_windows_sandbox_fallback_prompt(&mut self, _preset: ApprovalPreset) {} + pub(crate) fn open_windows_sandbox_fallback_prompt( + &mut self, + _preset: ApprovalPreset, + _approvals_reviewer: ApprovalsReviewer, + ) { + } #[cfg(target_os = "windows")] pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self, show_now: bool) { @@ -7776,7 +9226,7 @@ impl ChatWidget { .into_iter() .find(|preset| preset.id == "auto") { - self.open_windows_sandbox_enable_prompt(preset); + self.open_windows_sandbox_enable_prompt(preset, self.config.approvals_reviewer); } } @@ -9010,12 +10460,12 @@ impl ChatWidget { items.push(SelectionItem { name: "Review uncommitted changes".to_string(), actions: vec![Box::new(move |tx: &AppEventSender| { - tx.send(AppEvent::CodexOp(Op::Review { - review_request: ReviewRequest { + tx.send(AppEvent::HandleSlashCommandDraft( + Self::review_request_draft(&ReviewRequest { target: ReviewTarget::UncommittedChanges, user_facing_hint: None, - }, - })); + }), + )); })], dismiss_on_select: true, ..Default::default() @@ -9063,14 +10513,14 @@ impl ChatWidget { items.push(SelectionItem { name: format!("{current_branch} -> {branch}"), actions: vec![Box::new(move |tx3: &AppEventSender| { - tx3.send(AppEvent::CodexOp(Op::Review { - review_request: ReviewRequest { + tx3.send(AppEvent::HandleSlashCommandDraft( + Self::review_request_draft(&ReviewRequest { target: ReviewTarget::BaseBranch { branch: branch.clone(), }, user_facing_hint: None, - }, - })); + }), + )); })], dismiss_on_select: true, search_value: Some(option), @@ -9100,15 +10550,15 @@ impl ChatWidget { items.push(SelectionItem { name: subject.clone(), actions: vec![Box::new(move |tx3: &AppEventSender| { - tx3.send(AppEvent::CodexOp(Op::Review { - review_request: ReviewRequest { + tx3.send(AppEvent::HandleSlashCommandDraft( + Self::review_request_draft(&ReviewRequest { target: ReviewTarget::Commit { sha: sha.clone(), title: Some(subject.clone()), }, user_facing_hint: None, - }, - })); + }), + )); })], dismiss_on_select: true, search_value: Some(search_val), @@ -9137,14 +10587,14 @@ impl ChatWidget { if trimmed.is_empty() { return; } - tx.send(AppEvent::CodexOp(Op::Review { - review_request: ReviewRequest { + tx.send(AppEvent::HandleSlashCommandDraft( + Self::review_request_draft(&ReviewRequest { target: ReviewTarget::Custom { instructions: trimmed, }, user_facing_hint: None, - }, - })); + }), + )); }), ); self.bottom_pane.show_view(Box::new(view)); @@ -9500,15 +10950,15 @@ pub(crate) fn show_review_commit_picker_with_entries( items.push(SelectionItem { name: subject.clone(), actions: vec![Box::new(move |tx3: &AppEventSender| { - tx3.send(AppEvent::CodexOp(Op::Review { - review_request: ReviewRequest { + tx3.send(AppEvent::HandleSlashCommandDraft( + ChatWidget::review_request_draft(&ReviewRequest { target: ReviewTarget::Commit { sha: sha.clone(), title: Some(subject.clone()), }, user_facing_hint: None, - }, - })); + }), + )); })], dismiss_on_select: true, search_value: Some(search_val), diff --git a/codex-rs/tui/src/chatwidget/skills.rs b/codex-rs/tui/src/chatwidget/skills.rs index 24273b69763..c09c6f88c3d 100644 --- a/codex-rs/tui/src/chatwidget/skills.rs +++ b/codex-rs/tui/src/chatwidget/skills.rs @@ -12,6 +12,8 @@ use crate::bottom_pane::SkillsToggleView; use crate::bottom_pane::popup_consts::standard_popup_hint_line; use crate::skills_helpers::skill_description; use crate::skills_helpers::skill_display_name; +use crate::slash_command::SlashCommand; +use crate::slash_command_invocation::SlashCommandInvocation; use codex_chatgpt::connectors::AppInfo; use codex_core::connectors::connector_mention_slug; use codex_core::mention_syntax::TOOL_MENTION_SIGIL; @@ -34,7 +36,10 @@ impl ChatWidget { name: "List skills".to_string(), description: Some("Tip: press $ to open this list directly.".to_string()), actions: vec![Box::new(|tx| { - tx.send(AppEvent::OpenSkillsList); + tx.send(AppEvent::HandleSlashCommandDraft( + SlashCommandInvocation::with_args(SlashCommand::Skills, ["list"]) + .into_user_message(), + )); })], dismiss_on_select: true, ..Default::default() @@ -43,7 +48,10 @@ impl ChatWidget { name: "Enable/Disable Skills".to_string(), description: Some("Enable or disable skills.".to_string()), actions: vec![Box::new(|tx| { - tx.send(AppEvent::OpenManageSkillsPopup); + tx.send(AppEvent::HandleSlashCommandDraft( + SlashCommandInvocation::with_args(SlashCommand::Skills, ["manage"]) + .into_user_message(), + )); })], dismiss_on_select: true, ..Default::default() diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_slash_command_while_task_running_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_slash_command_while_task_running_popup.snap new file mode 100644 index 00000000000..737b1e5f4d7 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_slash_command_while_task_running_popup.snap @@ -0,0 +1,22 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: term.backend().vt100().screen().contents() +--- + + + + + Select Model and Effort + Access legacy models by running codex -m or in your config.toml + +› 1. gpt-5.3-codex (default) Latest frontier agentic coding model. + 2. gpt-5.4 Latest frontier agentic coding model. + 3. gpt-5.2-codex Frontier agentic coding model. + 4. gpt-5.1-codex-max Codex-optimized flagship for deep and fast + reasoning. + 5. gpt-5.2 Latest frontier model with improvements across + knowledge, reasoning and coding + 6. gpt-5.1-codex-mini Optimized for codex. Cheaper, faster, but less + capable. + + Press enter to select reasoning effort, or esc to dismiss. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_help_output.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_help_output.snap new file mode 100644 index 00000000000..f66b233136d --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_help_output.snap @@ -0,0 +1,223 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: popup +--- + Slash Commands + + Type / to open the command popup. For commands with both a picker and an arg form, bare /command + opens the picker and /command ... runs directly. + Args use shell-style quoting; quote values with spaces. + + /help + show slash command help + Usage: + /help + + /model + choose what model and reasoning effort to use + Usage: + /model + /model [default|none|minimal|low|medium|high|xhigh] [plan-only|all-modes] + + /fast + toggle Fast mode to enable fastest inference at 2X plan usage + Usage: + /fast + /fast + + /approvals + choose what Codex is allowed to do + Usage: + /approvals + /approvals [--smart-approvals] [--confirm-full-access] + [--remember-full-access] [--confirm-world-writable] [--remember-world-writable] + [--enable-windows-sandbox=elevated|legacy] + + /permissions + choose what Codex is allowed to do + Usage: + /permissions + /permissions [--smart-approvals] [--confirm-full-access] + [--remember-full-access] [--confirm-world-writable] [--remember-world-writable] + [--enable-windows-sandbox=elevated|legacy] + + /experimental + toggle experimental features + Usage: + /experimental + /experimental =on|off ... + + /skills + use skills to improve how Codex performs specific tasks + Usage: + /skills + /skills + + /review + review my current changes and find issues + Usage: + /review + /review uncommitted + /review branch + /review commit [title] + /review + + /rename + rename the current thread + Usage: + /rename + /rename + + /new + start a new chat during a conversation + Usage: + /new + + /resume + resume a saved chat + Usage: + /resume + /resume + /resume --path + + /fork + fork the current chat + Usage: + /fork + + /init + create an AGENTS.md file with instructions for Codex + Usage: + /init + + /compact + summarize conversation to prevent hitting the context limit + Usage: + /compact + + /plan + switch to Plan mode + Usage: + /plan + /plan + + /collab + change collaboration mode (experimental) + Usage: + /collab + /collab + + /agent + switch the active agent thread + Usage: + /agent + /agent + + /diff + show git diff (including untracked files) + Usage: + /diff + + /copy + copy the latest Codex output to your clipboard + Usage: + /copy + + /mention + mention a file + Usage: + /mention + + /status + show current session configuration and token usage + Usage: + /status + + /debug-config + show config layers and requirement sources for debugging + Usage: + /debug-config + + /statusline + configure which items appear in the status line + Usage: + /statusline + /statusline ... + /statusline none + + /theme + choose a syntax highlighting theme + Usage: + /theme + /theme + + /mcp + list configured MCP tools + Usage: + /mcp + + /logout + log out of Codex + Usage: + /logout + + /quit + exit Codex + Usage: + /quit + + /exit + exit Codex + Usage: + /exit + + /feedback + send logs to maintainers + Usage: + /feedback + /feedback + + /rollout + print the rollout file path + Usage: + /rollout + + /ps + list background terminals + Usage: + /ps + + /stop + stop all background terminals + Usage: + /stop + + /clear + clear the terminal and start a new chat + Usage: + /clear + + /personality + choose a communication style for Codex + Usage: + /personality + /personality + + /test-approval + test approval request + Usage: + /test-approval + + /subagents + switch the active agent thread + Usage: + /subagents + /subagents + + /debug-m-drop + DO NOT USE + Usage: + /debug-m-drop + + + ↑/↓ scroll | [ctrl + p / ctrl + n] page | / search | esc close 1-212/219 diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_help_output@windows.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_help_output@windows.snap new file mode 100644 index 00000000000..2ab30c98041 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_help_output@windows.snap @@ -0,0 +1,228 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: popup +--- + Slash Commands + + Type / to open the command popup. For commands with both a picker and an arg form, bare /command + opens the picker and /command ... runs directly. + Args use shell-style quoting; quote values with spaces. + + /help + show slash command help + Usage: + /help + + /model + choose what model and reasoning effort to use + Usage: + /model + /model [default|none|minimal|low|medium|high|xhigh] [plan-only|all-modes] + + /fast + toggle Fast mode to enable fastest inference at 2X plan usage + Usage: + /fast + /fast + + /approvals + choose what Codex is allowed to do + Usage: + /approvals + /approvals [--smart-approvals] [--confirm-full-access] + [--remember-full-access] [--confirm-world-writable] [--remember-world-writable] + [--enable-windows-sandbox=elevated|legacy] + + /permissions + choose what Codex is allowed to do + Usage: + /permissions + /permissions [--smart-approvals] [--confirm-full-access] + [--remember-full-access] [--confirm-world-writable] [--remember-world-writable] + [--enable-windows-sandbox=elevated|legacy] + + /sandbox-add-read-dir + let sandbox read a directory: /sandbox-add-read-dir + Usage: + /sandbox-add-read-dir + + /experimental + toggle experimental features + Usage: + /experimental + /experimental =on|off ... + + /skills + use skills to improve how Codex performs specific tasks + Usage: + /skills + /skills + + /review + review my current changes and find issues + Usage: + /review + /review uncommitted + /review branch + /review commit [title] + /review + + /rename + rename the current thread + Usage: + /rename + /rename + + /new + start a new chat during a conversation + Usage: + /new + + /resume + resume a saved chat + Usage: + /resume + /resume + /resume --path + + /fork + fork the current chat + Usage: + /fork + + /init + create an AGENTS.md file with instructions for Codex + Usage: + /init + + /compact + summarize conversation to prevent hitting the context limit + Usage: + /compact + + /plan + switch to Plan mode + Usage: + /plan + /plan + + /collab + change collaboration mode (experimental) + Usage: + /collab + /collab + + /agent + switch the active agent thread + Usage: + /agent + /agent + + /diff + show git diff (including untracked files) + Usage: + /diff + + /copy + copy the latest Codex output to your clipboard + Usage: + /copy + + /mention + mention a file + Usage: + /mention + + /status + show current session configuration and token usage + Usage: + /status + + /debug-config + show config layers and requirement sources for debugging + Usage: + /debug-config + + /statusline + configure which items appear in the status line + Usage: + /statusline + /statusline ... + /statusline none + + /theme + choose a syntax highlighting theme + Usage: + /theme + /theme + + /mcp + list configured MCP tools + Usage: + /mcp + + /logout + log out of Codex + Usage: + /logout + + /quit + exit Codex + Usage: + /quit + + /exit + exit Codex + Usage: + /exit + + /feedback + send logs to maintainers + Usage: + /feedback + /feedback + + /rollout + print the rollout file path + Usage: + /rollout + + /ps + list background terminals + Usage: + /ps + + /clean + stop all background terminals + Usage: + /clean + + /clear + clear the terminal and start a new chat + Usage: + /clear + + /personality + choose a communication style for Codex + Usage: + /personality + /personality + + /test-approval + test approval request + Usage: + /test-approval + + /subagents + switch the active agent thread + Usage: + /subagents + /subagents + + /debug-m-drop + DO NOT USE + Usage: + /debug-m-drop + + + ↑/↓ scroll | [ctrl + p / ctrl + n] page | / search | esc close 1-217/224 diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_help_search_output.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_help_search_output.snap new file mode 100644 index 00000000000..add94e7bc58 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_help_search_output.snap @@ -0,0 +1,223 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: searching +--- + Slash Commands + + Type / to open the command popup. For commands with both a picker and an arg form, bare /command + opens the picker and /command ... runs directly. + Args use shell-style quoting; quote values with spaces. + + /help + show slash command help + Usage: + /help + + /model + choose what model and reasoning effort to use + Usage: + /model + /model [default|none|minimal|low|medium|high|xhigh] [plan-only|all-modes] + + /fast + toggle Fast mode to enable fastest inference at 2X plan usage + Usage: + /fast + /fast + + /approvals + choose what Codex is allowed to do + Usage: + /approvals + /approvals [--smart-approvals] [--confirm-full-access] + [--remember-full-access] [--confirm-world-writable] [--remember-world-writable] + [--enable-windows-sandbox=elevated|legacy] + + /permissions + choose what Codex is allowed to do + Usage: + /permissions + /permissions [--smart-approvals] [--confirm-full-access] + [--remember-full-access] [--confirm-world-writable] [--remember-world-writable] + [--enable-windows-sandbox=elevated|legacy] + + /experimental + toggle experimental features + Usage: + /experimental + /experimental =on|off ... + + /skills + use skills to improve how Codex performs specific tasks + Usage: + /skills + /skills + + /review + review my current changes and find issues + Usage: + /review + /review uncommitted + /review branch + /review commit [title] + /review + + /rename + rename the current thread + Usage: + /rename + /rename + + /new + start a new chat during a conversation + Usage: + /new + + /resume + resume a saved chat + Usage: + /resume + /resume + /resume --path + + /fork + fork the current chat + Usage: + /fork + + /init + create an AGENTS.md file with instructions for Codex + Usage: + /init + + /compact + summarize conversation to prevent hitting the context limit + Usage: + /compact + + /plan + switch to Plan mode + Usage: + /plan + /plan + + /collab + change collaboration mode (experimental) + Usage: + /collab + /collab + + /agent + switch the active agent thread + Usage: + /agent + /agent + + /diff + show git diff (including untracked files) + Usage: + /diff + + /copy + copy the latest Codex output to your clipboard + Usage: + /copy + + /mention + mention a file + Usage: + /mention + + /status + show current session configuration and token usage + Usage: + /status + + /debug-config + show config layers and requirement sources for debugging + Usage: + /debug-config + + /statusline + configure which items appear in the status line + Usage: + /statusline + /statusline ... + /statusline none + + /theme + choose a syntax highlighting theme + Usage: + /theme + /theme + + /mcp + list configured MCP tools + Usage: + /mcp + + /logout + log out of Codex + Usage: + /logout + + /quit + exit Codex + Usage: + /quit + + /exit + exit Codex + Usage: + /exit + + /feedback + send logs to maintainers + Usage: + /feedback + /feedback + + /rollout + print the rollout file path + Usage: + /rollout + + /ps + list background terminals + Usage: + /ps + + /stop + stop all background terminals + Usage: + /stop + + /clear + clear the terminal and start a new chat + Usage: + /clear + + /personality + choose a communication style for Codex + Usage: + /personality + /personality + + /test-approval + test approval request + Usage: + /test-approval + + /subagents + switch the active agent thread + Usage: + /subagents + /subagents + + /debug-m-drop + DO NOT USE + Usage: + /debug-m-drop + + + Search: /maintainers | enter apply | esc cancel 1 match | 1-212/219 diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_help_search_output@windows.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_help_search_output@windows.snap new file mode 100644 index 00000000000..3cf8b7db2d7 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_help_search_output@windows.snap @@ -0,0 +1,228 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: searching +--- + Slash Commands + + Type / to open the command popup. For commands with both a picker and an arg form, bare /command + opens the picker and /command ... runs directly. + Args use shell-style quoting; quote values with spaces. + + /help + show slash command help + Usage: + /help + + /model + choose what model and reasoning effort to use + Usage: + /model + /model [default|none|minimal|low|medium|high|xhigh] [plan-only|all-modes] + + /fast + toggle Fast mode to enable fastest inference at 2X plan usage + Usage: + /fast + /fast + + /approvals + choose what Codex is allowed to do + Usage: + /approvals + /approvals [--smart-approvals] [--confirm-full-access] + [--remember-full-access] [--confirm-world-writable] [--remember-world-writable] + [--enable-windows-sandbox=elevated|legacy] + + /permissions + choose what Codex is allowed to do + Usage: + /permissions + /permissions [--smart-approvals] [--confirm-full-access] + [--remember-full-access] [--confirm-world-writable] [--remember-world-writable] + [--enable-windows-sandbox=elevated|legacy] + + /sandbox-add-read-dir + let sandbox read a directory: /sandbox-add-read-dir + Usage: + /sandbox-add-read-dir + + /experimental + toggle experimental features + Usage: + /experimental + /experimental =on|off ... + + /skills + use skills to improve how Codex performs specific tasks + Usage: + /skills + /skills + + /review + review my current changes and find issues + Usage: + /review + /review uncommitted + /review branch + /review commit [title] + /review + + /rename + rename the current thread + Usage: + /rename + /rename + + /new + start a new chat during a conversation + Usage: + /new + + /resume + resume a saved chat + Usage: + /resume + /resume + /resume --path + + /fork + fork the current chat + Usage: + /fork + + /init + create an AGENTS.md file with instructions for Codex + Usage: + /init + + /compact + summarize conversation to prevent hitting the context limit + Usage: + /compact + + /plan + switch to Plan mode + Usage: + /plan + /plan + + /collab + change collaboration mode (experimental) + Usage: + /collab + /collab + + /agent + switch the active agent thread + Usage: + /agent + /agent + + /diff + show git diff (including untracked files) + Usage: + /diff + + /copy + copy the latest Codex output to your clipboard + Usage: + /copy + + /mention + mention a file + Usage: + /mention + + /status + show current session configuration and token usage + Usage: + /status + + /debug-config + show config layers and requirement sources for debugging + Usage: + /debug-config + + /statusline + configure which items appear in the status line + Usage: + /statusline + /statusline ... + /statusline none + + /theme + choose a syntax highlighting theme + Usage: + /theme + /theme + + /mcp + list configured MCP tools + Usage: + /mcp + + /logout + log out of Codex + Usage: + /logout + + /quit + exit Codex + Usage: + /quit + + /exit + exit Codex + Usage: + /exit + + /feedback + send logs to maintainers + Usage: + /feedback + /feedback + + /rollout + print the rollout file path + Usage: + /rollout + + /ps + list background terminals + Usage: + /ps + + /clean + stop all background terminals + Usage: + /clean + + /clear + clear the terminal and start a new chat + Usage: + /clear + + /personality + choose a communication style for Codex + Usage: + /personality + /personality + + /test-approval + test approval request + Usage: + /test-approval + + /subagents + switch the active agent thread + Usage: + /subagents + /subagents + + /debug-m-drop + DO NOT USE + Usage: + /debug-m-drop + + + Search: /maintainers | enter apply | esc cancel 1 match | 1-217/224 diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index ffc571288a3..e78be3b9f36 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -14,6 +14,7 @@ use crate::bottom_pane::FeedbackAudience; use crate::bottom_pane::LocalImageAttachment; use crate::bottom_pane::MentionBinding; use crate::history_cell::UserHistoryCell; +use crate::slash_command_invocation::SlashCommandInvocation; use crate::test_backend::VT100Backend; use crate::tui::FrameRequester; use assert_matches::assert_matches; @@ -128,6 +129,10 @@ use pretty_assertions::assert_eq; use serial_test::serial; use std::collections::BTreeMap; use std::collections::HashSet; +#[cfg(unix)] +use std::ffi::OsString; +#[cfg(unix)] +use std::os::unix::ffi::OsStringExt; use std::path::PathBuf; use tempfile::NamedTempFile; use tempfile::tempdir; @@ -1106,7 +1111,10 @@ async fn blocked_image_restore_preserves_mention_bindings() { chat.bottom_pane.composer_local_image_paths(), vec![local_images[0].path.clone()], ); - assert_eq!(chat.bottom_pane.take_mention_bindings(), mention_bindings); + assert_eq!( + chat.bottom_pane.composer_mention_bindings(), + mention_bindings + ); let cells = drain_insert_history(&mut rx); let warning = cells @@ -1890,6 +1898,7 @@ async fn make_chatwidget_manual( retry_status_header: None, pending_status_indicator_restore: false, suppress_queue_autosend: false, + resume_queued_inputs_when_idle: false, thread_id: None, thread_name: None, forked_from: None, @@ -2031,6 +2040,19 @@ fn drain_insert_history( out } +fn run_next_serialized_slash_draft( + chat: &mut ChatWidget, + rx: &mut tokio::sync::mpsc::UnboundedReceiver, +) { + while let Ok(event) = rx.try_recv() { + if let AppEvent::HandleSlashCommandDraft(draft) = event { + chat.handle_serialized_slash_command(draft); + return; + } + } + panic!("expected serialized slash draft event"); +} + fn lines_to_single_string(lines: &[ratatui::text::Line<'static>]) -> String { let mut s = String::new(); for line in lines { @@ -2546,15 +2568,16 @@ async fn reasoning_selection_in_plan_mode_without_effort_change_does_not_open_sc assert!( events.iter().any(|event| matches!( event, - AppEvent::UpdateModel(model) if model == "gpt-5.1-codex-max" + AppEvent::HandleSlashCommandDraft(UserMessage { text, .. }) + if text == "/model gpt-5.1-codex-max medium" )), - "expected model update event; events: {events:?}" + "expected model selection event; events: {events:?}" ); assert!( events .iter() - .any(|event| matches!(event, AppEvent::UpdateReasoningEffort(Some(_)))), - "expected reasoning update event; events: {events:?}" + .all(|event| !matches!(event, AppEvent::OpenPlanReasoningScopePrompt { .. })), + "did not expect plan reasoning scope prompt event; events: {events:?}" ); } @@ -2631,15 +2654,16 @@ async fn reasoning_selection_in_plan_mode_model_switch_does_not_open_scope_promp assert!( events.iter().any(|event| matches!( event, - AppEvent::UpdateModel(model) if model == "gpt-5" + AppEvent::HandleSlashCommandDraft(UserMessage { text, .. }) + if text == "/model gpt-5 medium" )), - "expected model update event; events: {events:?}" + "expected model selection event; events: {events:?}" ); assert!( events .iter() - .any(|event| matches!(event, AppEvent::UpdateReasoningEffort(Some(_)))), - "expected reasoning update event; events: {events:?}" + .all(|event| !matches!(event, AppEvent::OpenPlanReasoningScopePrompt { .. })), + "did not expect plan reasoning scope prompt event; events: {events:?}" ); } @@ -2658,24 +2682,16 @@ async fn plan_reasoning_scope_popup_all_modes_persists_global_and_plan_override( assert!( events.iter().any(|event| matches!( event, - AppEvent::UpdatePlanModeReasoningEffort(Some(ReasoningEffortConfig::High)) - )), - "expected plan override to be updated; events: {events:?}" - ); - assert!( - events.iter().any(|event| matches!( - event, - AppEvent::PersistPlanModeReasoningEffort(Some(ReasoningEffortConfig::High)) + AppEvent::HandleSlashCommandDraft(UserMessage { text, .. }) + if text == "/model gpt-5.1-codex-max high all-modes" )), - "expected updated plan override to be persisted; events: {events:?}" + "expected all-modes model selection event; events: {events:?}" ); assert!( - events.iter().any(|event| matches!( - event, - AppEvent::PersistModelSelection { model, effort: Some(ReasoningEffortConfig::High) } - if model == "gpt-5.1-codex-max" - )), - "expected global model reasoning selection persistence; events: {events:?}" + events + .iter() + .all(|event| !matches!(event, AppEvent::PersistPlanModeReasoningEffort(_))), + "did not expect persistence events before app handling; events: {events:?}" ); } @@ -2865,9 +2881,10 @@ async fn plan_reasoning_scope_popup_plan_only_does_not_update_all_modes_reasonin assert!( events.iter().any(|event| matches!( event, - AppEvent::UpdatePlanModeReasoningEffort(Some(ReasoningEffortConfig::High)) + AppEvent::HandleSlashCommandDraft(UserMessage { text, .. }) + if text == "/model gpt-5.1-codex-max high plan-only" )), - "expected plan-only reasoning update; events: {events:?}" + "expected plan-only model selection event; events: {events:?}" ); assert!( events @@ -4611,7 +4628,10 @@ async fn manual_interrupt_restores_pending_steer_mention_bindings_to_composer() chat.on_interrupted_turn(TurnAbortReason::Interrupted); assert_eq!(chat.bottom_pane.composer_text(), "please use $figma"); - assert_eq!(chat.bottom_pane.take_mention_bindings(), mention_bindings); + assert_eq!( + chat.bottom_pane.composer_mention_bindings(), + mention_bindings + ); assert_no_submit_op(&mut op_rx); } @@ -5395,6 +5415,56 @@ async fn slash_init_skips_when_project_doc_exists() { ); } +#[tokio::test] +async fn queued_init_replay_stops_after_submitting_user_turn() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; + let tempdir = tempdir().expect("tempdir"); + let configured = codex_protocol::protocol::SessionConfiguredEvent { + session_id: ThreadId::new(), + forked_from_id: None, + thread_name: None, + model: "test-model".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: ApprovalsReviewer::User, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + cwd: tempdir.path().to_path_buf(), + reasoning_effort: Some(ReasoningEffortConfig::default()), + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: None, + }; + chat.handle_codex_event(Event { + id: "configured".into(), + msg: EventMsg::SessionConfigured(configured), + }); + + chat.queued_user_messages.push_back("/init".into()); + chat.queued_user_messages.push_back("after init".into()); + + chat.drain_queued_inputs_until_blocked(); + + let items = match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => items, + other => panic!("expected Op::UserTurn, got {other:?}"), + }; + assert_eq!( + items, + vec![UserInput::Text { + text: include_str!("../../prompt_for_init_command.md").to_string(), + text_elements: Vec::new(), + }] + ); + assert_eq!( + chat.queued_user_message_texts(), + vec!["after init".to_string()] + ); + assert_no_submit_op(&mut op_rx); +} + #[tokio::test] async fn collab_mode_shift_tab_cycles_only_when_idle() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; @@ -5488,11 +5558,7 @@ async fn collab_slash_command_opens_picker_and_updates_mode() { ); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); - let selected_mask = match rx.try_recv() { - Ok(AppEvent::UpdateCollaborationMode(mask)) => mask, - other => panic!("expected UpdateCollaborationMode event, got {other:?}"), - }; - chat.set_collaboration_mask(selected_mask); + run_next_serialized_slash_draft(&mut chat, &mut rx); chat.bottom_pane .set_composer_text("hello".to_string(), Vec::new(), Vec::new()); @@ -5596,6 +5662,107 @@ async fn plan_slash_command_with_args_submits_prompt_in_plan_mode() { assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan); } +#[tokio::test] +async fn queued_plan_replay_stops_after_submitting_user_turn() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::CollaborationModes, true); + + let configured = codex_protocol::protocol::SessionConfiguredEvent { + session_id: ThreadId::new(), + forked_from_id: None, + thread_name: None, + model: "test-model".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: ApprovalsReviewer::User, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + cwd: PathBuf::from("/home/user/project"), + reasoning_effort: Some(ReasoningEffortConfig::default()), + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: None, + }; + chat.handle_codex_event(Event { + id: "configured".into(), + msg: EventMsg::SessionConfigured(configured), + }); + + chat.queued_user_messages + .push_back("/plan build the plan".into()); + chat.queued_user_messages + .push_back("after plan replay".into()); + + chat.drain_queued_inputs_until_blocked(); + + let items = match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => items, + other => panic!("expected Op::UserTurn, got {other:?}"), + }; + assert_eq!( + items, + vec![UserInput::Text { + text: "build the plan".to_string(), + text_elements: Vec::new(), + }] + ); + assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan); + assert_eq!( + chat.queued_user_message_texts(), + vec!["after plan replay".to_string()] + ); + assert_no_submit_op(&mut op_rx); +} + +#[tokio::test] +async fn queued_resume_picker_selection_replays_exact_targets_in_order() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + chat.bottom_pane.set_task_running(true); + + let thread_id = ThreadId::new(); + let first = crate::resume_picker::SessionTarget { + path: PathBuf::from("/tmp/first.rollout"), + thread_id, + }; + let second = crate::resume_picker::SessionTarget { + path: PathBuf::from("/tmp/second.rollout"), + thread_id, + }; + + chat.handle_serialized_slash_command(ChatWidget::resume_selection_draft(&first)); + chat.handle_serialized_slash_command(ChatWidget::resume_selection_draft(&second)); + + chat.bottom_pane.set_task_running(false); + chat.drain_queued_inputs_until_blocked(); + assert_matches!(rx.try_recv(), Ok(AppEvent::ResumeSessionTarget(target)) if target == first); + + chat.drain_queued_inputs_until_blocked(); + assert_matches!(rx.try_recv(), Ok(AppEvent::ResumeSessionTarget(target)) if target == second); +} + +#[cfg(unix)] +#[tokio::test] +async fn queued_resume_picker_selection_preserves_non_utf8_path() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + chat.bottom_pane.set_task_running(true); + + let target = crate::resume_picker::SessionTarget { + path: PathBuf::from(OsString::from_vec(b"/tmp/non-utf8-\xff.rollout".to_vec())), + thread_id: ThreadId::new(), + }; + + let draft = ChatWidget::resume_selection_draft(&target); + assert!(draft.text.contains("--path-base64")); + + chat.handle_serialized_slash_command(draft); + chat.bottom_pane.set_task_running(false); + chat.drain_queued_inputs_until_blocked(); + + assert_matches!(rx.try_recv(), Ok(AppEvent::ResumeSessionTarget(replayed)) if replayed == target); +} + #[tokio::test] async fn collaboration_modes_defaults_to_code_on_startup() { let codex_home = tempdir().expect("tempdir"); @@ -5886,6 +6053,183 @@ async fn slash_copy_reports_when_no_copyable_output_exists() { ); } +#[tokio::test] +async fn slash_help_opens_reference_popup() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.dispatch_command(SlashCommand::Help); + + assert!(chat.bottom_pane.has_active_view()); + let popup = render_bottom_popup(&chat, 100); + if cfg!(target_os = "windows") { + insta::with_settings!({ snapshot_suffix => "windows" }, { + assert_snapshot!("slash_help_output", popup); + }); + } else { + assert_snapshot!("slash_help_output", popup); + } + assert!(popup.contains("/help")); + assert!(popup.contains("/model ")); + + let mut scrolled = popup; + for _ in 0..8 { + if scrolled.contains("/review ") { + break; + } + chat.handle_key_event(KeyEvent::from(KeyCode::PageDown)); + scrolled = render_bottom_popup(&chat, 100); + } + assert!(scrolled.contains("/review ")); +} + +#[tokio::test] +async fn slash_help_with_whitespace_only_args_clears_composer() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.bottom_pane + .set_composer_text("/help ".to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert!(chat.bottom_pane.has_active_view()); + assert_eq!(chat.bottom_pane.composer_text(), ""); + assert!(chat.remote_image_urls().is_empty()); +} + +#[tokio::test] +async fn slash_help_search_jumps_to_lower_match() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.dispatch_command(SlashCommand::Help); + let _ = render_bottom_popup(&chat, 100); + + chat.handle_key_event(KeyEvent::from(KeyCode::Char('/'))); + for ch in "maintainers".chars() { + chat.handle_key_event(KeyEvent::from(KeyCode::Char(ch))); + } + let searching = render_bottom_popup(&chat, 100); + if cfg!(target_os = "windows") { + insta::with_settings!({ snapshot_suffix => "windows" }, { + assert_snapshot!("slash_help_search_output", searching); + }); + } else { + assert_snapshot!("slash_help_search_output", searching); + } + assert!(searching.contains("Search: /maintainers")); + assert!(searching.contains("1 match")); + + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + let popup = render_bottom_popup(&chat, 100); + assert!(popup.contains("/feedback")); + assert!(popup.contains("n/p match")); +} + +#[tokio::test] +async fn slash_help_search_navigates_matches_with_n_and_p() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.dispatch_command(SlashCommand::Help); + let _ = render_bottom_popup(&chat, 100); + + chat.handle_key_event(KeyEvent::from(KeyCode::Char('/'))); + for ch in "show".chars() { + chat.handle_key_event(KeyEvent::from(KeyCode::Char(ch))); + } + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + let first = render_bottom_popup(&chat, 100); + assert!(first.contains("n/p match")); + assert!(first.contains("/help")); + + chat.handle_key_event(KeyEvent::from(KeyCode::Char('n'))); + let second = render_bottom_popup(&chat, 100); + assert!(second.contains("/diff")); + + chat.handle_key_event(KeyEvent::from(KeyCode::Char('n'))); + let third = render_bottom_popup(&chat, 100); + assert!(third.contains("/status")); + + chat.handle_key_event(KeyEvent::from(KeyCode::Char('p'))); + let previous = render_bottom_popup(&chat, 100); + assert!(previous.contains("/diff")); + + chat.handle_key_event(KeyEvent::new(KeyCode::Char('N'), KeyModifiers::SHIFT)); + let shifted_previous = render_bottom_popup(&chat, 100); + assert!(shifted_previous.contains("/help")); +} + +#[tokio::test] +async fn slash_help_search_restarts_from_empty_input() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.dispatch_command(SlashCommand::Help); + chat.handle_key_event(KeyEvent::from(KeyCode::Char('/'))); + for ch in "maintainers".chars() { + chat.handle_key_event(KeyEvent::from(KeyCode::Char(ch))); + } + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + let active = render_bottom_popup(&chat, 100); + assert!(active.contains("n/p match")); + + chat.handle_key_event(KeyEvent::from(KeyCode::Char('/'))); + let restarted = render_bottom_popup(&chat, 100); + assert!(restarted.contains("Search: /")); + assert!(!restarted.contains("Search: /maintainers")); + assert!(!restarted.contains("1/1 |")); +} + +#[tokio::test] +async fn slash_help_esc_dismisses_popup() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.dispatch_command(SlashCommand::Help); + assert!(chat.bottom_pane.has_active_view()); + + chat.handle_key_event(KeyEvent::from(KeyCode::Esc)); + + assert!(!chat.bottom_pane.has_active_view()); +} + +#[tokio::test] +async fn slash_help_esc_clears_active_search_before_dismissing_popup() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.dispatch_command(SlashCommand::Help); + chat.handle_key_event(KeyEvent::from(KeyCode::Char('/'))); + for ch in "toggle".chars() { + chat.handle_key_event(KeyEvent::from(KeyCode::Char(ch))); + } + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + let active = render_bottom_popup(&chat, 100); + assert!(active.contains("n/p match")); + assert!(chat.bottom_pane.has_active_view()); + + chat.handle_key_event(KeyEvent::from(KeyCode::Esc)); + + let cleared = render_bottom_popup(&chat, 100); + assert!(chat.bottom_pane.has_active_view()); + assert!(!cleared.contains("1/3 |")); + assert!(!cleared.contains("n/p match")); + + chat.handle_key_event(KeyEvent::from(KeyCode::Esc)); + + assert!(!chat.bottom_pane.has_active_view()); +} + +#[tokio::test] +async fn slash_help_q_dismisses_popup() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.dispatch_command(SlashCommand::Help); + assert!(chat.bottom_pane.has_active_view()); + + chat.handle_key_event(KeyEvent::from(KeyCode::Char('q'))); + + assert!(!chat.bottom_pane.has_active_view()); +} + #[tokio::test] async fn slash_copy_state_is_preserved_during_running_task() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; @@ -6054,24 +6398,19 @@ async fn slash_clear_requests_ui_clear_when_idle() { } #[tokio::test] -async fn slash_clear_is_disabled_while_task_running() { +async fn slash_clear_queues_while_task_running() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - chat.bottom_pane.set_task_running(true); + chat.on_task_started(); chat.dispatch_command(SlashCommand::Clear); - let event = rx.try_recv().expect("expected disabled command error"); - match event { - AppEvent::InsertHistoryCell(cell) => { - let rendered = lines_to_single_string(&cell.display_lines(80)); - assert!( - rendered.contains("'/clear' is disabled while a task is in progress."), - "expected /clear task-running error, got {rendered:?}" - ); - } - other => panic!("expected InsertHistoryCell error, got {other:?}"), - } - assert!(rx.try_recv().is_err(), "expected no follow-up events"); + assert_eq!(chat.queued_user_message_texts(), vec!["/clear".to_string()]); + assert!(rx.try_recv().is_err(), "expected no immediate app events"); + + chat.on_task_complete(None, false); + + assert!(chat.queued_user_messages.is_empty()); + assert_matches!(rx.try_recv(), Ok(AppEvent::ClearUi)); } #[tokio::test] @@ -6301,7 +6640,7 @@ async fn review_commit_picker_shows_subjects_without_timestamps() { /// Submitting the custom prompt view sends Op::Review with the typed prompt /// and uses the same text for the user-facing hint. #[tokio::test] -async fn custom_prompt_submit_sends_review_op() { +async fn custom_prompt_submit_serializes_review_draft() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; chat.show_review_custom_prompt(); @@ -6309,18 +6648,18 @@ async fn custom_prompt_submit_sends_review_op() { chat.handle_paste(" please audit dependencies ".to_string()); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - // Expect AppEvent::CodexOp(Op::Review { .. }) with trimmed prompt + // Expect a serialized /review draft with trimmed prompt. let evt = rx.try_recv().expect("expected one app event"); match evt { - AppEvent::CodexOp(Op::Review { review_request }) => { + AppEvent::HandleSlashCommandDraft(UserMessage { text, .. }) => { assert_eq!( - review_request, - ReviewRequest { - target: ReviewTarget::Custom { - instructions: "please audit dependencies".to_string(), - }, - user_facing_hint: None, - } + text, + SlashCommandInvocation::with_args( + SlashCommand::Review, + ["please audit dependencies"], + ) + .into_user_message() + .text ); } other => panic!("unexpected app event: {other:?}"), @@ -7460,13 +7799,13 @@ async fn experimental_features_popup_snapshot() { let features = vec![ ExperimentalFeatureItem { - feature: Feature::GhostCommit, + key: Feature::GhostCommit.key().to_string(), name: "Ghost snapshots".to_string(), description: "Capture undo snapshots each turn.".to_string(), enabled: false, }, ExperimentalFeatureItem { - feature: Feature::ShellTool, + key: Feature::ShellTool.key().to_string(), name: "Shell tool".to_string(), description: "Allow the model to run shell commands.".to_string(), enabled: true, @@ -7486,7 +7825,7 @@ async fn experimental_features_toggle_saves_on_exit() { let expected_feature = Feature::GhostCommit; let view = ExperimentalFeaturesView::new( vec![ExperimentalFeatureItem { - feature: expected_feature, + key: expected_feature.key().to_string(), name: "Ghost snapshots".to_string(), description: "Capture undo snapshots each turn.".to_string(), enabled: false, @@ -7503,6 +7842,7 @@ async fn experimental_features_toggle_saves_on_exit() { ); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + run_next_serialized_slash_draft(&mut chat, &mut rx); let mut updates = None; while let Ok(event) = rx.try_recv() { @@ -7657,7 +7997,7 @@ async fn realtime_microphone_picker_popup_snapshot() { #[cfg(all(not(target_os = "linux"), feature = "voice-input"))] #[tokio::test] -async fn realtime_audio_picker_emits_persist_event() { +async fn realtime_audio_picker_emits_serialized_slash_draft() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2-codex")).await; chat.open_realtime_audio_device_selection_with_names( RealtimeAudioDeviceKind::Speaker, @@ -7670,10 +8010,8 @@ async fn realtime_audio_picker_emits_persist_event() { assert_matches!( rx.try_recv(), - Ok(AppEvent::PersistRealtimeAudioDeviceSelection { - kind: RealtimeAudioDeviceKind::Speaker, - name: Some(name), - }) if name == "Headphones" + Ok(AppEvent::HandleSlashCommandDraft(UserMessage { text, .. })) + if text == "/settings speaker Headphones" ); } @@ -7840,7 +8178,7 @@ async fn windows_auto_mode_prompt_requests_enabling_sandbox_feature() { .into_iter() .find(|preset| preset.id == "auto") .expect("auto preset"); - chat.open_windows_sandbox_enable_prompt(preset); + chat.open_windows_sandbox_enable_prompt(preset, ApprovalsReviewer::User); let popup = render_bottom_popup(&chat, 120); assert!( @@ -7982,22 +8320,112 @@ async fn single_reasoning_option_skips_selection() { } assert!( - events - .iter() - .any(|ev| matches!(ev, AppEvent::UpdateReasoningEffort(Some(effort)) if *effort == ReasoningEffortConfig::High)), + events.iter().any(|event| matches!( + event, + AppEvent::HandleSlashCommandDraft(UserMessage { text, .. }) + if text == "/model model-with-single-reasoning high" + )), "expected reasoning effort to be applied automatically; events: {events:?}" ); } -#[tokio::test] -async fn feedback_selection_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - - // Open the feedback category selection popup via slash command. - chat.dispatch_command(SlashCommand::Feedback); +#[test] +fn model_parser_rejects_repeated_effort_when_first_token_is_default() { + let parsed = ChatWidget::parse_model_selection_args(&[ + "gpt-5".to_string(), + "default".to_string(), + "high".to_string(), + ]); - let popup = render_bottom_popup(&chat, 80); - assert_snapshot!("feedback_selection_popup", popup); + assert_eq!( + parsed, + Err( + "Usage: /model [default|none|minimal|low|medium|high|xhigh] [plan-only|all-modes]" + .to_string(), + ) + ); +} + +#[test] +fn model_parser_rejects_repeated_scope_when_first_token_is_global() { + let parsed = ChatWidget::parse_model_selection_args(&[ + "gpt-5".to_string(), + "global".to_string(), + "plan-only".to_string(), + ]); + + assert_eq!( + parsed, + Err( + "Usage: /model [default|none|minimal|low|medium|high|xhigh] [plan-only|all-modes]" + .to_string(), + ) + ); +} + +#[tokio::test] +async fn feedback_selection_popup_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + + // Open the feedback category selection popup via slash command. + chat.dispatch_command(SlashCommand::Feedback); + + let popup = render_bottom_popup(&chat, 80); + assert_snapshot!("feedback_selection_popup", popup); +} + +#[tokio::test] +async fn feedback_selection_popup_emits_serialized_slash_draft() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.dispatch_command(SlashCommand::Feedback); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_matches!( + rx.try_recv(), + Ok(AppEvent::HandleSlashCommandDraft(UserMessage { text, .. })) if text == "/feedback bug" + ); + assert_matches!(rx.try_recv(), Ok(AppEvent::BottomPaneViewCompleted)); +} + +#[tokio::test] +async fn feedback_inline_args_respect_feedback_disabled_flag() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.config.feedback_enabled = false; + + chat.handle_serialized_slash_command(UserMessage::from("/feedback bug".to_string())); + + let popup = render_bottom_popup(&chat, 80); + assert!(popup.contains("Sending feedback is disabled")); + assert!(popup.contains("This action is disabled by configuration.")); + assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); + assert_no_submit_op(&mut op_rx); +} + +#[tokio::test] +async fn skills_menu_emits_serialized_slash_drafts() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.open_skills_menu(); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_matches!( + rx.try_recv(), + Ok(AppEvent::HandleSlashCommandDraft(UserMessage { text, .. })) + if text == "/skills list" + ); + assert_matches!(rx.try_recv(), Ok(AppEvent::BottomPaneViewCompleted)); + + chat.open_skills_menu(); + chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_matches!( + rx.try_recv(), + Ok(AppEvent::HandleSlashCommandDraft(UserMessage { text, .. })) + if text == "/skills manage" + ); + assert_matches!(rx.try_recv(), Ok(AppEvent::BottomPaneViewCompleted)); } #[tokio::test] @@ -8113,1115 +8541,1780 @@ async fn user_shell_command_renders_output_not_exploring() { } #[tokio::test] -async fn disabled_slash_command_while_task_running_snapshot() { - // Build a chat widget and simulate an active task - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - chat.bottom_pane.set_task_running(true); +async fn model_slash_command_while_task_running_opens_popup_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex")).await; + chat.thread_id = Some(ThreadId::new()); + chat.on_task_started(); - // Dispatch a command that is unavailable while a task runs (e.g., /model) chat.dispatch_command(SlashCommand::Model); - // Drain history and snapshot the rendered error line(s) - let cells = drain_insert_history(&mut rx); - assert!( - !cells.is_empty(), - "expected an error message history cell to be emitted", + assert!(chat.queued_user_messages.is_empty()); + assert!(chat.has_active_view(), "expected /model popup to open"); + assert!(drain_insert_history(&mut rx).is_empty()); + + let width: u16 = 80; + let height: u16 = 18; + let backend = VT100Backend::new(width, height); + let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); + let desired_height = chat.desired_height(width).min(height); + term.set_viewport_area(Rect::new(0, height - desired_height, width, desired_height)); + term.draw(|f| { + chat.render(f.area(), f.buffer_mut()); + }) + .unwrap(); + assert_snapshot!( + "model_slash_command_while_task_running_popup", + term.backend().vt100().screen().contents() ); - let blob = lines_to_single_string(cells.last().unwrap()); - assert_snapshot!(blob); } #[tokio::test] -async fn fast_slash_command_updates_and_persists_local_service_tier() { - let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; - chat.set_feature_enabled(Feature::FastMode, true); - - chat.dispatch_command(SlashCommand::Fast); +async fn theme_slash_command_while_task_running_opens_popup_instead_of_queueing() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + chat.on_task_started(); - let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); - assert!( - events.iter().any(|event| matches!( - event, - AppEvent::CodexOp(Op::OverrideTurnContext { - service_tier: Some(Some(ServiceTier::Fast)), - .. - }) - )), - "expected fast-mode override app event; events: {events:?}" - ); - assert!( - events.iter().any(|event| matches!( - event, - AppEvent::PersistServiceTierSelection { - service_tier: Some(ServiceTier::Fast), - } - )), - "expected fast-mode persistence app event; events: {events:?}" - ); + chat.dispatch_command(SlashCommand::Theme); - assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); + assert!(chat.queued_user_messages.is_empty()); + assert!(chat.has_active_view(), "expected /theme popup to open"); + assert!(drain_insert_history(&mut rx).is_empty()); } #[tokio::test] -async fn user_turn_carries_service_tier_after_fast_toggle() { - let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; - chat.thread_id = Some(ThreadId::new()); - set_chatgpt_auth(&mut chat); - chat.set_feature_enabled(Feature::FastMode, true); +async fn queued_followup_waits_for_popup_opened_during_running_turn() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.handle_codex_event(Event { + id: "configured".into(), + msg: EventMsg::SessionConfigured(SessionConfiguredEvent { + session_id: ThreadId::new(), + forked_from_id: None, + thread_name: None, + model: "test-model".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: ApprovalsReviewer::User, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + cwd: PathBuf::from("/home/user/project"), + reasoning_effort: Some(ReasoningEffortConfig::default()), + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: None, + }), + }); + drain_insert_history(&mut rx); + chat.on_task_started(); + chat.dispatch_command(SlashCommand::Theme); + assert!(chat.has_active_view(), "expected /theme popup to stay open"); - chat.dispatch_command(SlashCommand::Fast); + chat.queued_user_messages + .push_back(UserMessage::from("followup".to_string())); - let _events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); + chat.on_task_complete(None, false); - chat.bottom_pane - .set_composer_text("hello".to_string(), Vec::new(), Vec::new()); - chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert!( + chat.has_active_view(), + "expected theme popup to remain open" + ); + assert_eq!( + chat.queued_user_message_texts(), + vec!["followup".to_string()] + ); + assert!(chat.resume_queued_inputs_when_idle); + assert_no_submit_op(&mut op_rx); + + let _ = chat + .bottom_pane + .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert_matches!(rx.try_recv(), Ok(AppEvent::BottomPaneViewCompleted)); + assert!(!chat.has_active_view(), "expected popup to dismiss"); + + chat.maybe_resume_queued_inputs_when_idle(); match next_submit_op(&mut op_rx) { - Op::UserTurn { - service_tier: Some(Some(ServiceTier::Fast)), - .. - } => {} - other => panic!("expected Op::UserTurn with fast service tier, got {other:?}"), + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "followup".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected Op::UserTurn, got {other:?}"), } + assert!(chat.queued_user_messages.is_empty()); + assert!(!chat.resume_queued_inputs_when_idle); } #[tokio::test] -async fn fast_status_indicator_requires_chatgpt_auth() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; - chat.set_service_tier(Some(ServiceTier::Fast)); - - assert!(!chat.should_show_fast_status(chat.current_model(), chat.current_service_tier(),)); +async fn model_selection_queues_selected_action_while_task_running() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex")).await; + chat.on_task_started(); - set_chatgpt_auth(&mut chat); + chat.handle_serialized_slash_command(ChatWidget::model_selection_draft( + "gpt-5.1-codex-max", + Some(ReasoningEffortConfig::High), + ModelSelectionScope::Global, + )); - assert!(chat.should_show_fast_status(chat.current_model(), chat.current_service_tier(),)); + assert_eq!( + chat.queued_user_message_texts(), + vec!["/model gpt-5.1-codex-max high".to_string()] + ); } #[tokio::test] -async fn fast_status_indicator_is_hidden_for_non_gpt54_model() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; - chat.set_service_tier(Some(ServiceTier::Fast)); - set_chatgpt_auth(&mut chat); - - assert!(!chat.should_show_fast_status(chat.current_model(), chat.current_service_tier(),)); -} +async fn model_selection_queues_behind_existing_queued_inputs() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex")).await; + chat.queued_user_messages + .push_back("older queued input".into()); -#[tokio::test] -async fn fast_status_indicator_is_hidden_when_fast_mode_is_off() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; - set_chatgpt_auth(&mut chat); + chat.handle_serialized_slash_command(ChatWidget::model_selection_draft( + "gpt-5.1-codex-max", + Some(ReasoningEffortConfig::High), + ModelSelectionScope::Global, + )); - assert!(!chat.should_show_fast_status(chat.current_model(), chat.current_service_tier(),)); + assert_eq!( + chat.queued_user_message_texts(), + vec![ + "older queued input".to_string(), + "/model gpt-5.1-codex-max high".to_string(), + ] + ); + assert_no_submit_op(&mut op_rx); } #[tokio::test] -async fn approvals_popup_shows_disabled_presets() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; +async fn interrupt_restores_queued_model_selection_into_composer() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex")).await; + chat.on_task_started(); - chat.config.permissions.approval_policy = - Constrained::new(AskForApproval::OnRequest, |candidate| match candidate { - AskForApproval::OnRequest => Ok(()), - _ => Err(invalid_value( - candidate.to_string(), - "this message should be printed in the description", - )), - }) - .expect("construct constrained approval policy"); - chat.open_approvals_popup(); + chat.handle_serialized_slash_command(ChatWidget::model_selection_draft( + "gpt-5.1-codex-max", + Some(ReasoningEffortConfig::High), + ModelSelectionScope::Global, + )); - let width = 80; - let height = chat.desired_height(width); - let mut terminal = - ratatui::Terminal::new(VT100Backend::new(width, height)).expect("create terminal"); - terminal.set_viewport_area(Rect::new(0, 0, width, height)); - terminal - .draw(|f| chat.render(f.area(), f.buffer_mut())) - .expect("render approvals popup"); + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { + turn_id: Some("turn-1".to_string()), + reason: TurnAbortReason::Interrupted, + }), + }); - let screen = terminal.backend().vt100().screen().contents(); - let collapsed = screen.split_whitespace().collect::>().join(" "); - assert!( - collapsed.contains("(disabled)"), - "disabled preset label should be shown" + assert_eq!( + chat.bottom_pane.composer_text(), + "/model gpt-5.1-codex-max high" ); + assert!(chat.queued_user_messages.is_empty()); assert!( - collapsed.contains("this message should be printed in the description"), - "disabled preset reason should be shown" + op_rx.try_recv().is_err(), + "unexpected outbound op after interrupt restoring queued /model selection" ); + let _ = drain_insert_history(&mut rx); } #[tokio::test] -async fn approvals_popup_navigation_skips_disabled() { - let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; +async fn model_slash_command_with_args_queues_while_task_running_and_replays_after_turn_complete() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex")).await; + chat.on_task_started(); + chat.bottom_pane.set_composer_text( + "/model gpt-5.1-codex-max high all-modes".to_string(), + Vec::new(), + Vec::new(), + ); - chat.config.permissions.approval_policy = - Constrained::new(AskForApproval::OnRequest, |candidate| match candidate { - AskForApproval::OnRequest => Ok(()), - _ => Err(invalid_value(candidate.to_string(), "[on-request]")), - }) - .expect("construct constrained approval policy"); - chat.open_approvals_popup(); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - // The approvals popup is the active bottom-pane view; drive navigation via chat handle_key_event. - // Start selected at idx 0 (enabled), move down twice; the disabled option should be skipped - // and selection should wrap back to idx 0 (also enabled). - chat.handle_key_event(KeyEvent::from(KeyCode::Down)); - chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + assert_eq!( + chat.queued_user_message_texts(), + vec!["/model gpt-5.1-codex-max high all-modes".to_string()] + ); - // Press numeric shortcut for the disabled row (3 => idx 2); should not close or accept. - chat.handle_key_event(KeyEvent::from(KeyCode::Char('3'))); + chat.on_task_complete(None, false); - // Ensure the popup remains open and no selection actions were sent. - let width = 80; - let height = chat.desired_height(width); - let mut terminal = - ratatui::Terminal::new(VT100Backend::new(width, height)).expect("create terminal"); - terminal.set_viewport_area(Rect::new(0, 0, width, height)); - terminal - .draw(|f| chat.render(f.area(), f.buffer_mut())) - .expect("render approvals popup after disabled selection"); - let screen = terminal.backend().vt100().screen().contents(); - assert!( - screen.contains("Update Model Permissions"), - "popup should remain open after selecting a disabled entry" + assert!(chat.queued_user_messages.is_empty()); + assert_eq!(chat.current_model(), "gpt-5.1-codex-max"); + assert_eq!( + chat.effective_reasoning_effort(), + Some(ReasoningEffortConfig::High) ); - assert!( - op_rx.try_recv().is_err(), - "no actions should be dispatched yet" + assert_eq!( + chat.config.plan_mode_reasoning_effort, + Some(ReasoningEffortConfig::High) ); - assert!(rx.try_recv().is_err(), "no history should be emitted"); - // Press Enter; selection should land on an enabled preset and dispatch updates. - chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); - let mut app_events = Vec::new(); - while let Ok(ev) = rx.try_recv() { - app_events.push(ev); - } + let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); assert!( - app_events.iter().any(|ev| matches!( - ev, - AppEvent::CodexOp(Op::OverrideTurnContext { - approval_policy: Some(AskForApproval::OnRequest), - personality: None, - .. - }) + events.iter().any(|event| matches!( + event, + AppEvent::PersistModelSelection { model, effort } + if model == "gpt-5.1-codex-max" + && *effort == Some(ReasoningEffortConfig::High) )), - "enter should select an enabled preset" + "expected queued typed /model replay to persist the global model selection; events: {events:?}" ); assert!( - !app_events.iter().any(|ev| matches!( - ev, - AppEvent::CodexOp(Op::OverrideTurnContext { - approval_policy: Some(AskForApproval::Never), - personality: None, - .. - }) + events.iter().any(|event| matches!( + event, + AppEvent::PersistPlanModeReasoningEffort(Some(ReasoningEffortConfig::High)) )), - "disabled preset should not be selected" + "expected queued typed /model replay to persist plan-mode reasoning; events: {events:?}" ); } #[tokio::test] -async fn permissions_selection_emits_history_cell_when_selection_changes() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - #[cfg(target_os = "windows")] - { - chat.config.notices.hide_world_writable_warning = Some(true); - chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated)); - } - chat.config.notices.hide_full_access_warning = Some(true); +async fn queued_model_selection_replays_after_turn_complete() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex")).await; + chat.on_task_started(); - chat.open_permissions_popup(); - chat.handle_key_event(KeyEvent::from(KeyCode::Down)); - chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + chat.handle_serialized_slash_command(ChatWidget::model_selection_draft( + "gpt-5.1-codex-max", + Some(ReasoningEffortConfig::High), + ModelSelectionScope::AllModes, + )); - let cells = drain_insert_history(&mut rx); assert_eq!( - cells.len(), - 1, - "expected one permissions selection history cell" + chat.queued_user_message_texts(), + vec!["/model gpt-5.1-codex-max high all-modes".to_string()] ); - let rendered = lines_to_single_string(&cells[0]); + + chat.on_task_complete(None, false); + + assert!(chat.queued_user_messages.is_empty()); + assert_eq!(chat.current_model(), "gpt-5.1-codex-max"); + assert_eq!( + chat.effective_reasoning_effort(), + Some(ReasoningEffortConfig::High) + ); + assert_eq!( + chat.config.plan_mode_reasoning_effort, + Some(ReasoningEffortConfig::High) + ); + + let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); assert!( - rendered.contains("Permissions updated to"), - "expected permissions selection history message, got: {rendered}" + events.iter().any(|event| matches!( + event, + AppEvent::PersistModelSelection { model, effort } + if model == "gpt-5.1-codex-max" + && *effort == Some(ReasoningEffortConfig::High) + )), + "expected queued /model replay to persist the global model selection; events: {events:?}" + ); + assert!( + events.iter().any(|event| matches!( + event, + AppEvent::PersistPlanModeReasoningEffort(Some(ReasoningEffortConfig::High)) + )), + "expected queued /model replay to persist plan-mode reasoning; events: {events:?}" ); } #[tokio::test] -async fn permissions_selection_history_snapshot_after_mode_switch() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - #[cfg(target_os = "windows")] - { - chat.config.notices.hide_world_writable_warning = Some(true); - chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated)); - } - chat.config.notices.hide_full_access_warning = Some(true); +async fn esc_interrupts_running_task_with_empty_composer() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex")).await; + chat.thread_id = Some(ThreadId::new()); + chat.on_task_started(); - chat.open_permissions_popup(); - chat.handle_key_event(KeyEvent::from(KeyCode::Down)); - #[cfg(target_os = "windows")] - chat.handle_key_event(KeyEvent::from(KeyCode::Down)); - chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - let cells = drain_insert_history(&mut rx); - assert_eq!(cells.len(), 1, "expected one mode-switch history cell"); - assert_snapshot!( - "permissions_selection_history_after_mode_switch", - lines_to_single_string(&cells[0]) - ); + next_interrupt_op(&mut op_rx); + assert!(!chat.has_active_view(), "expected no popup to remain open"); } #[tokio::test] -async fn permissions_selection_history_snapshot_full_access_to_default() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - #[cfg(target_os = "windows")] - { - chat.config.notices.hide_world_writable_warning = Some(true); - chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated)); - } - chat.config.notices.hide_full_access_warning = Some(true); - chat.config - .permissions - .approval_policy - .set(AskForApproval::Never) - .expect("set approval policy"); - chat.config - .permissions - .sandbox_policy - .set(SandboxPolicy::DangerFullAccess) - .expect("set sandbox policy"); - - chat.open_permissions_popup(); - let popup = render_bottom_popup(&chat, 120); - chat.handle_key_event(KeyEvent::from(KeyCode::Up)); - if popup.contains("Guardian Approvals") { - chat.handle_key_event(KeyEvent::from(KeyCode::Up)); - } - chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); +async fn esc_interrupts_running_task_with_nonempty_composer_without_restoring_draft() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex")).await; + chat.thread_id = Some(ThreadId::new()); + chat.on_task_started(); + chat.bottom_pane + .set_composer_text("still editing".to_string(), Vec::new(), Vec::new()); - let cells = drain_insert_history(&mut rx); - assert_eq!(cells.len(), 1, "expected one mode-switch history cell"); - #[cfg(target_os = "windows")] - insta::with_settings!({ snapshot_suffix => "windows" }, { - assert_snapshot!( - "permissions_selection_history_full_access_to_default", - lines_to_single_string(&cells[0]) - ); - }); - #[cfg(not(target_os = "windows"))] - assert_snapshot!( - "permissions_selection_history_full_access_to_default", - lines_to_single_string(&cells[0]) + assert!( + chat.drain_restorable_messages_for_restore().is_none(), + "existing composer text alone should not trigger draft restoration" ); + assert_eq!(chat.bottom_pane.composer_text(), "still editing"); + + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + next_interrupt_op(&mut op_rx); + chat.on_interrupted_turn(TurnAbortReason::Interrupted); + + assert_eq!(chat.bottom_pane.composer_text(), "still editing"); + assert!(chat.queued_user_messages.is_empty()); + let _ = drain_insert_history(&mut rx); } #[tokio::test] -async fn permissions_selection_emits_history_cell_when_current_is_selected() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - #[cfg(target_os = "windows")] - { - chat.config.notices.hide_world_writable_warning = Some(true); - chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated)); +async fn esc_with_model_popup_active_dismisses_popup_without_interrupting() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex")).await; + chat.thread_id = Some(ThreadId::new()); + chat.on_task_started(); + chat.open_model_popup(); + + assert!(chat.has_active_view(), "expected /model popup to open"); + + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + while let Ok(op) = op_rx.try_recv() { + assert!( + !matches!(op, Op::Interrupt), + "expected Esc to dismiss the popup without interrupting" + ); } - chat.config - .permissions - .approval_policy - .set(AskForApproval::OnRequest) - .expect("set approval policy"); - chat.config - .permissions - .sandbox_policy - .set(SandboxPolicy::new_workspace_write_policy()) - .expect("set sandbox policy"); + assert!(!chat.has_active_view(), "expected /model popup to close"); +} - chat.open_permissions_popup(); - chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); +#[tokio::test] +async fn esc_interrupts_running_task_while_final_message_streaming() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex")).await; + chat.thread_id = Some(ThreadId::new()); + chat.on_task_started(); + chat.on_agent_message_delta("Final answer line\n".to_string()); + chat.on_commit_tick(); + let _ = drain_insert_history(&mut rx); - let cells = drain_insert_history(&mut rx); - assert_eq!( - cells.len(), - 1, - "expected history cell even when selecting current permissions" - ); - let rendered = lines_to_single_string(&cells[0]); assert!( - rendered.contains("Permissions updated to"), - "expected permissions update history message, got: {rendered}" + !chat.bottom_pane.status_indicator_visible(), + "expected final-message streaming to hide the status indicator" ); + + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + next_interrupt_op(&mut op_rx); } #[tokio::test] -async fn permissions_selection_hides_guardian_approvals_when_feature_disabled() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - #[cfg(target_os = "windows")] - { - chat.config.notices.hide_world_writable_warning = Some(true); - chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated)); +async fn esc_with_popup_active_does_not_interrupt_pending_steers() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex")).await; + chat.thread_id = Some(ThreadId::new()); + chat.on_task_started(); + + chat.bottom_pane + .set_composer_text("pending steer".to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match next_submit_op(&mut op_rx) { + Op::UserTurn { .. } => {} + other => panic!("expected Op::UserTurn, got {other:?}"), } - chat.config.notices.hide_full_access_warning = Some(true); - chat.open_permissions_popup(); - let popup = render_bottom_popup(&chat, 120); + chat.open_model_popup(); - assert!( - !popup.contains("Guardian Approvals"), - "expected Guardian Approvals to stay hidden until the experimental feature is enabled: {popup}" - ); + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + while let Ok(op) = op_rx.try_recv() { + assert!( + !matches!(op, Op::Interrupt), + "expected Esc to dismiss the popup before interrupting pending steers" + ); + } + assert!(!chat.has_active_view(), "expected /model popup to close"); + assert_eq!(chat.pending_steers.len(), 1); } #[tokio::test] -async fn permissions_selection_hides_guardian_approvals_when_feature_disabled_even_if_auto_review_is_active() - { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - #[cfg(target_os = "windows")] - { - chat.config.notices.hide_world_writable_warning = Some(true); - chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated)); - } - chat.config.notices.hide_full_access_warning = Some(true); - chat.config.approvals_reviewer = ApprovalsReviewer::GuardianSubagent; - chat.config - .permissions - .approval_policy - .set(AskForApproval::OnRequest) - .expect("set approval policy"); - chat.config - .permissions - .sandbox_policy - .set(SandboxPolicy::new_workspace_write_policy()) - .expect("set sandbox policy"); +async fn fast_slash_command_updates_and_persists_local_service_tier() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; + chat.set_feature_enabled(Feature::FastMode, true); - chat.open_permissions_popup(); - let popup = render_bottom_popup(&chat, 120); + chat.dispatch_command(SlashCommand::Fast); + let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); assert!( - !popup.contains("Guardian Approvals"), - "expected Guardian Approvals to stay hidden when the experimental feature is disabled: {popup}" + events.iter().any(|event| matches!( + event, + AppEvent::CodexOp(Op::OverrideTurnContext { + service_tier: Some(Some(ServiceTier::Fast)), + .. + }) + )), + "expected fast-mode override app event; events: {events:?}" + ); + assert!( + events.iter().any(|event| matches!( + event, + AppEvent::PersistServiceTierSelection { + service_tier: Some(ServiceTier::Fast), + } + )), + "expected fast-mode persistence app event; events: {events:?}" ); + + assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); } #[tokio::test] -async fn permissions_selection_marks_guardian_approvals_current_after_session_configured() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - #[cfg(target_os = "windows")] - { - chat.config.notices.hide_world_writable_warning = Some(true); - chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated)); - } - chat.config.notices.hide_full_access_warning = Some(true); - let _ = chat - .config - .features - .set_enabled(Feature::GuardianApproval, true); +async fn disabled_fast_slash_command_with_args_restores_draft_instead_of_queueing() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; + chat.set_feature_enabled(Feature::FastMode, false); + chat.on_task_started(); + chat.bottom_pane + .set_composer_text("/fast on".to_string(), Vec::new(), Vec::new()); - chat.handle_codex_event(Event { - id: "session-configured".to_string(), - msg: EventMsg::SessionConfigured(SessionConfiguredEvent { - session_id: ThreadId::new(), - forked_from_id: None, - thread_name: None, - model: "gpt-test".to_string(), - model_provider_id: "test-provider".to_string(), - service_tier: None, - approval_policy: AskForApproval::OnRequest, - approvals_reviewer: ApprovalsReviewer::GuardianSubagent, - sandbox_policy: SandboxPolicy::new_workspace_write_policy(), - cwd: PathBuf::from("/tmp/project"), - reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, - initial_messages: None, - network_proxy: None, - rollout_path: Some(PathBuf::new()), - }), - }); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - chat.open_permissions_popup(); - let popup = render_bottom_popup(&chat, 120); + assert_eq!(chat.bottom_pane.composer_text(), "/fast on"); + assert!(chat.queued_user_messages.is_empty()); + assert_no_submit_op(&mut op_rx); + let _ = drain_insert_history(&mut rx); +} + +#[tokio::test] +async fn serialized_disabled_fast_slash_draft_is_rejected_immediately() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; + chat.thread_id = Some(ThreadId::new()); + chat.set_feature_enabled(Feature::FastMode, false); + chat.on_task_started(); + + chat.handle_serialized_slash_command(UserMessage::from("/fast on".to_string())); + + assert_no_submit_op(&mut op_rx); + assert!(chat.queued_user_messages.is_empty()); + assert_eq!(chat.bottom_pane.composer_text(), "/fast on"); + let inserted = drain_insert_history(&mut rx); + assert_eq!(inserted.len(), 1); assert!( - popup.contains("Guardian Approvals (current)"), - "expected Guardian Approvals to be current after SessionConfigured sync: {popup}" + lines_to_single_string(&inserted[0]).contains("/fast is not available in this session.") ); } #[tokio::test] -async fn permissions_selection_marks_guardian_approvals_current_with_custom_workspace_write_details() - { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - #[cfg(target_os = "windows")] - { - chat.config.notices.hide_world_writable_warning = Some(true); - chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated)); - } - chat.config.notices.hide_full_access_warning = Some(true); - let _ = chat - .config - .features - .set_enabled(Feature::GuardianApproval, true); - - let extra_root = AbsolutePathBuf::try_from("/tmp/guardian-approvals-extra") - .expect("absolute extra writable root"); +async fn queued_disabled_fast_slash_draft_is_rejected_on_replay() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; + chat.thread_id = Some(ThreadId::new()); + chat.set_feature_enabled(Feature::FastMode, false); + chat.on_task_started(); + chat.queued_user_messages.push_back("/fast on".into()); - chat.handle_codex_event(Event { - id: "session-configured-custom-workspace".to_string(), - msg: EventMsg::SessionConfigured(SessionConfiguredEvent { - session_id: ThreadId::new(), - forked_from_id: None, - thread_name: None, - model: "gpt-test".to_string(), - model_provider_id: "test-provider".to_string(), - service_tier: None, - approval_policy: AskForApproval::OnRequest, - approvals_reviewer: ApprovalsReviewer::GuardianSubagent, - sandbox_policy: SandboxPolicy::WorkspaceWrite { - writable_roots: vec![extra_root], - read_only_access: ReadOnlyAccess::FullAccess, - network_access: false, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, - }, - cwd: PathBuf::from("/tmp/project"), - reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, - initial_messages: None, - network_proxy: None, - rollout_path: Some(PathBuf::new()), - }), - }); + chat.bottom_pane.set_task_running(false); + chat.drain_queued_inputs_until_blocked(); - chat.open_permissions_popup(); - let popup = render_bottom_popup(&chat, 120); + assert_no_submit_op(&mut op_rx); + assert!(chat.queued_user_messages.is_empty()); + assert_eq!(chat.bottom_pane.composer_text(), "/fast on"); + let inserted = drain_insert_history(&mut rx); + assert_eq!(inserted.len(), 1); assert!( - popup.contains("Guardian Approvals (current)"), - "expected Guardian Approvals to be current even with custom workspace-write details: {popup}" + lines_to_single_string(&inserted[0]).contains("/fast is not available in this session.") ); } #[tokio::test] -async fn permissions_selection_can_disable_guardian_approvals() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - #[cfg(target_os = "windows")] - { - chat.config.notices.hide_world_writable_warning = Some(true); - chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated)); - } - chat.config.notices.hide_full_access_warning = Some(true); - chat.set_feature_enabled(Feature::GuardianApproval, true); - chat.config - .permissions - .approval_policy - .set(AskForApproval::OnRequest) - .expect("set approval policy"); - chat.config - .permissions - .sandbox_policy - .set(SandboxPolicy::new_workspace_write_policy()) - .expect("set sandbox policy"); +async fn user_turn_carries_service_tier_after_fast_toggle() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; + chat.thread_id = Some(ThreadId::new()); + set_chatgpt_auth(&mut chat); + chat.set_feature_enabled(Feature::FastMode, true); - chat.open_permissions_popup(); - chat.handle_key_event(KeyEvent::from(KeyCode::Up)); + chat.dispatch_command(SlashCommand::Fast); + + let _events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); + + chat.bottom_pane + .set_composer_text("hello".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); - let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); - assert!( - events.iter().any(|event| matches!( - event, - AppEvent::UpdateApprovalsReviewer(ApprovalsReviewer::User) - )), - "expected selecting Default from Guardian Approvals to switch back to manual approval review: {events:?}" - ); - assert!( - !events - .iter() - .any(|event| matches!(event, AppEvent::UpdateFeatureFlags { .. })), - "expected permissions selection to leave feature flags unchanged: {events:?}" - ); + match next_submit_op(&mut op_rx) { + Op::UserTurn { + service_tier: Some(Some(ServiceTier::Fast)), + .. + } => {} + other => panic!("expected Op::UserTurn with fast service tier, got {other:?}"), + } } #[tokio::test] -async fn permissions_selection_sends_approvals_reviewer_in_override_turn_context() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - #[cfg(target_os = "windows")] - { - chat.config.notices.hide_world_writable_warning = Some(true); - chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated)); - } - chat.config.notices.hide_full_access_warning = Some(true); - chat.set_feature_enabled(Feature::GuardianApproval, true); - chat.config - .permissions - .approval_policy - .set(AskForApproval::OnRequest) - .expect("set approval policy"); - chat.config - .permissions - .sandbox_policy - .set(SandboxPolicy::new_workspace_write_policy()) - .expect("set sandbox policy"); - chat.set_approvals_reviewer(ApprovalsReviewer::User); +async fn fast_status_indicator_requires_chatgpt_auth() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; + chat.set_service_tier(Some(ServiceTier::Fast)); - chat.open_permissions_popup(); - let popup = render_bottom_popup(&chat, 120); - assert!( - popup - .lines() - .any(|line| line.contains("(current)") && line.contains('›')), - "expected permissions popup to open with the current preset selected: {popup}" - ); + assert!(!chat.should_show_fast_status(chat.current_model(), chat.current_service_tier(),)); - chat.handle_key_event(KeyEvent::from(KeyCode::Down)); - let popup = render_bottom_popup(&chat, 120); - assert!( - popup - .lines() - .any(|line| line.contains("Guardian Approvals") && line.contains('›')), - "expected one Down from Default to select Guardian Approvals: {popup}" - ); - chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + set_chatgpt_auth(&mut chat); - let op = std::iter::from_fn(|| rx.try_recv().ok()) - .find_map(|event| match event { - AppEvent::CodexOp(op @ Op::OverrideTurnContext { .. }) => Some(op), - _ => None, - }) - .expect("expected OverrideTurnContext op"); - - assert_eq!( - op, - Op::OverrideTurnContext { - cwd: None, - approval_policy: Some(AskForApproval::OnRequest), - approvals_reviewer: Some(ApprovalsReviewer::GuardianSubagent), - sandbox_policy: Some(SandboxPolicy::new_workspace_write_policy()), - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, - } - ); -} + assert!(chat.should_show_fast_status(chat.current_model(), chat.current_service_tier(),)); +} #[tokio::test] -async fn permissions_full_access_history_cell_emitted_only_after_confirmation() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - #[cfg(target_os = "windows")] - { - chat.config.notices.hide_world_writable_warning = Some(true); - chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated)); - } - chat.config.notices.hide_full_access_warning = None; - - chat.open_permissions_popup(); - chat.handle_key_event(KeyEvent::from(KeyCode::Down)); - #[cfg(target_os = "windows")] - chat.handle_key_event(KeyEvent::from(KeyCode::Down)); - chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); +async fn fast_status_indicator_is_hidden_for_non_gpt54_model() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; + chat.set_service_tier(Some(ServiceTier::Fast)); + set_chatgpt_auth(&mut chat); - let mut open_confirmation_event = None; - let mut cells_before_confirmation = Vec::new(); - while let Ok(event) = rx.try_recv() { - match event { - AppEvent::InsertHistoryCell(cell) => { - cells_before_confirmation.push(cell.display_lines(80)); - } - AppEvent::OpenFullAccessConfirmation { - preset, - return_to_permissions, - } => { - open_confirmation_event = Some((preset, return_to_permissions)); - } - _ => {} - } - } - if cfg!(not(target_os = "windows")) { - assert!( - cells_before_confirmation.is_empty(), - "did not expect history cell before confirming full access" - ); - } - let (preset, return_to_permissions) = - open_confirmation_event.expect("expected full access confirmation event"); - chat.open_full_access_confirmation(preset, return_to_permissions); + assert!(!chat.should_show_fast_status(chat.current_model(), chat.current_service_tier(),)); +} - let popup = render_bottom_popup(&chat, 80); - assert!( - popup.contains("Enable full access?"), - "expected full access confirmation popup, got: {popup}" - ); +#[tokio::test] +async fn fast_status_indicator_is_hidden_when_fast_mode_is_off() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; + set_chatgpt_auth(&mut chat); - chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); - let cells_after_confirmation = drain_insert_history(&mut rx); - let total_history_cells = cells_before_confirmation.len() + cells_after_confirmation.len(); - assert_eq!( - total_history_cells, 1, - "expected one full access history cell total" - ); - let rendered = if !cells_before_confirmation.is_empty() { - lines_to_single_string(&cells_before_confirmation[0]) - } else { - lines_to_single_string(&cells_after_confirmation[0]) - }; - assert!( - rendered.contains("Permissions updated to Full Access"), - "expected full access update history message, got: {rendered}" - ); + assert!(!chat.should_show_fast_status(chat.current_model(), chat.current_service_tier(),)); } -// -// Snapshot test: command approval modal -// -// Synthesizes a Codex ExecApprovalRequest event to trigger the approval modal -// and snapshots the visual output using the ratatui TestBackend. #[tokio::test] -async fn approval_modal_exec_snapshot() -> anyhow::Result<()> { - // Build a chat widget with manual channels to avoid spawning the agent. +async fn approvals_popup_shows_disabled_presets() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - // Ensure policy allows surfacing approvals explicitly (not strictly required for direct event). - chat.config - .permissions - .approval_policy - .set(AskForApproval::OnRequest)?; - // Inject an exec approval request to display the approval modal. - let ev = ExecApprovalRequestEvent { - call_id: "call-approve-cmd".into(), - approval_id: Some("call-approve-cmd".into()), - turn_id: "turn-approve-cmd".into(), - command: vec!["bash".into(), "-lc".into(), "echo hello world".into()], - cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), - reason: Some( - "this is a test reason such as one that would be produced by the model".into(), - ), - network_approval_context: None, - proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ - "echo".into(), - "hello".into(), - "world".into(), - ])), - proposed_network_policy_amendments: None, - additional_permissions: None, - skill_metadata: None, - available_decisions: None, - parsed_cmd: vec![], - }; - chat.handle_codex_event(Event { - id: "sub-approve".into(), - msg: EventMsg::ExecApprovalRequest(ev), - }); - // Render to a fixed-size test terminal and snapshot. - // Call desired_height first and use that exact height for rendering. - let width = 100; + + chat.config.permissions.approval_policy = + Constrained::new(AskForApproval::OnRequest, |candidate| match candidate { + AskForApproval::OnRequest => Ok(()), + _ => Err(invalid_value( + candidate.to_string(), + "this message should be printed in the description", + )), + }) + .expect("construct constrained approval policy"); + chat.open_approvals_popup(); + + let width = 80; let height = chat.desired_height(width); let mut terminal = - crate::custom_terminal::Terminal::with_options(VT100Backend::new(width, height)) - .expect("create terminal"); - let viewport = Rect::new(0, 0, width, height); - terminal.set_viewport_area(viewport); - + ratatui::Terminal::new(VT100Backend::new(width, height)).expect("create terminal"); + terminal.set_viewport_area(Rect::new(0, 0, width, height)); terminal .draw(|f| chat.render(f.area(), f.buffer_mut())) - .expect("draw approval modal"); + .expect("render approvals popup"); + + let screen = terminal.backend().vt100().screen().contents(); + let collapsed = screen.split_whitespace().collect::>().join(" "); assert!( - terminal - .backend() - .vt100() - .screen() - .contents() - .contains("echo hello world") + collapsed.contains("(disabled)"), + "disabled preset label should be shown" ); - assert_snapshot!( - "approval_modal_exec", - terminal.backend().vt100().screen().contents() + assert!( + collapsed.contains("this message should be printed in the description"), + "disabled preset reason should be shown" ); - - Ok(()) } -// Snapshot test: command approval modal without a reason -// Ensures spacing looks correct when no reason text is provided. #[tokio::test] -async fn approval_modal_exec_without_reason_snapshot() -> anyhow::Result<()> { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - chat.config - .permissions - .approval_policy - .set(AskForApproval::OnRequest)?; +async fn approvals_popup_navigation_skips_disabled() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; - let ev = ExecApprovalRequestEvent { - call_id: "call-approve-cmd-noreason".into(), - approval_id: Some("call-approve-cmd-noreason".into()), - turn_id: "turn-approve-cmd-noreason".into(), - command: vec!["bash".into(), "-lc".into(), "echo hello world".into()], - cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), - reason: None, - network_approval_context: None, - proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ - "echo".into(), - "hello".into(), - "world".into(), - ])), - proposed_network_policy_amendments: None, - additional_permissions: None, - skill_metadata: None, - available_decisions: None, - parsed_cmd: vec![], - }; - chat.handle_codex_event(Event { - id: "sub-approve-noreason".into(), - msg: EventMsg::ExecApprovalRequest(ev), - }); + chat.config.permissions.approval_policy = + Constrained::new(AskForApproval::OnRequest, |candidate| match candidate { + AskForApproval::OnRequest => Ok(()), + _ => Err(invalid_value(candidate.to_string(), "[on-request]")), + }) + .expect("construct constrained approval policy"); + chat.open_approvals_popup(); - let width = 100; + // The approvals popup is the active bottom-pane view; drive navigation via chat handle_key_event. + // Start selected at idx 0 (enabled), move down twice; the disabled option should be skipped + // and selection should wrap back to idx 0 (also enabled). + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + + // Press numeric shortcut for the disabled row (3 => idx 2); should not close or accept. + chat.handle_key_event(KeyEvent::from(KeyCode::Char('3'))); + + // Ensure the popup remains open and no selection actions were sent. + let width = 80; let height = chat.desired_height(width); let mut terminal = ratatui::Terminal::new(VT100Backend::new(width, height)).expect("create terminal"); terminal.set_viewport_area(Rect::new(0, 0, width, height)); terminal .draw(|f| chat.render(f.area(), f.buffer_mut())) - .expect("draw approval modal (no reason)"); - assert_snapshot!( - "approval_modal_exec_no_reason", - terminal.backend().vt100().screen().contents() + .expect("render approvals popup after disabled selection"); + let screen = terminal.backend().vt100().screen().contents(); + assert!( + screen.contains("Update Model Permissions"), + "popup should remain open after selecting a disabled entry" ); + assert!( + op_rx.try_recv().is_err(), + "no actions should be dispatched yet" + ); + assert!(rx.try_recv().is_err(), "no history should be emitted"); - Ok(()) -} - -// Snapshot test: approval modal with a proposed execpolicy prefix that is multi-line; -// we should not offer adding it to execpolicy. -#[tokio::test] -async fn approval_modal_exec_multiline_prefix_hides_execpolicy_option_snapshot() --> anyhow::Result<()> { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - chat.config - .permissions - .approval_policy - .set(AskForApproval::OnRequest)?; - - let script = "python - <<'PY'\nprint('hello')\nPY".to_string(); - let command = vec!["bash".into(), "-lc".into(), script]; - let ev = ExecApprovalRequestEvent { - call_id: "call-approve-cmd-multiline-trunc".into(), - approval_id: Some("call-approve-cmd-multiline-trunc".into()), - turn_id: "turn-approve-cmd-multiline-trunc".into(), - command: command.clone(), - cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), - reason: None, - network_approval_context: None, - proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)), - proposed_network_policy_amendments: None, - additional_permissions: None, - skill_metadata: None, - available_decisions: None, - parsed_cmd: vec![], - }; - chat.handle_codex_event(Event { - id: "sub-approve-multiline-trunc".into(), - msg: EventMsg::ExecApprovalRequest(ev), - }); - - let width = 100; - let height = chat.desired_height(width); - let mut terminal = - ratatui::Terminal::new(VT100Backend::new(width, height)).expect("create terminal"); - terminal.set_viewport_area(Rect::new(0, 0, width, height)); - terminal - .draw(|f| chat.render(f.area(), f.buffer_mut())) - .expect("draw approval modal (multiline prefix)"); - let contents = terminal.backend().vt100().screen().contents(); - assert!(!contents.contains("don't ask again")); - assert_snapshot!( - "approval_modal_exec_multiline_prefix_no_execpolicy", - contents + // Press Enter; selection should land on an enabled preset and dispatch updates. + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + run_next_serialized_slash_draft(&mut chat, &mut rx); + let mut app_events = Vec::new(); + while let Ok(ev) = rx.try_recv() { + app_events.push(ev); + } + assert!( + app_events.iter().any(|ev| matches!( + ev, + AppEvent::CodexOp(Op::OverrideTurnContext { + approval_policy: Some(AskForApproval::OnRequest), + personality: None, + .. + }) + )), + "enter should select an enabled preset" + ); + assert!( + !app_events.iter().any(|ev| matches!( + ev, + AppEvent::CodexOp(Op::OverrideTurnContext { + approval_policy: Some(AskForApproval::Never), + personality: None, + .. + }) + )), + "disabled preset should not be selected" ); - - Ok(()) } -// Snapshot test: patch approval modal #[tokio::test] -async fn approval_modal_patch_snapshot() -> anyhow::Result<()> { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - chat.config - .permissions - .approval_policy - .set(AskForApproval::OnRequest)?; +async fn permissions_selection_emits_history_cell_when_selection_changes() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + #[cfg(target_os = "windows")] + { + chat.config.notices.hide_world_writable_warning = Some(true); + chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated)); + } + chat.config.notices.hide_full_access_warning = Some(true); - // Build a small changeset and a reason/grant_root to exercise the prompt text. - let mut changes = HashMap::new(); - changes.insert( - PathBuf::from("README.md"), - FileChange::Add { - content: "hello\nworld\n".into(), - }, - ); - let ev = ApplyPatchApprovalRequestEvent { - call_id: "call-approve-patch".into(), - turn_id: "turn-approve-patch".into(), - changes, - reason: Some("The model wants to apply changes".into()), - grant_root: Some(PathBuf::from("/tmp")), - }; - chat.handle_codex_event(Event { - id: "sub-approve-patch".into(), - msg: EventMsg::ApplyPatchApprovalRequest(ev), - }); + chat.open_permissions_popup(); + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + run_next_serialized_slash_draft(&mut chat, &mut rx); - // Render at the widget's desired height and snapshot. - let height = chat.desired_height(80); - let mut terminal = - ratatui::Terminal::new(VT100Backend::new(80, height)).expect("create terminal"); - terminal.set_viewport_area(Rect::new(0, 0, 80, height)); - terminal - .draw(|f| chat.render(f.area(), f.buffer_mut())) - .expect("draw patch approval modal"); - assert_snapshot!( - "approval_modal_patch", - terminal.backend().vt100().screen().contents() + let cells = drain_insert_history(&mut rx); + assert_eq!( + cells.len(), + 1, + "expected one permissions selection history cell" + ); + let rendered = lines_to_single_string(&cells[0]); + assert!( + rendered.contains("Permissions updated to"), + "expected permissions selection history message, got: {rendered}" ); - - Ok(()) } +#[cfg(target_os = "windows")] #[tokio::test] -async fn interrupt_restores_queued_messages_into_composer() { - let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; - - // Simulate a running task to enable queuing of user inputs. - chat.bottom_pane.set_task_running(true); +async fn approvals_elevated_inline_args_start_windows_sandbox_setup() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_windows_sandbox_mode(None); + chat.handle_serialized_slash_command(ChatWidget::approval_preset_draft( + "auto", + &["--enable-windows-sandbox=elevated"], + )); - // Queue two user messages while the task is running. - chat.queued_user_messages - .push_back(UserMessage::from("first queued".to_string())); - chat.queued_user_messages - .push_back(UserMessage::from("second queued".to_string())); - chat.refresh_pending_input_preview(); + assert_matches!( + rx.try_recv(), + Ok(AppEvent::BeginWindowsSandboxElevatedSetup { + preset, + approvals_reviewer: ApprovalsReviewer::User, + }) if preset.id == "auto" + ); +} - // Deliver a TurnAborted event with Interrupted reason (as if Esc was pressed). - chat.handle_codex_event(Event { - id: "turn-1".into(), - msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { - turn_id: Some("turn-1".to_string()), - reason: TurnAbortReason::Interrupted, - }), - }); +#[cfg(target_os = "windows")] +#[tokio::test] +async fn approvals_legacy_inline_args_start_windows_sandbox_setup() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_windows_sandbox_mode(None); + chat.handle_serialized_slash_command(ChatWidget::approval_preset_draft( + "auto", + &["--enable-windows-sandbox=legacy"], + )); - // Composer should now contain the queued messages joined by newlines, in order. - assert_eq!( - chat.bottom_pane.composer_text(), - "first queued\nsecond queued" + assert_matches!( + rx.try_recv(), + Ok(AppEvent::BeginWindowsSandboxLegacySetup { + preset, + approvals_reviewer: ApprovalsReviewer::User, + }) if preset.id == "auto" ); +} - // Queue should be cleared and no new user input should have been auto-submitted. - assert!(chat.queued_user_messages.is_empty()); - assert!( - op_rx.try_recv().is_err(), - "unexpected outbound op after interrupt" - ); +#[cfg(target_os = "windows")] +#[tokio::test] +async fn approvals_smart_inline_args_preserve_guardian_reviewer_through_windows_setup() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::GuardianApproval, true); + chat.set_windows_sandbox_mode(None); + chat.handle_serialized_slash_command(ChatWidget::approval_preset_draft_for_reviewer( + "auto", + ApprovalsReviewer::GuardianSubagent, + &["--enable-windows-sandbox=elevated"], + )); - // Drain rx to avoid unused warnings. - let _ = drain_insert_history(&mut rx); + assert_matches!( + rx.try_recv(), + Ok(AppEvent::BeginWindowsSandboxElevatedSetup { + preset, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + }) if preset.id == "auto" + ); } +#[cfg(target_os = "windows")] #[tokio::test] -async fn interrupt_prepends_queued_messages_before_existing_composer_text() { - let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; - - chat.bottom_pane.set_task_running(true); - chat.bottom_pane - .set_composer_text("current draft".to_string(), Vec::new(), Vec::new()); +async fn permissions_default_selection_queues_windows_sandbox_enable_draft() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + chat.config.notices.hide_full_access_warning = Some(true); + chat.config + .permissions + .approval_policy + .set(AskForApproval::Never) + .expect("set approval policy"); + chat.config + .permissions + .sandbox_policy + .set(SandboxPolicy::new_read_only_policy()) + .expect("set sandbox policy"); + chat.set_windows_sandbox_mode(None); + + let sandbox_dir = chat.config.codex_home.join(".sandbox"); + std::fs::create_dir_all(&sandbox_dir).expect("create sandbox dir"); + std::fs::write( + sandbox_dir.join("setup_marker.json"), + serde_json::to_string(&serde_json::json!({ + "version": codex_windows_sandbox::SETUP_VERSION, + "offline_username": "CodexSandboxOffline", + "online_username": "CodexSandboxOnline", + })) + .expect("serialize setup marker"), + ) + .expect("write setup marker"); + + let sandbox_secrets_dir = chat.config.codex_home.join(".sandbox-secrets"); + std::fs::create_dir_all(&sandbox_secrets_dir).expect("create sandbox secrets dir"); + std::fs::write( + sandbox_secrets_dir.join("sandbox_users.json"), + serde_json::to_string(&serde_json::json!({ + "version": codex_windows_sandbox::SETUP_VERSION, + "offline": { + "username": "CodexSandboxOffline", + "password": "ignored", + }, + "online": { + "username": "CodexSandboxOnline", + "password": "ignored", + }, + })) + .expect("serialize sandbox users"), + ) + .expect("write sandbox users"); - chat.queued_user_messages - .push_back(UserMessage::from("first queued".to_string())); - chat.queued_user_messages - .push_back(UserMessage::from("second queued".to_string())); - chat.refresh_pending_input_preview(); + chat.open_permissions_popup(); + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); - chat.handle_codex_event(Event { - id: "turn-1".into(), - msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { - turn_id: Some("turn-1".to_string()), - reason: TurnAbortReason::Interrupted, + let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); + assert!( + events.iter().any(|event| { + matches!( + event, + AppEvent::HandleSlashCommandDraft(draft) + if *draft + == ChatWidget::approval_preset_draft( + "auto", + &["--enable-windows-sandbox=elevated"], + ) + ) }), - }); - - assert_eq!( - chat.bottom_pane.composer_text(), - "first queued\nsecond queued\ncurrent draft" + "expected selecting Default to emit a serialized approvals draft: {events:?}" ); - assert!(chat.queued_user_messages.is_empty()); assert!( - op_rx.try_recv().is_err(), - "unexpected outbound op after interrupt" + !events + .iter() + .any(|event| matches!(event, AppEvent::EnableWindowsSandboxForAgentMode { .. })), + "expected selecting Default to defer sandbox enable until slash replay: {events:?}" ); - - let _ = drain_insert_history(&mut rx); } #[tokio::test] -async fn interrupt_keeps_unified_exec_processes() { +async fn permissions_selection_history_snapshot_after_mode_switch() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + #[cfg(target_os = "windows")] + { + chat.config.notices.hide_world_writable_warning = Some(true); + chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated)); + } + chat.config.notices.hide_full_access_warning = Some(true); - begin_unified_exec_startup(&mut chat, "call-1", "process-1", "sleep 5"); - begin_unified_exec_startup(&mut chat, "call-2", "process-2", "sleep 6"); - assert_eq!(chat.unified_exec_processes.len(), 2); - - chat.handle_codex_event(Event { - id: "turn-1".into(), - msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { - turn_id: Some("turn-1".to_string()), - reason: TurnAbortReason::Interrupted, - }), - }); - - assert_eq!(chat.unified_exec_processes.len(), 2); + chat.open_permissions_popup(); + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + #[cfg(target_os = "windows")] + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + run_next_serialized_slash_draft(&mut chat, &mut rx); - let _ = drain_insert_history(&mut rx); + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected one mode-switch history cell"); + assert_snapshot!( + "permissions_selection_history_after_mode_switch", + lines_to_single_string(&cells[0]) + ); } #[tokio::test] -async fn review_ended_keeps_unified_exec_processes() { +async fn permissions_selection_history_snapshot_full_access_to_default() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + #[cfg(target_os = "windows")] + { + chat.config.notices.hide_world_writable_warning = Some(true); + chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated)); + } + chat.config.notices.hide_full_access_warning = Some(true); + chat.config + .permissions + .approval_policy + .set(AskForApproval::Never) + .expect("set approval policy"); + chat.config + .permissions + .sandbox_policy + .set(SandboxPolicy::DangerFullAccess) + .expect("set sandbox policy"); - begin_unified_exec_startup(&mut chat, "call-1", "process-1", "sleep 5"); - begin_unified_exec_startup(&mut chat, "call-2", "process-2", "sleep 6"); - assert_eq!(chat.unified_exec_processes.len(), 2); + chat.open_permissions_popup(); + let popup = render_bottom_popup(&chat, 120); + chat.handle_key_event(KeyEvent::from(KeyCode::Up)); + if popup.contains("Guardian Approvals") { + chat.handle_key_event(KeyEvent::from(KeyCode::Up)); + } + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + run_next_serialized_slash_draft(&mut chat, &mut rx); - chat.handle_codex_event(Event { - id: "turn-1".into(), - msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { - turn_id: Some("turn-1".to_string()), - reason: TurnAbortReason::ReviewEnded, - }), + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected one mode-switch history cell"); + #[cfg(target_os = "windows")] + insta::with_settings!({ snapshot_suffix => "windows" }, { + assert_snapshot!( + "permissions_selection_history_full_access_to_default", + lines_to_single_string(&cells[0]) + ); }); + #[cfg(not(target_os = "windows"))] + assert_snapshot!( + "permissions_selection_history_full_access_to_default", + lines_to_single_string(&cells[0]) + ); +} - assert_eq!(chat.unified_exec_processes.len(), 2); +#[tokio::test] +async fn permissions_selection_emits_history_cell_when_current_is_selected() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + #[cfg(target_os = "windows")] + { + chat.config.notices.hide_world_writable_warning = Some(true); + chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated)); + } + chat.config + .permissions + .approval_policy + .set(AskForApproval::OnRequest) + .expect("set approval policy"); + chat.config + .permissions + .sandbox_policy + .set(SandboxPolicy::new_workspace_write_policy()) + .expect("set sandbox policy"); + + chat.open_permissions_popup(); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + run_next_serialized_slash_draft(&mut chat, &mut rx); - chat.add_ps_output(); let cells = drain_insert_history(&mut rx); - let combined = cells - .iter() - .map(|lines| lines_to_single_string(lines)) - .collect::>() - .join("\n"); - assert!( - combined.contains("Background terminals"), - "expected /ps to remain available after review-ended abort; got {combined:?}" + assert_eq!( + cells.len(), + 1, + "expected history cell even when selecting current permissions" ); + let rendered = lines_to_single_string(&cells[0]); assert!( - combined.contains("sleep 5") && combined.contains("sleep 6"), - "expected /ps to list running unified exec processes; got {combined:?}" + rendered.contains("Permissions updated to"), + "expected permissions update history message, got: {rendered}" ); - - let _ = drain_insert_history(&mut rx); } #[tokio::test] -async fn interrupt_preserves_unified_exec_wait_streak_snapshot() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; +async fn permissions_selection_hides_guardian_approvals_when_feature_disabled() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + #[cfg(target_os = "windows")] + { + chat.config.notices.hide_world_writable_warning = Some(true); + chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated)); + } + chat.config.notices.hide_full_access_warning = Some(true); - chat.handle_codex_event(Event { - id: "turn-1".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - model_context_window: None, - collaboration_mode_kind: ModeKind::Default, - }), - }); + chat.open_permissions_popup(); + let popup = render_bottom_popup(&chat, 120); - let begin = begin_unified_exec_startup(&mut chat, "call-1", "process-1", "just fix"); - terminal_interaction(&mut chat, "call-1a", "process-1", ""); + assert!( + !popup.contains("Guardian Approvals"), + "expected Guardian Approvals to stay hidden until the experimental feature is enabled: {popup}" + ); +} - chat.handle_codex_event(Event { - id: "turn-1".into(), - msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { - turn_id: Some("turn-1".to_string()), - reason: TurnAbortReason::Interrupted, - }), - }); +#[tokio::test] +async fn permissions_selection_hides_guardian_approvals_when_feature_disabled_even_if_auto_review_is_active() + { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + #[cfg(target_os = "windows")] + { + chat.config.notices.hide_world_writable_warning = Some(true); + chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated)); + } + chat.config.notices.hide_full_access_warning = Some(true); + chat.config.approvals_reviewer = ApprovalsReviewer::GuardianSubagent; + chat.config + .permissions + .approval_policy + .set(AskForApproval::OnRequest) + .expect("set approval policy"); + chat.config + .permissions + .sandbox_policy + .set(SandboxPolicy::new_workspace_write_policy()) + .expect("set sandbox policy"); - end_exec(&mut chat, begin, "", "", 0); - let cells = drain_insert_history(&mut rx); - let combined = cells - .iter() - .map(|lines| lines_to_single_string(lines)) - .collect::>() - .join("\n"); - let snapshot = format!("cells={}\n{combined}", cells.len()); - assert_snapshot!("interrupt_preserves_unified_exec_wait_streak", snapshot); + chat.open_permissions_popup(); + let popup = render_bottom_popup(&chat, 120); + + assert!( + !popup.contains("Guardian Approvals"), + "expected Guardian Approvals to stay hidden when the experimental feature is disabled: {popup}" + ); } #[tokio::test] -async fn turn_complete_keeps_unified_exec_processes() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - begin_unified_exec_startup(&mut chat, "call-1", "process-1", "sleep 5"); - begin_unified_exec_startup(&mut chat, "call-2", "process-2", "sleep 6"); - assert_eq!(chat.unified_exec_processes.len(), 2); +async fn permissions_selection_marks_guardian_approvals_current_after_session_configured() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + #[cfg(target_os = "windows")] + { + chat.config.notices.hide_world_writable_warning = Some(true); + chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated)); + } + chat.config.notices.hide_full_access_warning = Some(true); + let _ = chat + .config + .features + .set_enabled(Feature::GuardianApproval, true); chat.handle_codex_event(Event { - id: "turn-1".into(), - msg: EventMsg::TurnComplete(TurnCompleteEvent { - turn_id: "turn-1".to_string(), - last_agent_message: None, + id: "session-configured".to_string(), + msg: EventMsg::SessionConfigured(SessionConfiguredEvent { + session_id: ThreadId::new(), + forked_from_id: None, + thread_name: None, + model: "gpt-test".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_policy: SandboxPolicy::new_workspace_write_policy(), + cwd: PathBuf::from("/tmp/project"), + reasoning_effort: None, + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: Some(PathBuf::new()), }), }); - assert_eq!(chat.unified_exec_processes.len(), 2); + chat.open_permissions_popup(); + let popup = render_bottom_popup(&chat, 120); - chat.add_ps_output(); - let cells = drain_insert_history(&mut rx); - let combined = cells - .iter() - .map(|lines| lines_to_single_string(lines)) - .collect::>() - .join("\n"); - assert!( - combined.contains("Background terminals"), - "expected /ps to remain available after turn complete; got {combined:?}" - ); assert!( - combined.contains("sleep 5") && combined.contains("sleep 6"), - "expected /ps to list running unified exec processes; got {combined:?}" + popup.contains("Guardian Approvals (current)"), + "expected Guardian Approvals to be current after SessionConfigured sync: {popup}" ); - - let _ = drain_insert_history(&mut rx); } -// Snapshot test: ChatWidget at very small heights (idle) -// Ensures overall layout behaves when terminal height is extremely constrained. #[tokio::test] -async fn ui_snapshots_small_heights_idle() { - use ratatui::Terminal; - use ratatui::backend::TestBackend; - let (chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - for h in [1u16, 2, 3] { - let name = format!("chat_small_idle_h{h}"); - let mut terminal = Terminal::new(TestBackend::new(40, h)).expect("create terminal"); - terminal - .draw(|f| chat.render(f.area(), f.buffer_mut())) - .expect("draw chat idle"); - assert_snapshot!(name, terminal.backend()); +async fn permissions_selection_marks_guardian_approvals_current_with_custom_workspace_write_details() + { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + #[cfg(target_os = "windows")] + { + chat.config.notices.hide_world_writable_warning = Some(true); + chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated)); } -} + chat.config.notices.hide_full_access_warning = Some(true); + let _ = chat + .config + .features + .set_enabled(Feature::GuardianApproval, true); -// Snapshot test: ChatWidget at very small heights (task running) -// Validates how status + composer are presented within tight space. -#[tokio::test] -async fn ui_snapshots_small_heights_task_running() { - use ratatui::Terminal; - use ratatui::backend::TestBackend; - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - // Activate status line - chat.handle_codex_event(Event { - id: "task-1".into(), + let extra_root = AbsolutePathBuf::try_from("/tmp/guardian-approvals-extra") + .expect("absolute extra writable root"); + + chat.handle_codex_event(Event { + id: "session-configured-custom-workspace".to_string(), + msg: EventMsg::SessionConfigured(SessionConfiguredEvent { + session_id: ThreadId::new(), + forked_from_id: None, + thread_name: None, + model: "gpt-test".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_policy: SandboxPolicy::WorkspaceWrite { + writable_roots: vec![extra_root], + read_only_access: ReadOnlyAccess::FullAccess, + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }, + cwd: PathBuf::from("/tmp/project"), + reasoning_effort: None, + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: Some(PathBuf::new()), + }), + }); + + chat.open_permissions_popup(); + let popup = render_bottom_popup(&chat, 120); + + assert!( + popup.contains("Guardian Approvals (current)"), + "expected Guardian Approvals to be current even with custom workspace-write details: {popup}" + ); +} + +#[tokio::test] +async fn permissions_selection_can_disable_guardian_approvals() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + #[cfg(target_os = "windows")] + { + chat.config.notices.hide_world_writable_warning = Some(true); + chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated)); + } + chat.config.notices.hide_full_access_warning = Some(true); + chat.set_feature_enabled(Feature::GuardianApproval, true); + chat.config.approvals_reviewer = ApprovalsReviewer::GuardianSubagent; + chat.config + .permissions + .approval_policy + .set(AskForApproval::OnRequest) + .expect("set approval policy"); + chat.config + .permissions + .sandbox_policy + .set(SandboxPolicy::new_workspace_write_policy()) + .expect("set sandbox policy"); + + chat.open_permissions_popup(); + chat.handle_key_event(KeyEvent::from(KeyCode::Up)); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); + assert!( + events.iter().any(|event| { + matches!( + event, + AppEvent::HandleSlashCommandDraft(draft) + if *draft == ChatWidget::approval_preset_draft("auto", &[]) + ) + }), + "expected selecting Default from Guardian Approvals to queue the default approvals draft: {events:?}" + ); + assert!( + !events + .iter() + .any(|event| matches!(event, AppEvent::UpdateFeatureFlags { .. })), + "expected permissions selection to leave feature flags unchanged: {events:?}" + ); +} + +#[tokio::test] +async fn permissions_selection_emits_smart_approvals_draft_before_replay() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + #[cfg(target_os = "windows")] + { + chat.config.notices.hide_world_writable_warning = Some(true); + chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated)); + } + chat.config.notices.hide_full_access_warning = Some(true); + chat.set_feature_enabled(Feature::GuardianApproval, true); + chat.config + .permissions + .approval_policy + .set(AskForApproval::OnRequest) + .expect("set approval policy"); + chat.config + .permissions + .sandbox_policy + .set(SandboxPolicy::new_workspace_write_policy()) + .expect("set sandbox policy"); + chat.set_approvals_reviewer(ApprovalsReviewer::User); + + chat.open_permissions_popup(); + let popup = render_bottom_popup(&chat, 120); + assert!( + popup + .lines() + .any(|line| line.contains("(current)") && line.contains('›')), + "expected permissions popup to open with the current preset selected: {popup}" + ); + + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + let popup = render_bottom_popup(&chat, 120); + assert!( + popup + .lines() + .any(|line| line.contains("Guardian Approvals") && line.contains('›')), + "expected one Down from Default to select Guardian Approvals: {popup}" + ); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + let draft = match rx.try_recv() { + Ok(AppEvent::HandleSlashCommandDraft(draft)) => draft, + other => panic!("expected serialized smart approvals draft, got {other:?}"), + }; + assert_eq!( + draft, + ChatWidget::approval_preset_draft_for_reviewer( + "auto", + ApprovalsReviewer::GuardianSubagent, + &[], + ) + ); + chat.handle_serialized_slash_command(draft); + + let op = std::iter::from_fn(|| rx.try_recv().ok()) + .find_map(|event| match event { + AppEvent::CodexOp(op @ Op::OverrideTurnContext { .. }) => Some(op), + _ => None, + }) + .expect("expected OverrideTurnContext op after replay"); + + assert_eq!( + op, + Op::OverrideTurnContext { + cwd: None, + approval_policy: Some(AskForApproval::OnRequest), + approvals_reviewer: Some(ApprovalsReviewer::GuardianSubagent), + sandbox_policy: Some(SandboxPolicy::new_workspace_write_policy()), + windows_sandbox_level: None, + model: None, + effort: None, + summary: None, + service_tier: None, + collaboration_mode: None, + personality: None, + } + ); +} + +#[tokio::test] +async fn permissions_full_access_history_cell_emitted_only_after_confirmation() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + #[cfg(target_os = "windows")] + { + chat.config.notices.hide_world_writable_warning = Some(true); + chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated)); + } + chat.config.notices.hide_full_access_warning = None; + + chat.open_permissions_popup(); + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + #[cfg(target_os = "windows")] + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + let mut open_confirmation_event = None; + let mut cells_before_confirmation = Vec::new(); + while let Ok(event) = rx.try_recv() { + match event { + AppEvent::InsertHistoryCell(cell) => { + cells_before_confirmation.push(cell.display_lines(80)); + } + AppEvent::OpenFullAccessConfirmation { + preset, + return_to_permissions, + } => { + open_confirmation_event = Some((preset, return_to_permissions)); + } + _ => {} + } + } + if cfg!(not(target_os = "windows")) { + assert!( + cells_before_confirmation.is_empty(), + "did not expect history cell before confirming full access" + ); + } + let (preset, return_to_permissions) = + open_confirmation_event.expect("expected full access confirmation event"); + chat.open_full_access_confirmation(preset, return_to_permissions); + + let popup = render_bottom_popup(&chat, 80); + assert!( + popup.contains("Enable full access?"), + "expected full access confirmation popup, got: {popup}" + ); + + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + run_next_serialized_slash_draft(&mut chat, &mut rx); + let cells_after_confirmation = drain_insert_history(&mut rx); + let total_history_cells = cells_before_confirmation.len() + cells_after_confirmation.len(); + assert_eq!( + total_history_cells, 1, + "expected one full access history cell total" + ); + let rendered = if !cells_before_confirmation.is_empty() { + lines_to_single_string(&cells_before_confirmation[0]) + } else { + lines_to_single_string(&cells_after_confirmation[0]) + }; + assert!( + rendered.contains("Permissions updated to Full Access"), + "expected full access update history message, got: {rendered}" + ); +} + +#[cfg(target_os = "windows")] +#[tokio::test] +async fn world_writable_warning_without_preset_persists_dont_warn_again() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.open_world_writable_warning_confirmation(None, None, Vec::new(), 0, true); + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + let events: Vec = std::iter::from_fn(|| rx.try_recv().ok()).collect(); + assert!( + events.iter().any(|event| matches!( + event, + AppEvent::UpdateWorldWritableWarningAcknowledged(true) + )), + "expected update-world-writable-warning acknowledgement event, got: {events:?}" + ); + assert!( + events + .iter() + .any(|event| matches!(event, AppEvent::PersistWorldWritableWarningAcknowledged)), + "expected persist-world-writable-warning acknowledgement event, got: {events:?}" + ); +} + +// +// Snapshot test: command approval modal +// +// Synthesizes a Codex ExecApprovalRequest event to trigger the approval modal +// and snapshots the visual output using the ratatui TestBackend. +#[tokio::test] +async fn approval_modal_exec_snapshot() -> anyhow::Result<()> { + // Build a chat widget with manual channels to avoid spawning the agent. + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + // Ensure policy allows surfacing approvals explicitly (not strictly required for direct event). + chat.config + .permissions + .approval_policy + .set(AskForApproval::OnRequest)?; + // Inject an exec approval request to display the approval modal. + let ev = ExecApprovalRequestEvent { + call_id: "call-approve-cmd".into(), + approval_id: Some("call-approve-cmd".into()), + turn_id: "turn-approve-cmd".into(), + command: vec!["bash".into(), "-lc".into(), "echo hello world".into()], + cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + reason: Some( + "this is a test reason such as one that would be produced by the model".into(), + ), + network_approval_context: None, + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ + "echo".into(), + "hello".into(), + "world".into(), + ])), + proposed_network_policy_amendments: None, + additional_permissions: None, + skill_metadata: None, + available_decisions: None, + parsed_cmd: vec![], + }; + chat.handle_codex_event(Event { + id: "sub-approve".into(), + msg: EventMsg::ExecApprovalRequest(ev), + }); + // Render to a fixed-size test terminal and snapshot. + // Call desired_height first and use that exact height for rendering. + let width = 100; + let height = chat.desired_height(width); + let mut terminal = + crate::custom_terminal::Terminal::with_options(VT100Backend::new(width, height)) + .expect("create terminal"); + let viewport = Rect::new(0, 0, width, height); + terminal.set_viewport_area(viewport); + + terminal + .draw(|f| chat.render(f.area(), f.buffer_mut())) + .expect("draw approval modal"); + assert!( + terminal + .backend() + .vt100() + .screen() + .contents() + .contains("echo hello world") + ); + assert_snapshot!( + "approval_modal_exec", + terminal.backend().vt100().screen().contents() + ); + + Ok(()) +} + +// Snapshot test: command approval modal without a reason +// Ensures spacing looks correct when no reason text is provided. +#[tokio::test] +async fn approval_modal_exec_without_reason_snapshot() -> anyhow::Result<()> { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.config + .permissions + .approval_policy + .set(AskForApproval::OnRequest)?; + + let ev = ExecApprovalRequestEvent { + call_id: "call-approve-cmd-noreason".into(), + approval_id: Some("call-approve-cmd-noreason".into()), + turn_id: "turn-approve-cmd-noreason".into(), + command: vec!["bash".into(), "-lc".into(), "echo hello world".into()], + cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + reason: None, + network_approval_context: None, + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ + "echo".into(), + "hello".into(), + "world".into(), + ])), + proposed_network_policy_amendments: None, + additional_permissions: None, + skill_metadata: None, + available_decisions: None, + parsed_cmd: vec![], + }; + chat.handle_codex_event(Event { + id: "sub-approve-noreason".into(), + msg: EventMsg::ExecApprovalRequest(ev), + }); + + let width = 100; + let height = chat.desired_height(width); + let mut terminal = + ratatui::Terminal::new(VT100Backend::new(width, height)).expect("create terminal"); + terminal.set_viewport_area(Rect::new(0, 0, width, height)); + terminal + .draw(|f| chat.render(f.area(), f.buffer_mut())) + .expect("draw approval modal (no reason)"); + assert_snapshot!( + "approval_modal_exec_no_reason", + terminal.backend().vt100().screen().contents() + ); + + Ok(()) +} + +// Snapshot test: approval modal with a proposed execpolicy prefix that is multi-line; +// we should not offer adding it to execpolicy. +#[tokio::test] +async fn approval_modal_exec_multiline_prefix_hides_execpolicy_option_snapshot() +-> anyhow::Result<()> { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.config + .permissions + .approval_policy + .set(AskForApproval::OnRequest)?; + + let script = "python - <<'PY'\nprint('hello')\nPY".to_string(); + let command = vec!["bash".into(), "-lc".into(), script]; + let ev = ExecApprovalRequestEvent { + call_id: "call-approve-cmd-multiline-trunc".into(), + approval_id: Some("call-approve-cmd-multiline-trunc".into()), + turn_id: "turn-approve-cmd-multiline-trunc".into(), + command: command.clone(), + cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + reason: None, + network_approval_context: None, + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)), + proposed_network_policy_amendments: None, + additional_permissions: None, + skill_metadata: None, + available_decisions: None, + parsed_cmd: vec![], + }; + chat.handle_codex_event(Event { + id: "sub-approve-multiline-trunc".into(), + msg: EventMsg::ExecApprovalRequest(ev), + }); + + let width = 100; + let height = chat.desired_height(width); + let mut terminal = + ratatui::Terminal::new(VT100Backend::new(width, height)).expect("create terminal"); + terminal.set_viewport_area(Rect::new(0, 0, width, height)); + terminal + .draw(|f| chat.render(f.area(), f.buffer_mut())) + .expect("draw approval modal (multiline prefix)"); + let contents = terminal.backend().vt100().screen().contents(); + assert!(!contents.contains("don't ask again")); + assert_snapshot!( + "approval_modal_exec_multiline_prefix_no_execpolicy", + contents + ); + + Ok(()) +} + +// Snapshot test: patch approval modal +#[tokio::test] +async fn approval_modal_patch_snapshot() -> anyhow::Result<()> { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.config + .permissions + .approval_policy + .set(AskForApproval::OnRequest)?; + + // Build a small changeset and a reason/grant_root to exercise the prompt text. + let mut changes = HashMap::new(); + changes.insert( + PathBuf::from("README.md"), + FileChange::Add { + content: "hello\nworld\n".into(), + }, + ); + let ev = ApplyPatchApprovalRequestEvent { + call_id: "call-approve-patch".into(), + turn_id: "turn-approve-patch".into(), + changes, + reason: Some("The model wants to apply changes".into()), + grant_root: Some(PathBuf::from("/tmp")), + }; + chat.handle_codex_event(Event { + id: "sub-approve-patch".into(), + msg: EventMsg::ApplyPatchApprovalRequest(ev), + }); + + // Render at the widget's desired height and snapshot. + let height = chat.desired_height(80); + let mut terminal = + ratatui::Terminal::new(VT100Backend::new(80, height)).expect("create terminal"); + terminal.set_viewport_area(Rect::new(0, 0, 80, height)); + terminal + .draw(|f| chat.render(f.area(), f.buffer_mut())) + .expect("draw patch approval modal"); + assert_snapshot!( + "approval_modal_patch", + terminal.backend().vt100().screen().contents() + ); + + Ok(()) +} + +#[tokio::test] +async fn interrupt_restores_queued_messages_into_composer() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + + // Simulate a running task to enable queuing of user inputs. + chat.bottom_pane.set_task_running(true); + + // Queue two user messages while the task is running. + chat.queued_user_messages + .push_back(UserMessage::from("first queued".to_string())); + chat.queued_user_messages + .push_back(UserMessage::from("second queued".to_string())); + chat.refresh_pending_input_preview(); + + // Deliver a TurnAborted event with Interrupted reason (as if Esc was pressed). + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { + turn_id: Some("turn-1".to_string()), + reason: TurnAbortReason::Interrupted, + }), + }); + + // Composer should now contain the queued messages joined by newlines, in order. + assert_eq!( + chat.bottom_pane.composer_text(), + "first queued\nsecond queued" + ); + + // Queue should be cleared and no new user input should have been auto-submitted. + assert!(chat.queued_user_messages.is_empty()); + assert!( + op_rx.try_recv().is_err(), + "unexpected outbound op after interrupt" + ); + + // Drain rx to avoid unused warnings. + let _ = drain_insert_history(&mut rx); +} + +#[tokio::test] +async fn interrupt_restores_queued_slash_commands_into_composer() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + + chat.bottom_pane.set_task_running(true); + chat.queued_user_messages + .push_back(UserMessage::from("queued draft".to_string())); + chat.queued_user_messages + .push_back(UserMessage::from("/review".to_string())); + chat.refresh_pending_input_preview(); + + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { + turn_id: Some("turn-1".to_string()), + reason: TurnAbortReason::Interrupted, + }), + }); + + assert_eq!(chat.bottom_pane.composer_text(), "queued draft\n/review"); + assert!( + !chat.has_active_view(), + "expected interrupt restore to keep queued slash drafts in the composer" + ); + assert!(chat.queued_user_messages.is_empty()); + assert!( + op_rx.try_recv().is_err(), + "unexpected outbound op after interrupt restore" + ); + + let _ = drain_insert_history(&mut rx); +} + +#[tokio::test] +async fn interrupt_restores_multiple_queued_slash_commands_into_composer() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + + chat.bottom_pane.set_task_running(true); + chat.queued_user_messages + .push_back(UserMessage::from("/fast status".to_string())); + chat.queued_user_messages + .push_back(UserMessage::from("/review".to_string())); + chat.refresh_pending_input_preview(); + + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { + turn_id: Some("turn-1".to_string()), + reason: TurnAbortReason::Interrupted, + }), + }); + + assert_eq!(chat.bottom_pane.composer_text(), "/fast status\n/review"); + assert!( + !chat.has_active_view(), + "expected interrupt restore to keep slash drafts editable" + ); + assert!(chat.queued_user_messages.is_empty()); + assert!( + op_rx.try_recv().is_err(), + "unexpected outbound op after slash-only interrupt restore" + ); + let _ = drain_insert_history(&mut rx); +} + +#[tokio::test] +async fn interrupt_prepends_queued_messages_before_existing_composer_text() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + + chat.bottom_pane.set_task_running(true); + chat.bottom_pane + .set_composer_text("current draft".to_string(), Vec::new(), Vec::new()); + + chat.queued_user_messages + .push_back(UserMessage::from("first queued".to_string())); + chat.queued_user_messages + .push_back(UserMessage::from("second queued".to_string())); + chat.refresh_pending_input_preview(); + + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { + turn_id: Some("turn-1".to_string()), + reason: TurnAbortReason::Interrupted, + }), + }); + + assert_eq!( + chat.bottom_pane.composer_text(), + "first queued\nsecond queued\ncurrent draft" + ); + assert!(chat.queued_user_messages.is_empty()); + assert!( + op_rx.try_recv().is_err(), + "unexpected outbound op after interrupt" + ); + + let _ = drain_insert_history(&mut rx); +} + +#[tokio::test] +async fn interrupt_keeps_unified_exec_processes() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + + begin_unified_exec_startup(&mut chat, "call-1", "process-1", "sleep 5"); + begin_unified_exec_startup(&mut chat, "call-2", "process-2", "sleep 6"); + assert_eq!(chat.unified_exec_processes.len(), 2); + + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { + turn_id: Some("turn-1".to_string()), + reason: TurnAbortReason::Interrupted, + }), + }); + + assert_eq!(chat.unified_exec_processes.len(), 2); + + let _ = drain_insert_history(&mut rx); +} + +#[tokio::test] +async fn review_ended_keeps_unified_exec_processes() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + + begin_unified_exec_startup(&mut chat, "call-1", "process-1", "sleep 5"); + begin_unified_exec_startup(&mut chat, "call-2", "process-2", "sleep 6"); + assert_eq!(chat.unified_exec_processes.len(), 2); + + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { + turn_id: Some("turn-1".to_string()), + reason: TurnAbortReason::ReviewEnded, + }), + }); + + assert_eq!(chat.unified_exec_processes.len(), 2); + + chat.add_ps_output(); + let cells = drain_insert_history(&mut rx); + let combined = cells + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::>() + .join("\n"); + assert!( + combined.contains("Background terminals"), + "expected /ps to remain available after review-ended abort; got {combined:?}" + ); + assert!( + combined.contains("sleep 5") && combined.contains("sleep 6"), + "expected /ps to list running unified exec processes; got {combined:?}" + ); + + let _ = drain_insert_history(&mut rx); +} + +#[tokio::test] +async fn interrupt_preserves_unified_exec_wait_streak_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + model_context_window: None, + collaboration_mode_kind: ModeKind::Default, + }), + }); + + let begin = begin_unified_exec_startup(&mut chat, "call-1", "process-1", "just fix"); + terminal_interaction(&mut chat, "call-1a", "process-1", ""); + + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { + turn_id: Some("turn-1".to_string()), + reason: TurnAbortReason::Interrupted, + }), + }); + + end_exec(&mut chat, begin, "", "", 0); + let cells = drain_insert_history(&mut rx); + let combined = cells + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::>() + .join("\n"); + let snapshot = format!("cells={}\n{combined}", cells.len()); + assert_snapshot!("interrupt_preserves_unified_exec_wait_streak", snapshot); +} + +#[tokio::test] +async fn turn_complete_keeps_unified_exec_processes() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + + begin_unified_exec_startup(&mut chat, "call-1", "process-1", "sleep 5"); + begin_unified_exec_startup(&mut chat, "call-2", "process-2", "sleep 6"); + assert_eq!(chat.unified_exec_processes.len(), 2); + + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-1".to_string(), + last_agent_message: None, + }), + }); + + assert_eq!(chat.unified_exec_processes.len(), 2); + + chat.add_ps_output(); + let cells = drain_insert_history(&mut rx); + let combined = cells + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::>() + .join("\n"); + assert!( + combined.contains("Background terminals"), + "expected /ps to remain available after turn complete; got {combined:?}" + ); + assert!( + combined.contains("sleep 5") && combined.contains("sleep 6"), + "expected /ps to list running unified exec processes; got {combined:?}" + ); + + let _ = drain_insert_history(&mut rx); +} + +// Snapshot test: ChatWidget at very small heights (idle) +// Ensures overall layout behaves when terminal height is extremely constrained. +#[tokio::test] +async fn ui_snapshots_small_heights_idle() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + let (chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + for h in [1u16, 2, 3] { + let name = format!("chat_small_idle_h{h}"); + let mut terminal = Terminal::new(TestBackend::new(40, h)).expect("create terminal"); + terminal + .draw(|f| chat.render(f.area(), f.buffer_mut())) + .expect("draw chat idle"); + assert_snapshot!(name, terminal.backend()); + } +} + +// Snapshot test: ChatWidget at very small heights (task running) +// Validates how status + composer are presented within tight space. +#[tokio::test] +async fn ui_snapshots_small_heights_task_running() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + // Activate status line + chat.handle_codex_event(Event { + id: "task-1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), model_context_window: None, @@ -10724,104 +11817,245 @@ async fn hook_events_render_snapshot() { }); chat.handle_codex_event(Event { - id: "hook-1".into(), - msg: EventMsg::HookCompleted(codex_protocol::protocol::HookCompletedEvent { - turn_id: None, - run: codex_protocol::protocol::HookRunSummary { - id: "session-start:0:/tmp/hooks.json".to_string(), - event_name: codex_protocol::protocol::HookEventName::SessionStart, - handler_type: codex_protocol::protocol::HookHandlerType::Command, - execution_mode: codex_protocol::protocol::HookExecutionMode::Sync, - scope: codex_protocol::protocol::HookScope::Thread, - source_path: PathBuf::from("/tmp/hooks.json"), - display_order: 0, - status: codex_protocol::protocol::HookRunStatus::Completed, - status_message: Some("warming the shell".to_string()), - started_at: 1, - completed_at: Some(11), - duration_ms: Some(10), - entries: vec![ - codex_protocol::protocol::HookOutputEntry { - kind: codex_protocol::protocol::HookOutputEntryKind::Warning, - text: "Heads up from the hook".to_string(), - }, - codex_protocol::protocol::HookOutputEntry { - kind: codex_protocol::protocol::HookOutputEntryKind::Context, - text: "Remember the startup checklist.".to_string(), - }, - ], - }, + id: "hook-1".into(), + msg: EventMsg::HookCompleted(codex_protocol::protocol::HookCompletedEvent { + turn_id: None, + run: codex_protocol::protocol::HookRunSummary { + id: "session-start:0:/tmp/hooks.json".to_string(), + event_name: codex_protocol::protocol::HookEventName::SessionStart, + handler_type: codex_protocol::protocol::HookHandlerType::Command, + execution_mode: codex_protocol::protocol::HookExecutionMode::Sync, + scope: codex_protocol::protocol::HookScope::Thread, + source_path: PathBuf::from("/tmp/hooks.json"), + display_order: 0, + status: codex_protocol::protocol::HookRunStatus::Completed, + status_message: Some("warming the shell".to_string()), + started_at: 1, + completed_at: Some(11), + duration_ms: Some(10), + entries: vec![ + codex_protocol::protocol::HookOutputEntry { + kind: codex_protocol::protocol::HookOutputEntryKind::Warning, + text: "Heads up from the hook".to_string(), + }, + codex_protocol::protocol::HookOutputEntry { + kind: codex_protocol::protocol::HookOutputEntryKind::Context, + text: "Remember the startup checklist.".to_string(), + }, + ], + }, + }), + }); + + let cells = drain_insert_history(&mut rx); + let combined = cells + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::(); + assert_snapshot!("hook_events_render_snapshot", combined); +} + +// Combined visual snapshot using vt100 for history + direct buffer overlay for UI. +// This renders the final visual as seen in a terminal: history above, then a blank line, +// then the exec block, another blank line, the status line, a blank line, and the composer. +#[tokio::test] +async fn chatwidget_exec_and_status_layout_vt100_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + complete_assistant_message( + &mut chat, + "msg-search", + "I’m going to search the repo for where “Change Approved” is rendered to update that view.", + None, + ); + + let command = vec!["bash".into(), "-lc".into(), "rg \"Change Approved\"".into()]; + let parsed_cmd = vec![ + ParsedCommand::Search { + query: Some("Change Approved".into()), + path: None, + cmd: "rg \"Change Approved\"".into(), + }, + ParsedCommand::Read { + name: "diff_render.rs".into(), + cmd: "cat diff_render.rs".into(), + path: "diff_render.rs".into(), + }, + ]; + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + chat.handle_codex_event(Event { + id: "c1".into(), + msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent { + call_id: "c1".into(), + process_id: None, + turn_id: "turn-1".into(), + command: command.clone(), + cwd: cwd.clone(), + parsed_cmd: parsed_cmd.clone(), + source: ExecCommandSource::Agent, + interaction_input: None, + }), + }); + chat.handle_codex_event(Event { + id: "c1".into(), + msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent { + call_id: "c1".into(), + process_id: None, + turn_id: "turn-1".into(), + command, + cwd, + parsed_cmd, + source: ExecCommandSource::Agent, + interaction_input: None, + stdout: String::new(), + stderr: String::new(), + aggregated_output: String::new(), + exit_code: 0, + duration: std::time::Duration::from_millis(16000), + formatted_output: String::new(), + status: CoreExecCommandStatus::Completed, + }), + }); + chat.handle_codex_event(Event { + id: "t1".into(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + model_context_window: None, + collaboration_mode_kind: ModeKind::Default, + }), + }); + chat.handle_codex_event(Event { + id: "t1".into(), + msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { + delta: "**Investigating rendering code**".into(), + }), + }); + chat.bottom_pane.set_composer_text( + "Summarize recent commits".to_string(), + Vec::new(), + Vec::new(), + ); + + let width: u16 = 80; + let ui_height: u16 = chat.desired_height(width); + let vt_height: u16 = 40; + let viewport = Rect::new(0, vt_height - ui_height - 1, width, ui_height); + + let backend = VT100Backend::new(width, vt_height); + let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); + term.set_viewport_area(viewport); + + for lines in drain_insert_history(&mut rx) { + crate::insert_history::insert_history_lines(&mut term, lines) + .expect("Failed to insert history lines in test"); + } + + term.draw(|f| { + chat.render(f.area(), f.buffer_mut()); + }) + .unwrap(); + + assert_snapshot!(term.backend().vt100().screen().contents()); +} + +// E2E vt100 snapshot for complex markdown with indented and nested fenced code blocks +#[tokio::test] +async fn chatwidget_markdown_code_blocks_vt100_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + + // Simulate a final agent message via streaming deltas instead of a single message + + chat.handle_codex_event(Event { + id: "t1".into(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + model_context_window: None, + collaboration_mode_kind: ModeKind::Default, + }), + }); + // Build a vt100 visual from the history insertions only (no UI overlay) + let width: u16 = 80; + let height: u16 = 50; + let backend = VT100Backend::new(width, height); + let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); + // Place viewport at the last line so that history lines insert above it + term.set_viewport_area(Rect::new(0, height - 1, width, 1)); + + // Simulate streaming via AgentMessageDelta in 2-character chunks (no final AgentMessage). + let source: &str = r#" + + -- Indented code block (4 spaces) + SELECT * + FROM "users" + WHERE "email" LIKE '%@example.com'; + +````markdown +```sh +printf 'fenced within fenced\n' +``` +```` + +```jsonc +{ + // comment allowed in jsonc + "path": "C:\\Program Files\\App", + "regex": "^foo.*(bar)?$" +} +``` +"#; + + let mut it = source.chars(); + loop { + let mut delta = String::new(); + match it.next() { + Some(c) => delta.push(c), + None => break, + } + if let Some(c2) = it.next() { + delta.push(c2); + } + + chat.handle_codex_event(Event { + id: "t1".into(), + msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }), + }); + // Drive commit ticks and drain emitted history lines into the vt100 buffer. + loop { + chat.on_commit_tick(); + let mut inserted_any = false; + while let Ok(app_ev) = rx.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = app_ev { + let lines = cell.display_lines(width); + crate::insert_history::insert_history_lines(&mut term, lines) + .expect("Failed to insert history lines in test"); + inserted_any = true; + } + } + if !inserted_any { + break; + } + } + } + + // Finalize the stream without sending a final AgentMessage, to flush any tail. + chat.handle_codex_event(Event { + id: "t1".into(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-1".to_string(), + last_agent_message: None, }), }); + for lines in drain_insert_history(&mut rx) { + crate::insert_history::insert_history_lines(&mut term, lines) + .expect("Failed to insert history lines in test"); + } - let cells = drain_insert_history(&mut rx); - let combined = cells - .iter() - .map(|lines| lines_to_single_string(lines)) - .collect::(); - assert_snapshot!("hook_events_render_snapshot", combined); + assert_snapshot!(term.backend().vt100().screen().contents()); } -// Combined visual snapshot using vt100 for history + direct buffer overlay for UI. -// This renders the final visual as seen in a terminal: history above, then a blank line, -// then the exec block, another blank line, the status line, a blank line, and the composer. #[tokio::test] -async fn chatwidget_exec_and_status_layout_vt100_snapshot() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - complete_assistant_message( - &mut chat, - "msg-search", - "I’m going to search the repo for where “Change Approved” is rendered to update that view.", - None, - ); - - let command = vec!["bash".into(), "-lc".into(), "rg \"Change Approved\"".into()]; - let parsed_cmd = vec![ - ParsedCommand::Search { - query: Some("Change Approved".into()), - path: None, - cmd: "rg \"Change Approved\"".into(), - }, - ParsedCommand::Read { - name: "diff_render.rs".into(), - cmd: "cat diff_render.rs".into(), - path: "diff_render.rs".into(), - }, - ]; - let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - chat.handle_codex_event(Event { - id: "c1".into(), - msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent { - call_id: "c1".into(), - process_id: None, - turn_id: "turn-1".into(), - command: command.clone(), - cwd: cwd.clone(), - parsed_cmd: parsed_cmd.clone(), - source: ExecCommandSource::Agent, - interaction_input: None, - }), - }); - chat.handle_codex_event(Event { - id: "c1".into(), - msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent { - call_id: "c1".into(), - process_id: None, - turn_id: "turn-1".into(), - command, - cwd, - parsed_cmd, - source: ExecCommandSource::Agent, - interaction_input: None, - stdout: String::new(), - stderr: String::new(), - aggregated_output: String::new(), - exit_code: 0, - duration: std::time::Duration::from_millis(16000), - formatted_output: String::new(), - status: CoreExecCommandStatus::Completed, - }), - }); +async fn chatwidget_tall() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); chat.handle_codex_event(Event { id: "t1".into(), msg: EventMsg::TurnStarted(TurnStartedEvent { @@ -10830,192 +12064,490 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() { collaboration_mode_kind: ModeKind::Default, }), }); + for i in 0..30 { + chat.queue_user_message(format!("Hello, world! {i}").into()); + } + let width: u16 = 80; + let height: u16 = 24; + let backend = VT100Backend::new(width, height); + let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); + let desired_height = chat.desired_height(width).min(height); + term.set_viewport_area(Rect::new(0, height - desired_height, width, desired_height)); + term.draw(|f| { + chat.render(f.area(), f.buffer_mut()); + }) + .unwrap(); + assert_snapshot!(term.backend().vt100().screen().contents()); +} + +#[tokio::test] +async fn enter_queues_user_messages_while_review_is_running() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); + chat.handle_codex_event(Event { - id: "t1".into(), - msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { - delta: "**Investigating rendering code**".into(), + id: "review-1".into(), + msg: EventMsg::EnteredReviewMode(ReviewRequest { + target: ReviewTarget::UncommittedChanges, + user_facing_hint: Some("current changes".to_string()), }), }); + let _ = drain_insert_history(&mut rx); + chat.bottom_pane.set_composer_text( - "Summarize recent commits".to_string(), + "Queued while /review is running.".to_string(), Vec::new(), Vec::new(), ); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - let width: u16 = 80; - let ui_height: u16 = chat.desired_height(width); - let vt_height: u16 = 40; - let viewport = Rect::new(0, vt_height - ui_height - 1, width, ui_height); + assert_eq!(chat.queued_user_messages.len(), 1); + assert_eq!( + chat.queued_user_messages.front().unwrap().text, + "Queued while /review is running." + ); + assert!(chat.pending_steers.is_empty()); + assert_no_submit_op(&mut op_rx); + assert!(drain_insert_history(&mut rx).is_empty()); +} - let backend = VT100Backend::new(width, vt_height); - let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); - term.set_viewport_area(viewport); +#[tokio::test] +async fn review_slash_command_opens_popup_while_task_running() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + chat.on_task_started(); - for lines in drain_insert_history(&mut rx) { - crate::insert_history::insert_history_lines(&mut term, lines) - .expect("Failed to insert history lines in test"); + chat.dispatch_command(SlashCommand::Review); + + assert!(chat.queued_user_messages.is_empty()); + assert!(chat.has_active_view(), "expected /review popup to open"); + assert!(drain_insert_history(&mut rx).is_empty()); +} + +#[tokio::test] +async fn review_slash_command_with_args_queues_while_task_running_and_submits_after_turn_complete() +{ + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.on_task_started(); + chat.bottom_pane.set_composer_text( + "/review audit dependency changes".to_string(), + Vec::new(), + Vec::new(), + ); + + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!( + chat.queued_user_message_texts(), + vec!["/review audit dependency changes".to_string()] + ); + assert_no_submit_op(&mut op_rx); + + chat.on_task_complete(None, false); + + loop { + match op_rx.try_recv() { + Ok(Op::Review { review_request }) => { + assert_eq!( + review_request, + ReviewRequest { + target: ReviewTarget::Custom { + instructions: "audit dependency changes".to_string(), + }, + user_facing_hint: None, + } + ); + break; + } + Ok(_) => continue, + Err(TryRecvError::Empty) => panic!("expected queued /review op"), + Err(TryRecvError::Disconnected) => panic!("expected queued /review op"), + } + } +} + +#[tokio::test] +async fn queued_review_selection_replays_after_turn_complete() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.on_task_started(); + + chat.handle_serialized_slash_command(ChatWidget::review_request_draft(&ReviewRequest { + target: ReviewTarget::BaseBranch { + branch: "main".to_string(), + }, + user_facing_hint: None, + })); + + assert_eq!( + chat.queued_user_message_texts(), + vec!["/review branch main".to_string()] + ); + + chat.on_task_complete(None, false); + + loop { + match op_rx.try_recv() { + Ok(Op::Review { review_request }) => { + assert_eq!( + review_request, + ReviewRequest { + target: ReviewTarget::BaseBranch { + branch: "main".to_string(), + }, + user_facing_hint: None, + } + ); + break; + } + Ok(_) => continue, + Err(TryRecvError::Empty) => panic!("expected queued /review branch op"), + Err(TryRecvError::Disconnected) => panic!("expected queued /review branch op"), + } + } +} + +#[tokio::test] +async fn queued_bare_theme_command_restores_to_composer_instead_of_opening_popup() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.on_task_started(); + chat.queued_user_messages + .push_back(UserMessage::from("/theme".to_string())); + + chat.on_task_complete(None, false); + + assert_eq!(chat.bottom_pane.composer_text(), "/theme"); + assert!(!chat.has_active_view(), "expected no popup during replay"); + assert!(chat.queued_user_messages.is_empty()); + assert_no_submit_op(&mut op_rx); + assert!( + !drain_insert_history(&mut rx).is_empty(), + "expected replay failure to be surfaced to the user" + ); +} + +#[tokio::test] +async fn queued_theme_selection_resumes_followup_after_idle_resume() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.handle_codex_event(Event { + id: "configured".into(), + msg: EventMsg::SessionConfigured(SessionConfiguredEvent { + session_id: ThreadId::new(), + forked_from_id: None, + thread_name: None, + model: "test-model".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: ApprovalsReviewer::User, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + cwd: PathBuf::from("/home/user/project"), + reasoning_effort: Some(ReasoningEffortConfig::default()), + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: None, + }), + }); + chat.on_task_started(); + + chat.handle_serialized_slash_command(UserMessage::from("/theme ansi".to_string())); + chat.queued_user_messages + .push_back(UserMessage::from("followup".to_string())); + + chat.on_task_complete(None, false); + + assert_eq!( + chat.queued_user_message_texts(), + vec!["followup".to_string()] + ); + assert!(chat.resume_queued_inputs_when_idle); + loop { + match rx.try_recv() { + Ok(AppEvent::SyntaxThemeSelected { name }) => { + assert_eq!(name, "ansi"); + break; + } + Ok(_) => continue, + Err(TryRecvError::Empty) => panic!("expected AppEvent::SyntaxThemeSelected"), + Err(TryRecvError::Disconnected) => { + panic!("expected AppEvent::SyntaxThemeSelected") + } + } } + assert_no_submit_op(&mut op_rx); - term.draw(|f| { - chat.render(f.area(), f.buffer_mut()); - }) - .unwrap(); + chat.maybe_resume_queued_inputs_when_idle(); - assert_snapshot!(term.backend().vt100().screen().contents()); + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "followup".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected Op::UserTurn, got {other:?}"), + } + assert!(chat.queued_user_messages.is_empty()); + assert!(!chat.resume_queued_inputs_when_idle); } -// E2E vt100 snapshot for complex markdown with indented and nested fenced code blocks #[tokio::test] -async fn chatwidget_markdown_code_blocks_vt100_snapshot() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - // Simulate a final agent message via streaming deltas instead of a single message - +async fn queued_personality_selection_resumes_followup_after_idle_resume() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.2-codex")).await; + chat.set_feature_enabled(Feature::Personality, true); chat.handle_codex_event(Event { - id: "t1".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - model_context_window: None, - collaboration_mode_kind: ModeKind::Default, + id: "configured".into(), + msg: EventMsg::SessionConfigured(SessionConfiguredEvent { + session_id: ThreadId::new(), + forked_from_id: None, + thread_name: None, + model: "gpt-5.2-codex".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: ApprovalsReviewer::User, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + cwd: PathBuf::from("/home/user/project"), + reasoning_effort: Some(ReasoningEffortConfig::default()), + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: None, }), }); - // Build a vt100 visual from the history insertions only (no UI overlay) - let width: u16 = 80; - let height: u16 = 50; - let backend = VT100Backend::new(width, height); - let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); - // Place viewport at the last line so that history lines insert above it - term.set_viewport_area(Rect::new(0, height - 1, width, 1)); - - // Simulate streaming via AgentMessageDelta in 2-character chunks (no final AgentMessage). - let source: &str = r#" + chat.on_task_started(); - -- Indented code block (4 spaces) - SELECT * - FROM "users" - WHERE "email" LIKE '%@example.com'; + chat.handle_serialized_slash_command(UserMessage::from("/personality pragmatic".to_string())); + chat.queued_user_messages + .push_back(UserMessage::from("followup".to_string())); -````markdown -```sh -printf 'fenced within fenced\n' -``` -```` + chat.on_task_complete(None, false); -```jsonc -{ - // comment allowed in jsonc - "path": "C:\\Program Files\\App", - "regex": "^foo.*(bar)?$" -} -``` -"#; + assert_eq!( + chat.queued_user_message_texts(), + vec!["followup".to_string()] + ); + assert!(chat.resume_queued_inputs_when_idle); + assert_no_submit_op(&mut op_rx); - let mut it = source.chars(); loop { - let mut delta = String::new(); - match it.next() { - Some(c) => delta.push(c), - None => break, - } - if let Some(c2) = it.next() { - delta.push(c2); - } - - chat.handle_codex_event(Event { - id: "t1".into(), - msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }), - }); - // Drive commit ticks and drain emitted history lines into the vt100 buffer. - loop { - chat.on_commit_tick(); - let mut inserted_any = false; - while let Ok(app_ev) = rx.try_recv() { - if let AppEvent::InsertHistoryCell(cell) = app_ev { - let lines = cell.display_lines(width); - crate::insert_history::insert_history_lines(&mut term, lines) - .expect("Failed to insert history lines in test"); - inserted_any = true; - } - } - if !inserted_any { + match rx.try_recv() { + Ok(AppEvent::CodexOp(Op::OverrideTurnContext { + personality: Some(Personality::Pragmatic), + .. + })) => continue, + Ok(AppEvent::UpdatePersonality(Personality::Pragmatic)) => { + chat.set_personality(Personality::Pragmatic); break; } + Ok(AppEvent::PersistPersonalitySelection { + personality: Personality::Pragmatic, + }) => continue, + Ok(_) => continue, + Err(TryRecvError::Empty) => panic!("expected personality update events"), + Err(TryRecvError::Disconnected) => panic!("expected personality update events"), } } - // Finalize the stream without sending a final AgentMessage, to flush any tail. - chat.handle_codex_event(Event { - id: "t1".into(), - msg: EventMsg::TurnComplete(TurnCompleteEvent { - turn_id: "turn-1".to_string(), - last_agent_message: None, - }), - }); - for lines in drain_insert_history(&mut rx) { - crate::insert_history::insert_history_lines(&mut term, lines) - .expect("Failed to insert history lines in test"); - } + assert_no_submit_op(&mut op_rx); - assert_snapshot!(term.backend().vt100().screen().contents()); + chat.maybe_resume_queued_inputs_when_idle(); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { + items, + personality: Some(Personality::Pragmatic), + .. + } => assert_eq!( + items, + vec![UserInput::Text { + text: "followup".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected Op::UserTurn with pragmatic personality, got {other:?}"), + } + assert!(chat.queued_user_messages.is_empty()); + assert!(!chat.resume_queued_inputs_when_idle); } #[tokio::test] -async fn chatwidget_tall() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - chat.thread_id = Some(ThreadId::new()); +async fn queued_followup_waits_for_popup_dismissal_before_idle_resume() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; chat.handle_codex_event(Event { - id: "t1".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - turn_id: "turn-1".to_string(), - model_context_window: None, - collaboration_mode_kind: ModeKind::Default, + id: "configured".into(), + msg: EventMsg::SessionConfigured(SessionConfiguredEvent { + session_id: ThreadId::new(), + forked_from_id: None, + thread_name: None, + model: "test-model".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: ApprovalsReviewer::User, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + cwd: PathBuf::from("/home/user/project"), + reasoning_effort: Some(ReasoningEffortConfig::default()), + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: None, }), }); - for i in 0..30 { - chat.queue_user_message(format!("Hello, world! {i}").into()); + chat.queued_user_messages + .push_back(UserMessage::from("/theme ansi".to_string())); + chat.queued_user_messages + .push_back(UserMessage::from("followup".to_string())); + + chat.drain_queued_inputs_until_blocked(); + + assert_eq!( + chat.queued_user_message_texts(), + vec!["followup".to_string()] + ); + assert!(chat.resume_queued_inputs_when_idle); + loop { + match rx.try_recv() { + Ok(AppEvent::SyntaxThemeSelected { name }) => { + assert_eq!(name, "ansi"); + break; + } + Ok(_) => continue, + Err(TryRecvError::Empty) => panic!("expected AppEvent::SyntaxThemeSelected"), + Err(TryRecvError::Disconnected) => { + panic!("expected AppEvent::SyntaxThemeSelected") + } + } } - let width: u16 = 80; - let height: u16 = 24; - let backend = VT100Backend::new(width, height); - let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); - let desired_height = chat.desired_height(width).min(height); - term.set_viewport_area(Rect::new(0, height - desired_height, width, desired_height)); - term.draw(|f| { - chat.render(f.area(), f.buffer_mut()); - }) - .unwrap(); - assert_snapshot!(term.backend().vt100().screen().contents()); + + chat.open_feedback_consent(crate::app_event::FeedbackCategory::Bug); + chat.maybe_resume_queued_inputs_when_idle(); + + assert_eq!( + chat.queued_user_message_texts(), + vec!["followup".to_string()] + ); + assert_no_submit_op(&mut op_rx); + + let _ = chat + .bottom_pane + .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(!chat.has_active_view()); + assert_matches!(rx.try_recv(), Ok(AppEvent::BottomPaneViewCompleted)); + + chat.maybe_resume_queued_inputs_when_idle(); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "followup".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected Op::UserTurn, got {other:?}"), + } + assert!(chat.queued_user_messages.is_empty()); + assert!(!chat.resume_queued_inputs_when_idle); } #[tokio::test] -async fn enter_queues_user_messages_while_review_is_running() { - let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; - chat.thread_id = Some(ThreadId::new()); +async fn queued_custom_review_selection_preserves_branch_like_instructions_after_turn_complete() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.on_task_started(); - chat.handle_codex_event(Event { - id: "review-1".into(), - msg: EventMsg::EnteredReviewMode(ReviewRequest { - target: ReviewTarget::UncommittedChanges, - user_facing_hint: Some("current changes".to_string()), - }), - }); - let _ = drain_insert_history(&mut rx); + chat.handle_serialized_slash_command(ChatWidget::review_request_draft(&ReviewRequest { + target: ReviewTarget::Custom { + instructions: "branch main but focus on risky migrations".to_string(), + }, + user_facing_hint: None, + })); - chat.bottom_pane.set_composer_text( - "Queued while /review is running.".to_string(), - Vec::new(), - Vec::new(), + assert_eq!( + chat.queued_user_message_texts(), + vec![ + SlashCommandInvocation::with_args( + SlashCommand::Review, + ["branch main but focus on risky migrations"], + ) + .into_user_message() + .text, + ] ); - chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!(chat.queued_user_messages.len(), 1); + chat.on_task_complete(None, false); + + loop { + match op_rx.try_recv() { + Ok(Op::Review { review_request }) => { + assert_eq!( + review_request, + ReviewRequest { + target: ReviewTarget::Custom { + instructions: "branch main but focus on risky migrations".to_string(), + }, + user_facing_hint: None, + } + ); + break; + } + Ok(_) => continue, + Err(TryRecvError::Empty) => panic!("expected queued custom /review op"), + Err(TryRecvError::Disconnected) => panic!("expected queued custom /review op"), + } + } +} + +#[tokio::test] +async fn queued_commit_review_selection_preserves_title_after_turn_complete() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.on_task_started(); + + chat.handle_serialized_slash_command(ChatWidget::review_request_draft(&ReviewRequest { + target: ReviewTarget::Commit { + sha: "abc123".to_string(), + title: Some("Preserve commit subject".to_string()), + }, + user_facing_hint: None, + })); + assert_eq!( - chat.queued_user_messages.front().unwrap().text, - "Queued while /review is running." + chat.queued_user_message_texts(), + vec![ + SlashCommandInvocation::with_args( + SlashCommand::Review, + ["commit", "abc123", "Preserve commit subject"], + ) + .into_user_message() + .text, + ] ); - assert!(chat.pending_steers.is_empty()); - assert_no_submit_op(&mut op_rx); - assert!(drain_insert_history(&mut rx).is_empty()); + + chat.on_task_complete(None, false); + + loop { + match op_rx.try_recv() { + Ok(Op::Review { review_request }) => { + assert_eq!( + review_request, + ReviewRequest { + target: ReviewTarget::Commit { + sha: "abc123".to_string(), + title: Some("Preserve commit subject".to_string()), + }, + user_facing_hint: None, + } + ); + break; + } + Ok(_) => continue, + Err(TryRecvError::Empty) => panic!("expected queued /review commit op"), + Err(TryRecvError::Disconnected) => panic!("expected queued /review commit op"), + } + } } #[tokio::test] diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 3c1151773d6..a41a34cdd3a 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -114,6 +114,7 @@ mod session_log; mod shimmer; mod skills_helpers; mod slash_command; +mod slash_command_invocation; mod status; mod status_indicator_widget; mod streaming; diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index f2d8db0fcbe..f53ea35f563 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -40,7 +40,7 @@ use unicode_width::UnicodeWidthStr; const PAGE_SIZE: usize = 25; const LOAD_NEAR_THRESHOLD: usize = 5; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct SessionTarget { pub path: PathBuf, pub thread_id: ThreadId, diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index d83135c2ffd..3cf459321bd 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -12,6 +12,7 @@ use strum_macros::IntoStaticStr; pub enum SlashCommand { // DO NOT ALPHA-SORT! Enum order is presentation order in the popup, so // more frequently used commands should be listed first. + Help, Model, Fast, Approvals, @@ -55,7 +56,7 @@ pub enum SlashCommand { Realtime, Settings, TestApproval, - #[strum(serialize = "subagents")] + #[strum(serialize = "subagents", serialize = "multi-agents")] MultiAgents, // Debugging commands. #[strum(serialize = "debug-m-drop")] @@ -65,121 +66,415 @@ pub enum SlashCommand { } impl SlashCommand { - /// User-visible description shown in the popup. - pub fn description(self) -> &'static str { + fn spec(self) -> SlashCommandSpec { match self { - SlashCommand::Feedback => "send logs to maintainers", - SlashCommand::New => "start a new chat during a conversation", - SlashCommand::Init => "create an AGENTS.md file with instructions for Codex", - SlashCommand::Compact => "summarize conversation to prevent hitting the context limit", - SlashCommand::Review => "review my current changes and find issues", - SlashCommand::Rename => "rename the current thread", - SlashCommand::Resume => "resume a saved chat", - SlashCommand::Clear => "clear the terminal and start a new chat", - SlashCommand::Fork => "fork the current chat", - // SlashCommand::Undo => "ask Codex to undo a turn", - SlashCommand::Quit | SlashCommand::Exit => "exit Codex", - SlashCommand::Diff => "show git diff (including untracked files)", - SlashCommand::Copy => "copy the latest Codex output to your clipboard", - SlashCommand::Mention => "mention a file", - SlashCommand::Skills => "use skills to improve how Codex performs specific tasks", - SlashCommand::Status => "show current session configuration and token usage", - SlashCommand::DebugConfig => "show config layers and requirement sources for debugging", - SlashCommand::Statusline => "configure which items appear in the status line", - SlashCommand::Theme => "choose a syntax highlighting theme", - SlashCommand::Ps => "list background terminals", - SlashCommand::Stop => "stop all background terminals", - SlashCommand::MemoryDrop => "DO NOT USE", - SlashCommand::MemoryUpdate => "DO NOT USE", - SlashCommand::Model => "choose what model and reasoning effort to use", - SlashCommand::Fast => "toggle Fast mode to enable fastest inference at 2X plan usage", - SlashCommand::Personality => "choose a communication style for Codex", - SlashCommand::Realtime => "toggle realtime voice mode (experimental)", - SlashCommand::Settings => "configure realtime microphone/speaker", - SlashCommand::Plan => "switch to Plan mode", - SlashCommand::Collab => "change collaboration mode (experimental)", - SlashCommand::Agent | SlashCommand::MultiAgents => "switch the active agent thread", - SlashCommand::Approvals => "choose what Codex is allowed to do", - SlashCommand::Permissions => "choose what Codex is allowed to do", - SlashCommand::ElevateSandbox => "set up elevated agent sandbox", - SlashCommand::SandboxReadRoot => { - "let sandbox read a directory: /sandbox-add-read-dir " - } - SlashCommand::Experimental => "toggle experimental features", - SlashCommand::Mcp => "list configured MCP tools", - SlashCommand::Apps => "manage apps", - SlashCommand::Logout => "log out of Codex", - SlashCommand::Rollout => "print the rollout file path", - SlashCommand::TestApproval => "test approval request", + SlashCommand::Help => SlashCommandSpec { + description: "show slash command help", + help_forms: &[""], + requires_interaction: false, + execution_kind: SlashCommandExecutionKind::Immediate, + show_in_command_popup: true, + }, + SlashCommand::Model => SlashCommandSpec { + description: "choose what model and reasoning effort to use", + help_forms: &[ + "", + " [default|none|minimal|low|medium|high|xhigh] [plan-only|all-modes]", + ], + requires_interaction: true, + execution_kind: SlashCommandExecutionKind::ChangesTurnContext, + show_in_command_popup: true, + }, + SlashCommand::Fast => SlashCommandSpec { + description: "toggle Fast mode to enable fastest inference at 2X plan usage", + help_forms: &["", ""], + requires_interaction: false, + execution_kind: SlashCommandExecutionKind::ChangesTurnContext, + show_in_command_popup: true, + }, + SlashCommand::Approvals => SlashCommandSpec { + description: "choose what Codex is allowed to do", + help_forms: &[ + "", + " [--smart-approvals] [--confirm-full-access] [--remember-full-access] [--confirm-world-writable] [--remember-world-writable] [--enable-windows-sandbox=elevated|legacy]", + ], + requires_interaction: true, + execution_kind: SlashCommandExecutionKind::ChangesTurnContext, + show_in_command_popup: false, + }, + SlashCommand::Permissions => SlashCommandSpec { + description: "choose what Codex is allowed to do", + help_forms: &[ + "", + " [--smart-approvals] [--confirm-full-access] [--remember-full-access] [--confirm-world-writable] [--remember-world-writable] [--enable-windows-sandbox=elevated|legacy]", + ], + requires_interaction: true, + execution_kind: SlashCommandExecutionKind::ChangesTurnContext, + show_in_command_popup: true, + }, + SlashCommand::ElevateSandbox => SlashCommandSpec { + description: "set up elevated agent sandbox", + help_forms: &[""], + requires_interaction: false, + execution_kind: SlashCommandExecutionKind::ChangesTurnContext, + show_in_command_popup: true, + }, + SlashCommand::SandboxReadRoot => SlashCommandSpec { + description: "let sandbox read a directory: /sandbox-add-read-dir ", + help_forms: &[""], + requires_interaction: false, + execution_kind: SlashCommandExecutionKind::ChangesTurnContext, + show_in_command_popup: true, + }, + SlashCommand::Experimental => SlashCommandSpec { + description: "toggle experimental features", + help_forms: &["", "=on|off ..."], + requires_interaction: true, + execution_kind: SlashCommandExecutionKind::ChangesTurnContext, + show_in_command_popup: true, + }, + SlashCommand::Skills => SlashCommandSpec { + description: "use skills to improve how Codex performs specific tasks", + help_forms: &["", ""], + requires_interaction: true, + execution_kind: SlashCommandExecutionKind::Immediate, + show_in_command_popup: true, + }, + SlashCommand::Review => SlashCommandSpec { + description: "review my current changes and find issues", + help_forms: &[ + "", + "uncommitted", + "branch ", + "commit [title]", + "", + ], + requires_interaction: true, + execution_kind: SlashCommandExecutionKind::ChangesTurnContext, + show_in_command_popup: true, + }, + SlashCommand::Rename => SlashCommandSpec { + description: "rename the current thread", + help_forms: &["", ""], + requires_interaction: true, + execution_kind: SlashCommandExecutionKind::Immediate, + show_in_command_popup: true, + }, + SlashCommand::New => SlashCommandSpec { + description: "start a new chat during a conversation", + help_forms: &[""], + requires_interaction: false, + execution_kind: SlashCommandExecutionKind::ChangesTurnContext, + show_in_command_popup: true, + }, + SlashCommand::Resume => SlashCommandSpec { + description: "resume a saved chat", + help_forms: &["", "", " --path "], + requires_interaction: true, + execution_kind: SlashCommandExecutionKind::ChangesTurnContext, + show_in_command_popup: true, + }, + SlashCommand::Fork => SlashCommandSpec { + description: "fork the current chat", + help_forms: &[""], + requires_interaction: false, + execution_kind: SlashCommandExecutionKind::ChangesTurnContext, + show_in_command_popup: true, + }, + SlashCommand::Init => SlashCommandSpec { + description: "create an AGENTS.md file with instructions for Codex", + help_forms: &[""], + requires_interaction: false, + execution_kind: SlashCommandExecutionKind::JustLikeUserMessage, + show_in_command_popup: true, + }, + SlashCommand::Compact => SlashCommandSpec { + description: "summarize conversation to prevent hitting the context limit", + help_forms: &[""], + requires_interaction: false, + execution_kind: SlashCommandExecutionKind::ChangesTurnContext, + show_in_command_popup: true, + }, + SlashCommand::Plan => SlashCommandSpec { + description: "switch to Plan mode", + help_forms: &["", ""], + requires_interaction: false, + execution_kind: SlashCommandExecutionKind::JustLikeUserMessage, + show_in_command_popup: true, + }, + SlashCommand::Collab => SlashCommandSpec { + description: "change collaboration mode (experimental)", + help_forms: &["", ""], + requires_interaction: true, + execution_kind: SlashCommandExecutionKind::Immediate, + show_in_command_popup: true, + }, + SlashCommand::Agent => SlashCommandSpec { + description: "switch the active agent thread", + help_forms: &["", ""], + requires_interaction: true, + execution_kind: SlashCommandExecutionKind::Immediate, + show_in_command_popup: true, + }, + SlashCommand::Diff => SlashCommandSpec { + description: "show git diff (including untracked files)", + help_forms: &[""], + requires_interaction: false, + execution_kind: SlashCommandExecutionKind::Immediate, + show_in_command_popup: true, + }, + SlashCommand::Copy => SlashCommandSpec { + description: "copy the latest Codex output to your clipboard", + help_forms: &[""], + requires_interaction: false, + execution_kind: SlashCommandExecutionKind::Immediate, + show_in_command_popup: true, + }, + SlashCommand::Mention => SlashCommandSpec { + description: "mention a file", + help_forms: &[""], + requires_interaction: false, + execution_kind: SlashCommandExecutionKind::Immediate, + show_in_command_popup: true, + }, + SlashCommand::Status => SlashCommandSpec { + description: "show current session configuration and token usage", + help_forms: &[""], + requires_interaction: false, + execution_kind: SlashCommandExecutionKind::Immediate, + show_in_command_popup: true, + }, + SlashCommand::DebugConfig => SlashCommandSpec { + description: "show config layers and requirement sources for debugging", + help_forms: &[""], + requires_interaction: false, + execution_kind: SlashCommandExecutionKind::Immediate, + show_in_command_popup: true, + }, + SlashCommand::Statusline => SlashCommandSpec { + description: "configure which items appear in the status line", + help_forms: &["", "...", "none"], + requires_interaction: true, + execution_kind: SlashCommandExecutionKind::ChangesTurnContext, + show_in_command_popup: true, + }, + SlashCommand::Theme => SlashCommandSpec { + description: "choose a syntax highlighting theme", + help_forms: &["", ""], + requires_interaction: true, + execution_kind: SlashCommandExecutionKind::ChangesTurnContext, + show_in_command_popup: true, + }, + SlashCommand::Mcp => SlashCommandSpec { + description: "list configured MCP tools", + help_forms: &[""], + requires_interaction: false, + execution_kind: SlashCommandExecutionKind::Immediate, + show_in_command_popup: true, + }, + SlashCommand::Apps => SlashCommandSpec { + description: "manage apps", + help_forms: &[""], + requires_interaction: false, + execution_kind: SlashCommandExecutionKind::Immediate, + show_in_command_popup: true, + }, + SlashCommand::Logout => SlashCommandSpec { + description: "log out of Codex", + help_forms: &[""], + requires_interaction: false, + execution_kind: SlashCommandExecutionKind::ChangesTurnContext, + show_in_command_popup: true, + }, + SlashCommand::Quit => SlashCommandSpec { + description: "exit Codex", + help_forms: &[""], + requires_interaction: false, + execution_kind: SlashCommandExecutionKind::Immediate, + show_in_command_popup: false, + }, + SlashCommand::Exit => SlashCommandSpec { + description: "exit Codex", + help_forms: &[""], + requires_interaction: false, + execution_kind: SlashCommandExecutionKind::Immediate, + show_in_command_popup: true, + }, + SlashCommand::Feedback => SlashCommandSpec { + description: "send logs to maintainers", + help_forms: &["", ""], + requires_interaction: true, + execution_kind: SlashCommandExecutionKind::Immediate, + show_in_command_popup: true, + }, + SlashCommand::Rollout => SlashCommandSpec { + description: "print the rollout file path", + help_forms: &[""], + requires_interaction: false, + execution_kind: SlashCommandExecutionKind::Immediate, + show_in_command_popup: true, + }, + SlashCommand::Ps => SlashCommandSpec { + description: "list background terminals", + help_forms: &[""], + requires_interaction: false, + execution_kind: SlashCommandExecutionKind::Immediate, + show_in_command_popup: true, + }, + SlashCommand::Stop => SlashCommandSpec { + description: "stop all background terminals", + help_forms: &[""], + requires_interaction: false, + execution_kind: SlashCommandExecutionKind::Immediate, + show_in_command_popup: true, + }, + SlashCommand::Clear => SlashCommandSpec { + description: "clear the terminal and start a new chat", + help_forms: &[""], + requires_interaction: false, + execution_kind: SlashCommandExecutionKind::ChangesTurnContext, + show_in_command_popup: true, + }, + SlashCommand::Personality => SlashCommandSpec { + description: "choose a communication style for Codex", + help_forms: &["", ""], + requires_interaction: true, + execution_kind: SlashCommandExecutionKind::ChangesTurnContext, + show_in_command_popup: true, + }, + SlashCommand::Realtime => SlashCommandSpec { + description: "toggle realtime voice mode (experimental)", + help_forms: &[""], + requires_interaction: false, + execution_kind: SlashCommandExecutionKind::Immediate, + show_in_command_popup: true, + }, + SlashCommand::Settings => SlashCommandSpec { + description: "configure realtime microphone/speaker", + help_forms: &["", " [default|]"], + requires_interaction: true, + execution_kind: SlashCommandExecutionKind::Immediate, + show_in_command_popup: true, + }, + SlashCommand::TestApproval => SlashCommandSpec { + description: "test approval request", + help_forms: &[""], + requires_interaction: false, + execution_kind: SlashCommandExecutionKind::Immediate, + show_in_command_popup: true, + }, + SlashCommand::MultiAgents => SlashCommandSpec { + description: "switch the active agent thread", + help_forms: &["", ""], + requires_interaction: true, + execution_kind: SlashCommandExecutionKind::Immediate, + show_in_command_popup: true, + }, + SlashCommand::MemoryDrop => SlashCommandSpec { + description: "DO NOT USE", + help_forms: &[""], + requires_interaction: false, + execution_kind: SlashCommandExecutionKind::ChangesTurnContext, + show_in_command_popup: true, + }, + SlashCommand::MemoryUpdate => SlashCommandSpec { + description: "DO NOT USE", + help_forms: &[""], + requires_interaction: false, + execution_kind: SlashCommandExecutionKind::ChangesTurnContext, + show_in_command_popup: true, + }, } } + /// User-visible description shown in the popup. + pub fn description(self) -> &'static str { + self.spec().description + } + /// Command string without the leading '/'. Provided for compatibility with /// existing code that expects a method named `command()`. pub fn command(self) -> &'static str { - self.into() - } - - /// Whether this command supports inline args (for example `/review ...`). - pub fn supports_inline_args(self) -> bool { - matches!( - self, - SlashCommand::Review - | SlashCommand::Rename - | SlashCommand::Plan - | SlashCommand::Fast - | SlashCommand::SandboxReadRoot - ) + match self { + SlashCommand::MultiAgents => "subagents", + _ => self.into(), + } } - /// Whether this command can be run while a task is in progress. - pub fn available_during_task(self) -> bool { + /// Additional accepted built-in names besides `command()`. + pub fn command_aliases(self) -> &'static [&'static str] { match self { - SlashCommand::New - | SlashCommand::Resume - | SlashCommand::Fork - | SlashCommand::Init - | SlashCommand::Compact - // | SlashCommand::Undo + SlashCommand::Help | SlashCommand::Model | SlashCommand::Fast - | SlashCommand::Personality | SlashCommand::Approvals | SlashCommand::Permissions | SlashCommand::ElevateSandbox | SlashCommand::SandboxReadRoot | SlashCommand::Experimental + | SlashCommand::Skills | SlashCommand::Review + | SlashCommand::Rename + | SlashCommand::New + | SlashCommand::Resume + | SlashCommand::Fork + | SlashCommand::Init + | SlashCommand::Compact | SlashCommand::Plan - | SlashCommand::Clear - | SlashCommand::Logout - | SlashCommand::MemoryDrop - | SlashCommand::MemoryUpdate => false, - SlashCommand::Diff + | SlashCommand::Collab + | SlashCommand::Agent + | SlashCommand::Diff | SlashCommand::Copy - | SlashCommand::Rename | SlashCommand::Mention - | SlashCommand::Skills | SlashCommand::Status | SlashCommand::DebugConfig - | SlashCommand::Ps - | SlashCommand::Stop + | SlashCommand::Statusline + | SlashCommand::Theme | SlashCommand::Mcp | SlashCommand::Apps - | SlashCommand::Feedback + | SlashCommand::Logout | SlashCommand::Quit - | SlashCommand::Exit => true, - SlashCommand::Rollout => true, - SlashCommand::TestApproval => true, - SlashCommand::Realtime => true, - SlashCommand::Settings => true, - SlashCommand::Collab => true, - SlashCommand::Agent | SlashCommand::MultiAgents => true, - SlashCommand::Statusline => false, - SlashCommand::Theme => false, + | SlashCommand::Exit + | SlashCommand::Feedback + | SlashCommand::Rollout + | SlashCommand::Ps + | SlashCommand::Clear + | SlashCommand::Personality + | SlashCommand::Realtime + | SlashCommand::Settings + | SlashCommand::TestApproval + | SlashCommand::MemoryDrop + | SlashCommand::MemoryUpdate => &[], + SlashCommand::Stop => &["clean"], + SlashCommand::MultiAgents => &["multi-agents"], } } + pub fn all_command_names(self) -> impl Iterator { + std::iter::once(self.command()).chain(self.command_aliases().iter().copied()) + } + + /// Human-facing forms accepted by the TUI. + /// + /// An empty string represents the bare `/command` form. + pub fn help_forms(self) -> &'static [&'static str] { + self.spec().help_forms + } + + /// Whether bare dispatch opens interactive UI that should be resolved before queueing. + pub fn requires_interaction(self) -> bool { + self.spec().requires_interaction + } + + /// How this command should behave when dispatched while another turn is running. + pub fn execution_kind(self) -> SlashCommandExecutionKind { + self.spec().execution_kind + } + + pub fn show_in_command_popup(self) -> bool { + self.spec().show_in_command_popup + } + fn is_visible(self) -> bool { match self { SlashCommand::SandboxReadRoot => cfg!(target_os = "windows"), @@ -190,11 +485,46 @@ impl SlashCommand { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SlashCommandExecutionKind { + /// Behaves like a normal user message. + /// + /// Enter should submit immediately when idle, and queue while a turn is running. + /// Use this for commands whose effect is "ask the model to do work now". + JustLikeUserMessage, + + /// Does not become a user message, but changes state that affects future turns. + /// + /// While a turn is running, it must queue and apply later in order. + ChangesTurnContext, + + /// Does not submit model work and does not need to wait for the current turn. + /// + /// Run it immediately, even while a turn is in progress. + Immediate, +} + +#[derive(Clone, Copy)] +struct SlashCommandSpec { + description: &'static str, + help_forms: &'static [&'static str], + requires_interaction: bool, + execution_kind: SlashCommandExecutionKind, + show_in_command_popup: bool, +} + /// Return all built-in commands in a Vec paired with their command string. pub fn built_in_slash_commands() -> Vec<(&'static str, SlashCommand)> { SlashCommand::iter() .filter(|command| command.is_visible()) - .map(|c| (c.command(), c)) + .flat_map(|command| command.all_command_names().map(move |name| (name, command))) + .collect() +} + +/// Return all visible built-in commands once each, in presentation order. +pub fn visible_built_in_slash_commands() -> Vec { + SlashCommand::iter() + .filter(|command| command.is_visible()) .collect() } diff --git a/codex-rs/tui/src/slash_command_invocation.rs b/codex-rs/tui/src/slash_command_invocation.rs new file mode 100644 index 00000000000..2dcae03225f --- /dev/null +++ b/codex-rs/tui/src/slash_command_invocation.rs @@ -0,0 +1,78 @@ +use crate::chatwidget::UserMessage; +use crate::slash_command::SlashCommand; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SlashCommandInvocation { + pub(crate) command: SlashCommand, + pub(crate) args: Vec, +} + +impl SlashCommandInvocation { + pub(crate) fn bare(command: SlashCommand) -> Self { + Self { + command, + args: Vec::new(), + } + } + + pub(crate) fn with_args(command: SlashCommand, args: I) -> Self + where + I: IntoIterator, + S: Into, + { + Self { + command, + args: args.into_iter().map(Into::into).collect(), + } + } + + pub(crate) fn parse_args(args: &str, usage: &str) -> Result, String> { + shlex::split(args).ok_or_else(|| usage.to_string()) + } + + pub(crate) fn into_user_message(self) -> UserMessage { + let command = self.command.command(); + let joined = match shlex::try_join( + std::iter::once(command).chain(self.args.iter().map(String::as_str)), + ) { + Ok(joined) => joined, + Err(err) => panic!("slash command invocation should serialize: {err}"), + }; + format!("/{joined}").into() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn serializes_quoted_args() { + let draft = SlashCommandInvocation::with_args( + SlashCommand::Review, + ["branch main needs coverage".to_string()], + ) + .into_user_message(); + + assert_eq!( + draft, + UserMessage::from("/review 'branch main needs coverage'") + ); + } + + #[test] + fn parses_shlex_args() { + let parsed = SlashCommandInvocation::parse_args("'branch main' --flag key=value", "usage") + .expect("quoted args should parse"); + + assert_eq!( + parsed, + vec![ + "branch main".to_string(), + "--flag".to_string(), + "key=value".to_string() + ] + ); + } +} diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index 9fa85a2e419..0a1db581790 100644 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ b/codex-rs/tui/src/status_indicator_widget.rs @@ -7,7 +7,6 @@ use std::time::Duration; use std::time::Instant; -use codex_protocol::protocol::Op; use crossterm::event::KeyCode; use ratatui::buffer::Buffer; use ratatui::layout::Rect; @@ -19,8 +18,6 @@ use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; use unicode_width::UnicodeWidthStr; -use crate::app_event::AppEvent; -use crate::app_event_sender::AppEventSender; use crate::exec_cell::spinner; use crate::key_hint; use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; @@ -53,7 +50,6 @@ pub(crate) struct StatusIndicatorWidget { elapsed_running: Duration, last_resume_at: Instant, is_paused: bool, - app_event_tx: AppEventSender, frame_requester: FrameRequester, animations_enabled: bool, } @@ -76,11 +72,7 @@ pub fn fmt_elapsed_compact(elapsed_secs: u64) -> String { } impl StatusIndicatorWidget { - pub(crate) fn new( - app_event_tx: AppEventSender, - frame_requester: FrameRequester, - animations_enabled: bool, - ) -> Self { + pub(crate) fn new(frame_requester: FrameRequester, animations_enabled: bool) -> Self { Self { header: String::from("Working"), details: None, @@ -91,16 +83,11 @@ impl StatusIndicatorWidget { last_resume_at: Instant::now(), is_paused: false, - app_event_tx, frame_requester, animations_enabled, } } - pub(crate) fn interrupt(&self) { - self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt)); - } - /// Update the animated header label (left of the brackets). pub(crate) fn update_header(&mut self, header: String) { self.header = header; @@ -292,13 +279,10 @@ impl Renderable for StatusIndicatorWidget { #[cfg(test)] mod tests { use super::*; - use crate::app_event::AppEvent; - use crate::app_event_sender::AppEventSender; use ratatui::Terminal; use ratatui::backend::TestBackend; use std::time::Duration; use std::time::Instant; - use tokio::sync::mpsc::unbounded_channel; use pretty_assertions::assert_eq; @@ -318,9 +302,7 @@ mod tests { #[test] fn renders_with_working_header() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true); + let w = StatusIndicatorWidget::new(crate::tui::FrameRequester::test_dummy(), true); // Render into a fixed-size test terminal and snapshot the backend. let mut terminal = Terminal::new(TestBackend::new(80, 2)).expect("terminal"); @@ -332,9 +314,7 @@ mod tests { #[test] fn renders_truncated() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true); + let w = StatusIndicatorWidget::new(crate::tui::FrameRequester::test_dummy(), true); // Render into a fixed-size test terminal and snapshot the backend. let mut terminal = Terminal::new(TestBackend::new(20, 2)).expect("terminal"); @@ -346,9 +326,7 @@ mod tests { #[test] fn renders_wrapped_details_panama_two_lines() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), false); + let mut w = StatusIndicatorWidget::new(crate::tui::FrameRequester::test_dummy(), false); w.update_details( Some("A man a plan a canal panama".to_string()), StatusDetailsCapitalization::CapitalizeFirst, @@ -371,10 +349,7 @@ mod tests { #[test] fn timer_pauses_when_requested() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let mut widget = - StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true); + let mut widget = StatusIndicatorWidget::new(crate::tui::FrameRequester::test_dummy(), true); let baseline = Instant::now(); widget.last_resume_at = baseline; @@ -393,9 +368,7 @@ mod tests { #[test] fn details_overflow_adds_ellipsis() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true); + let mut w = StatusIndicatorWidget::new(crate::tui::FrameRequester::test_dummy(), true); w.update_details( Some("abcd abcd abcd abcd".to_string()), StatusDetailsCapitalization::CapitalizeFirst, @@ -413,9 +386,7 @@ mod tests { #[test] fn details_args_can_disable_capitalization_and_limit_lines() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true); + let mut w = StatusIndicatorWidget::new(crate::tui::FrameRequester::test_dummy(), true); w.update_details( Some("cargo test -p codex-core and then cargo test -p codex-tui".to_string()), StatusDetailsCapitalization::Preserve, diff --git a/codex-rs/tui/src/theme_picker.rs b/codex-rs/tui/src/theme_picker.rs index 54910ee10a8..59d0e2861f0 100644 --- a/codex-rs/tui/src/theme_picker.rs +++ b/codex-rs/tui/src/theme_picker.rs @@ -8,8 +8,8 @@ //! the preview panel and any visible code blocks. //! - **Cancel-restore:** on dismiss (Esc / Ctrl+C) the `on_cancel` callback //! restores the theme snapshot taken when the picker opened. -//! - **Persist on confirm:** the `AppEvent::SyntaxThemeSelected` action persists -//! `[tui] theme = "..."` to `config.toml` via `ConfigEditsBuilder`. +//! - **Persist on confirm:** the picker emits a canonical `/theme ` draft, +//! which then persists `[tui] theme = "..."` through normal slash-command handling. //! //! Two preview renderables adapt to terminal width: //! @@ -35,6 +35,8 @@ use crate::diff_render::push_wrapped_diff_line_with_style_context; use crate::diff_render::push_wrapped_diff_line_with_syntax_and_style_context; use crate::render::highlight; use crate::render::renderable::Renderable; +use crate::slash_command::SlashCommand; +use crate::slash_command_invocation::SlashCommandInvocation; use crate::status::format_directory_display; use ratatui::buffer::Buffer; use ratatui::layout::Rect; @@ -356,9 +358,13 @@ pub(crate) fn build_theme_picker_params( dismiss_on_select: true, search_value: Some(entry.name.clone()), actions: vec![Box::new(move |tx| { - tx.send(AppEvent::SyntaxThemeSelected { - name: name_for_action.clone(), - }); + tx.send(AppEvent::HandleSlashCommandDraft( + SlashCommandInvocation::with_args( + SlashCommand::Theme, + [name_for_action.clone()], + ) + .into_user_message(), + )); })], ..Default::default() } diff --git a/codex-rs/tui_app_server/src/app.rs b/codex-rs/tui_app_server/src/app.rs index 9fd3f1bd3dd..b7092433778 100644 --- a/codex-rs/tui_app_server/src/app.rs +++ b/codex-rs/tui_app_server/src/app.rs @@ -39,6 +39,7 @@ 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::resume_picker::SessionTarget; use crate::tui; use crate::tui::TuiEvent; use crate::update_action::UpdateAction; @@ -54,6 +55,7 @@ use codex_core::config::types::ApprovalsReviewer; use codex_core::config::types::ModelAvailabilityNuxConfig; use codex_core::config_loader::ConfigLayerStackOrdering; use codex_core::features::Feature; +use codex_core::find_thread_path_by_id_str; use codex_core::models_manager::collaboration_mode_presets::CollaborationModesConfig; 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; @@ -758,6 +760,7 @@ pub(crate) struct App { primary_thread_id: Option, primary_session_configured: Option, pending_primary_events: VecDeque, + pending_async_queue_resume_barriers: usize, pending_app_server_requests: PendingAppServerRequests, } @@ -786,6 +789,33 @@ fn normalize_harness_overrides_for_cwd( } impl App { + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + fn begin_async_queue_resume_barrier(&mut self) { + self.pending_async_queue_resume_barriers += 1; + } + + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + fn finish_async_queue_resume_barrier(&mut self) { + if self.pending_async_queue_resume_barriers == 0 { + tracing::warn!("finished async queue-resume barrier with no pending barrier"); + return; + } + self.pending_async_queue_resume_barriers -= 1; + } + + fn maybe_resume_queued_inputs_after_app_events(&mut self, app_events_drained: bool) { + if !app_events_drained || self.pending_async_queue_resume_barriers != 0 { + return; + } + + self.chat_widget.maybe_resume_queued_inputs_when_idle(); + } + + fn restore_input_state_after_thread_switch(&mut self, input_state: Option) { + self.chat_widget.restore_thread_input_state(input_state); + self.chat_widget.drain_queued_inputs_until_blocked(); + } + pub fn chatwidget_init_for_forked_or_resumed_thread( &self, tui: &mut tui::Tui, @@ -867,6 +897,106 @@ impl App { } } + async fn resume_session_target( + &mut self, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + target_session: SessionTarget, + ) -> Result { + let current_cwd = self.config.cwd.clone(); + let resume_cwd = match crate::resolve_cwd_for_resume_or_fork( + tui, + &self.config, + ¤t_cwd, + target_session.thread_id, + target_session.path.as_deref(), + CwdPromptAction::Resume, + true, + ) + .await? + { + crate::ResolveCwdOutcome::Continue(Some(cwd)) => cwd, + crate::ResolveCwdOutcome::Continue(None) => current_cwd.clone(), + crate::ResolveCwdOutcome::Exit => { + return Ok(self + .handle_exit_mode(app_server, ExitMode::ShutdownFirst) + .await); + } + }; + let mut resume_config = match self + .rebuild_config_for_resume_or_fallback(¤t_cwd, resume_cwd) + .await + { + Ok(cfg) => cfg, + Err(err) => { + self.chat_widget.add_error_message(format!( + "Failed to rebuild configuration for resume: {err}" + )); + return Ok(AppRunControl::Continue); + } + }; + self.apply_runtime_policy_overrides(&mut resume_config); + let summary = session_summary( + self.chat_widget.token_usage(), + self.chat_widget.thread_id(), + self.chat_widget.thread_name(), + ); + match app_server + .resume_thread(resume_config.clone(), target_session.thread_id) + .await + { + Ok(resumed) => { + let input_state = self.chat_widget.capture_thread_input_state(); + self.shutdown_current_thread(app_server).await; + self.config = resume_config; + tui.set_notification_method(self.config.tui_notification_method); + self.file_search.update_search_dir(self.config.cwd.clone()); + match self + .replace_chat_widget_with_app_server_thread(tui, resumed) + .await + { + Ok(()) => { + 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) => { + self.chat_widget.add_error_message(format!( + "Failed to attach to resumed app-server thread: {err}" + )); + } + } + self.restore_input_state_after_thread_switch(input_state); + } + Err(err) => { + let path_display = target_session.display_label(); + self.chat_widget.add_error_message(format!( + "Failed to resume session from {path_display}: {err}" + )); + } + } + Ok(AppRunControl::Continue) + } + + async fn resume_session_by_thread_id( + &mut self, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + thread_id: ThreadId, + ) -> Result { + let path = + find_thread_path_by_id_str(&self.config.codex_home, &thread_id.to_string()).await?; + self.resume_session_target(tui, app_server, SessionTarget { path, thread_id }) + .await + } + fn apply_runtime_policy_overrides(&mut self, config: &mut Config) { if let Some(policy) = self.runtime_approval_policy_override.as_ref() && let Err(err) = config.permissions.approval_policy.set(*policy) @@ -1939,7 +2069,13 @@ impl App { description: Some(uuid.clone()), is_current: self.active_thread_id == Some(*thread_id), actions: vec![Box::new(move |tx| { - tx.send(AppEvent::SelectAgentThread(id)); + tx.send(AppEvent::HandleSlashCommandDraft( + crate::slash_command_invocation::SlashCommandInvocation::with_args( + crate::slash_command::SlashCommand::Agent, + [id.to_string()], + ) + .into_user_message(), + )); })], dismiss_on_select: true, search_value: Some(format!("{name} {uuid}")), @@ -2072,6 +2208,7 @@ impl App { self.chat_widget.thread_id(), self.chat_widget.thread_name(), ); + let input_state = self.chat_widget.capture_thread_input_state(); self.shutdown_current_thread(app_server).await; let tracked_thread_ids: Vec = self.thread_event_channels.keys().copied().collect(); @@ -2098,6 +2235,7 @@ impl App { } self.chat_widget.add_plain_history_lines(lines); } + self.restore_input_state_after_thread_switch(input_state); } Err(err) => { self.chat_widget.add_error_message(format!( @@ -2200,7 +2338,7 @@ impl App { self.chat_widget .set_queue_autosend_suppressed(/*suppressed*/ false); if resume_restored_queue { - self.chat_widget.maybe_send_next_queued_input(); + self.chat_widget.drain_queued_inputs_until_blocked(); } self.refresh_status_line(); } @@ -2472,6 +2610,7 @@ impl App { primary_thread_id: None, primary_session_configured: None, pending_primary_events: VecDeque::new(), + pending_async_queue_resume_barriers: 0, pending_app_server_requests: PendingAppServerRequests::default(), }; if let Some(session_configured) = initial_session_configured { @@ -2498,6 +2637,7 @@ impl App { .hide_world_writable_warning .unwrap_or(false); if should_check { + app.begin_async_queue_resume_barrier(); let cwd = app.config.cwd.clone(); let env_map: std::collections::HashMap = std::env::vars().collect(); let tx = app.app_event_tx.clone(); @@ -2590,6 +2730,11 @@ impl App { ) { waiting_for_initial_session_configured = false; } + // Some replayed slash commands pause queue draining until their app-side updates, + // popup flows, or async follow-up work settle. Only resume once the app-event + // queue is fully drained and no background slash-command completions are still + // pending, so later queued input cannot interleave with those updates. + app.maybe_resume_queued_inputs_after_app_events(app_event_rx.is_empty()); match control { AppRunControl::Continue => {} AppRunControl::Exit(reason) => break Ok(reason), @@ -2727,87 +2872,10 @@ impl App { .await? { SessionSelection::Resume(target_session) => { - let current_cwd = self.config.cwd.clone(); - let resume_cwd = if self.remote_app_server_url.is_some() { - current_cwd.clone() - } else { - match crate::resolve_cwd_for_resume_or_fork( - tui, - &self.config, - ¤t_cwd, - target_session.thread_id, - target_session.path.as_deref(), - CwdPromptAction::Resume, - /*allow_prompt*/ true, - ) - .await? - { - crate::ResolveCwdOutcome::Continue(Some(cwd)) => cwd, - crate::ResolveCwdOutcome::Continue(None) => current_cwd.clone(), - crate::ResolveCwdOutcome::Exit => { - return Ok(AppRunControl::Exit(ExitReason::UserRequested)); - } - } - }; - let mut resume_config = match self - .rebuild_config_for_resume_or_fallback(¤t_cwd, resume_cwd) - .await - { - Ok(cfg) => cfg, - Err(err) => { - self.chat_widget.add_error_message(format!( - "Failed to rebuild configuration for resume: {err}" - )); - return Ok(AppRunControl::Continue); - } - }; - self.apply_runtime_policy_overrides(&mut resume_config); - let summary = session_summary( - self.chat_widget.token_usage(), - self.chat_widget.thread_id(), - self.chat_widget.thread_name(), + self.chat_widget.handle_serialized_slash_command( + ChatWidget::resume_selection_draft(&target_session), ); - match app_server - .resume_thread(resume_config.clone(), target_session.thread_id) - .await - { - Ok(resumed) => { - self.shutdown_current_thread(app_server).await; - self.config = resume_config; - tui.set_notification_method(self.config.tui_notification_method); - self.file_search.update_search_dir(self.config.cwd.clone()); - match self - .replace_chat_widget_with_app_server_thread(tui, resumed) - .await - { - Ok(()) => { - 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) => { - self.chat_widget.add_error_message(format!( - "Failed to attach to resumed app-server thread: {err}" - )); - } - } - } - Err(err) => { - let path_display = target_session.display_label(); - self.chat_widget.add_error_message(format!( - "Failed to resume session from {path_display}: {err}" - )); - } - } + self.refresh_status_line(); } SessionSelection::Exit | SessionSelection::StartFresh @@ -2817,6 +2885,16 @@ impl App { // Leaving alt-screen may blank the inline viewport; force a redraw either way. tui.frame_requester().schedule_frame(); } + AppEvent::ResumeSession(thread_id) => { + return self + .resume_session_by_thread_id(tui, app_server, thread_id) + .await; + } + AppEvent::ResumeSessionTarget(target_session) => { + return self + .resume_session_target(tui, app_server, target_session) + .await; + } AppEvent::ForkCurrentSession => { self.session_telemetry.counter( "codex.thread.fork", @@ -2835,6 +2913,7 @@ impl App { .await; match app_server.fork_thread(self.config.clone(), thread_id).await { Ok(forked) => { + let input_state = self.chat_widget.capture_thread_input_state(); self.shutdown_current_thread(app_server).await; match self .replace_chat_widget_with_app_server_thread(tui, forked) @@ -2853,6 +2932,7 @@ impl App { } self.chat_widget.add_plain_history_lines(lines); } + self.restore_input_state_after_thread_switch(input_state); } Err(err) => { self.chat_widget.add_error_message(format!( @@ -3019,6 +3099,11 @@ impl App { self.chat_widget.set_model(&model); self.refresh_status_line(); } + AppEvent::HandleSlashCommandDraft(draft) => { + self.chat_widget.handle_serialized_slash_command(draft); + self.refresh_status_line(); + } + AppEvent::BottomPaneViewCompleted => {} AppEvent::UpdateCollaborationMode(mask) => { self.chat_widget.set_collaboration_mask(mask); self.refresh_status_line(); @@ -3048,12 +3133,14 @@ impl App { } AppEvent::OpenWorldWritableWarningConfirmation { preset, + approvals_reviewer, sample_paths, extra_count, failed_scan, } => { self.chat_widget.open_world_writable_warning_confirmation( preset, + approvals_reviewer, sample_paths, extra_count, failed_scan, @@ -3073,10 +3160,17 @@ impl App { self.launch_external_editor(tui).await; } } - AppEvent::OpenWindowsSandboxEnablePrompt { preset } => { - self.chat_widget.open_windows_sandbox_enable_prompt(preset); + AppEvent::OpenWindowsSandboxEnablePrompt { + preset, + approvals_reviewer, + } => { + self.chat_widget + .open_windows_sandbox_enable_prompt(preset, approvals_reviewer); } - AppEvent::OpenWindowsSandboxFallbackPrompt { preset } => { + AppEvent::OpenWindowsSandboxFallbackPrompt { + preset, + approvals_reviewer, + } => { self.session_telemetry.counter( "codex.windows_sandbox.fallback_prompt_shown", /*inc*/ 1, @@ -3091,9 +3185,12 @@ impl App { ); } self.chat_widget - .open_windows_sandbox_fallback_prompt(preset); + .open_windows_sandbox_fallback_prompt(preset, approvals_reviewer); } - AppEvent::BeginWindowsSandboxElevatedSetup { preset } => { + AppEvent::BeginWindowsSandboxElevatedSetup { + preset, + approvals_reviewer, + } => { #[cfg(target_os = "windows")] { let policy = preset.sandbox.clone(); @@ -3111,12 +3208,14 @@ impl App { tx.send(AppEvent::EnableWindowsSandboxForAgentMode { preset, mode: WindowsSandboxEnableMode::Elevated, + approvals_reviewer, }); return Ok(AppRunControl::Continue); } self.chat_widget.show_windows_sandbox_setup_status(); self.windows_sandbox.setup_started_at = Some(Instant::now()); + self.begin_async_queue_resume_barrier(); let session_telemetry = self.session_telemetry.clone(); tokio::task::spawn_blocking(move || { let result = codex_core::windows_sandbox::run_elevated_setup( @@ -3133,9 +3232,10 @@ impl App { 1, &[], ); - AppEvent::EnableWindowsSandboxForAgentMode { + AppEvent::WindowsSandboxElevatedSetupCompleted { preset: preset.clone(), - mode: WindowsSandboxEnableMode::Elevated, + approvals_reviewer, + setup_succeeded: true, } } Err(err) => { @@ -3167,7 +3267,11 @@ impl App { error = %err, "failed to run elevated Windows sandbox setup" ); - AppEvent::OpenWindowsSandboxFallbackPrompt { preset } + AppEvent::WindowsSandboxElevatedSetupCompleted { + preset, + approvals_reviewer, + setup_succeeded: false, + } } }; tx.send(event); @@ -3175,10 +3279,40 @@ impl App { } #[cfg(not(target_os = "windows"))] { - let _ = preset; + let _ = (preset, approvals_reviewer); } } - AppEvent::BeginWindowsSandboxLegacySetup { preset } => { + AppEvent::WindowsSandboxElevatedSetupCompleted { + preset, + approvals_reviewer, + setup_succeeded, + } => { + #[cfg(target_os = "windows")] + { + self.finish_async_queue_resume_barrier(); + let event = if setup_succeeded { + AppEvent::EnableWindowsSandboxForAgentMode { + preset, + mode: WindowsSandboxEnableMode::Elevated, + approvals_reviewer, + } + } else { + AppEvent::OpenWindowsSandboxFallbackPrompt { + preset, + approvals_reviewer, + } + }; + self.app_event_tx.send(event); + } + #[cfg(not(target_os = "windows"))] + { + let _ = (preset, approvals_reviewer, setup_succeeded); + } + } + AppEvent::BeginWindowsSandboxLegacySetup { + preset, + approvals_reviewer, + } => { #[cfg(target_os = "windows")] { let policy = preset.sandbox.clone(); @@ -3188,36 +3322,70 @@ impl App { std::env::vars().collect(); let codex_home = self.config.codex_home.clone(); let tx = self.app_event_tx.clone(); - let session_telemetry = self.session_telemetry.clone(); - self.chat_widget.show_windows_sandbox_setup_status(); + self.begin_async_queue_resume_barrier(); tokio::task::spawn_blocking(move || { - if let Err(err) = codex_core::windows_sandbox::run_legacy_setup_preflight( + let preset_for_error = preset.clone(); + let result = codex_core::windows_sandbox::run_legacy_setup_preflight( &policy, policy_cwd.as_path(), command_cwd.as_path(), &env_map, codex_home.as_path(), - ) { - session_telemetry.counter( - "codex.windows_sandbox.legacy_setup_preflight_failed", - 1, - &[], - ); - tracing::warn!( - error = %err, - "failed to preflight non-admin Windows sandbox setup" - ); - } - tx.send(AppEvent::EnableWindowsSandboxForAgentMode { - preset, - mode: WindowsSandboxEnableMode::Legacy, - }); + ); + let event = match result { + Ok(()) => AppEvent::WindowsSandboxLegacySetupCompleted { + preset, + approvals_reviewer, + error: None, + }, + Err(err) => { + tracing::error!( + error = %err, + "failed to run legacy Windows sandbox setup preflight" + ); + AppEvent::WindowsSandboxLegacySetupCompleted { + preset: preset_for_error, + approvals_reviewer, + error: Some(err.to_string()), + } + } + }; + tx.send(event); }); } #[cfg(not(target_os = "windows"))] { - let _ = preset; + let _ = (preset, approvals_reviewer); + } + } + AppEvent::WindowsSandboxLegacySetupCompleted { + preset, + approvals_reviewer, + error, + } => { + #[cfg(target_os = "windows")] + { + self.finish_async_queue_resume_barrier(); + match error { + None => { + self.app_event_tx + .send(AppEvent::EnableWindowsSandboxForAgentMode { + preset, + mode: WindowsSandboxEnableMode::Legacy, + approvals_reviewer, + }); + } + Some(err) => { + self.chat_widget.add_error_message(format!( + "Failed to enable the Windows sandbox feature: {err}" + )); + } + } + } + #[cfg(not(target_os = "windows"))] + { + let _ = (preset, approvals_reviewer, error); } } AppEvent::BeginWindowsSandboxGrantReadRoot { path } => { @@ -3277,7 +3445,11 @@ impl App { )); } }, - AppEvent::EnableWindowsSandboxForAgentMode { preset, mode } => { + AppEvent::EnableWindowsSandboxForAgentMode { + preset, + mode, + approvals_reviewer, + } => { #[cfg(target_os = "windows")] { self.chat_widget.clear_windows_sandbox_setup_status(); @@ -3335,6 +3507,7 @@ impl App { self.app_event_tx.send( AppEvent::OpenWorldWritableWarningConfirmation { preset: Some(preset.clone()), + approvals_reviewer: Some(approvals_reviewer), sample_paths, extra_count, failed_scan, @@ -3362,6 +3535,8 @@ impl App { .send(AppEvent::UpdateAskForApprovalPolicy(preset.approval)); self.app_event_tx .send(AppEvent::UpdateSandboxPolicy(preset.sandbox.clone())); + self.app_event_tx + .send(AppEvent::UpdateApprovalsReviewer(approvals_reviewer)); let _ = mode; self.chat_widget.add_plain_history_lines(vec![ Line::from(vec!["• ".dim(), "Sandbox ready".into()]), @@ -3386,7 +3561,7 @@ impl App { } #[cfg(not(target_os = "windows"))] { - let _ = (preset, mode); + let _ = (preset, mode, approvals_reviewer); } } AppEvent::PersistModelSelection { model, effort } => { @@ -3606,6 +3781,7 @@ impl App { && policy_is_workspace_write_or_ro && !self.chat_widget.world_writable_warning_hidden(); if should_check { + self.begin_async_queue_resume_barrier(); let cwd = self.config.cwd.clone(); let env_map: std::collections::HashMap = std::env::vars().collect(); @@ -3778,15 +3954,16 @@ impl App { AppEvent::OpenApprovalsPopup => { self.chat_widget.open_approvals_popup(); } + AppEvent::WorldWritableScanCompleted => { + #[cfg(target_os = "windows")] + self.finish_async_queue_resume_barrier(); + } AppEvent::OpenAgentPicker => { self.open_agent_picker().await; } AppEvent::SelectAgentThread(thread_id) => { self.select_agent_thread(tui, thread_id).await?; } - AppEvent::OpenSkillsList => { - self.chat_widget.open_skills_list(); - } AppEvent::OpenManageSkillsPopup => { self.chat_widget.open_manage_skills_popup(); } @@ -4412,11 +4589,13 @@ impl App { // Scan failed: warn without examples. tx.send(AppEvent::OpenWorldWritableWarningConfirmation { preset: None, + approvals_reviewer: None, sample_paths: Vec::new(), extra_count: 0usize, failed_scan: true, }); } + tx.send(AppEvent::WorldWritableScanCompleted); }); } } @@ -4427,6 +4606,7 @@ mod tests { use crate::app_backtrack::BacktrackSelection; use crate::app_backtrack::BacktrackState; use crate::app_backtrack::user_count; + use crate::chatwidget::UserMessage; use crate::chatwidget::tests::make_chatwidget_manual_with_sender; use crate::chatwidget::tests::set_chatgpt_auth; use crate::file_search::FileSearchManager; @@ -4927,6 +5107,60 @@ mod tests { } } + #[tokio::test] + async fn thread_switch_restores_and_drains_queued_follow_up() { + let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + let session_configured = Event { + id: "session-configured".to_string(), + msg: EventMsg::SessionConfigured(SessionConfiguredEvent { + session_id: thread_id, + forked_from_id: None, + thread_name: None, + model: "gpt-test".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: ApprovalsReviewer::User, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + cwd: PathBuf::from("/tmp/project"), + reasoning_effort: None, + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: Some(PathBuf::new()), + }), + }; + app.chat_widget + .apply_external_edit("queued follow-up".to_string()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + let input_state = app + .chat_widget + .capture_thread_input_state() + .expect("expected queued follow-up state"); + + let (chat_widget, _app_event_tx, _rx, mut new_op_rx) = + make_chatwidget_manual_with_sender().await; + app.chat_widget = chat_widget; + app.chat_widget.handle_codex_event(session_configured); + while new_op_rx.try_recv().is_ok() {} + + app.restore_input_state_after_thread_switch(Some(input_state)); + + match next_user_turn_op(&mut new_op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "queued follow-up".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected queued follow-up submission, got {other:?}"), + } + } + #[tokio::test] async fn replay_only_thread_keeps_restored_queue_visible() { let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; @@ -6170,10 +6404,12 @@ guardian_approval = true app.chat_widget .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_matches!( - app_event_rx.try_recv(), - Ok(AppEvent::SelectAgentThread(selected_thread_id)) if selected_thread_id == thread_id - ); + match app_event_rx.try_recv() { + Ok(AppEvent::HandleSlashCommandDraft(draft)) => { + assert_eq!(draft, UserMessage::from(format!("/agent {thread_id}"))); + } + other => panic!("expected serialized agent slash draft, got {other:?}"), + } Ok(()) } @@ -6621,6 +6857,7 @@ guardian_approval = true primary_thread_id: None, primary_session_configured: None, pending_primary_events: VecDeque::new(), + pending_async_queue_resume_barriers: 0, pending_app_server_requests: PendingAppServerRequests::default(), } } @@ -6673,6 +6910,7 @@ guardian_approval = true primary_thread_id: None, primary_session_configured: None, pending_primary_events: VecDeque::new(), + pending_async_queue_resume_barriers: 0, pending_app_server_requests: PendingAppServerRequests::default(), }, rx, diff --git a/codex-rs/tui_app_server/src/app_event.rs b/codex-rs/tui_app_server/src/app_event.rs index 0582538bd93..0858421e4fa 100644 --- a/codex-rs/tui_app_server/src/app_event.rs +++ b/codex-rs/tui_app_server/src/app_event.rs @@ -21,6 +21,7 @@ use codex_utils_approval_presets::ApprovalPreset; use crate::bottom_pane::ApprovalRequest; use crate::bottom_pane::StatusLineItem; +use crate::chatwidget::UserMessage; use crate::history_cell::HistoryCell; use codex_core::config::types::ApprovalsReviewer; @@ -99,6 +100,12 @@ pub(crate) enum AppEvent { /// Open the resume picker inside the running TUI session. OpenResumePicker, + /// Resume a saved session by thread id. + ResumeSession(ThreadId), + + /// Resume a saved session using the exact picker-selected rollout target. + ResumeSessionTarget(crate::resume_picker::SessionTarget), + /// Fork the current session into a new thread. ForkCurrentSession, @@ -186,6 +193,14 @@ pub(crate) enum AppEvent { /// Update the current model slug in the running app and widget. UpdateModel(String), + /// Evaluate a serialized built-in slash-command draft. If a task is currently running, the + /// draft is queued and replayed later through the same path as queued composer input. + HandleSlashCommandDraft(UserMessage), + + /// Notify the app that an interactive bottom-pane view finished, so queued replay can resume + /// once the UI is idle again. + BottomPaneViewCompleted, + /// Update the active collaboration mask in the running app and widget. UpdateCollaborationMode(CollaborationModeMask), @@ -257,6 +272,7 @@ pub(crate) enum AppEvent { #[cfg_attr(not(target_os = "windows"), allow(dead_code))] OpenWorldWritableWarningConfirmation { preset: Option, + approvals_reviewer: Option, /// Up to 3 sample world-writable directories to display in the warning. sample_paths: Vec, /// If there are more than `sample_paths`, this carries the remaining count. @@ -269,24 +285,44 @@ pub(crate) enum AppEvent { #[cfg_attr(not(target_os = "windows"), allow(dead_code))] OpenWindowsSandboxEnablePrompt { preset: ApprovalPreset, + approvals_reviewer: ApprovalsReviewer, }, /// Open the Windows sandbox fallback prompt after declining or failing elevation. #[cfg_attr(not(target_os = "windows"), allow(dead_code))] OpenWindowsSandboxFallbackPrompt { preset: ApprovalPreset, + approvals_reviewer: ApprovalsReviewer, }, /// Begin the elevated Windows sandbox setup flow. #[cfg_attr(not(target_os = "windows"), allow(dead_code))] BeginWindowsSandboxElevatedSetup { preset: ApprovalPreset, + approvals_reviewer: ApprovalsReviewer, + }, + + /// Result of the elevated Windows sandbox setup flow. + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + WindowsSandboxElevatedSetupCompleted { + preset: ApprovalPreset, + approvals_reviewer: ApprovalsReviewer, + setup_succeeded: bool, }, /// Begin the non-elevated Windows sandbox setup flow. #[cfg_attr(not(target_os = "windows"), allow(dead_code))] BeginWindowsSandboxLegacySetup { preset: ApprovalPreset, + approvals_reviewer: ApprovalsReviewer, + }, + + /// Result of the non-elevated Windows sandbox setup flow. + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + WindowsSandboxLegacySetupCompleted { + preset: ApprovalPreset, + approvals_reviewer: ApprovalsReviewer, + error: Option, }, /// Begin a non-elevated grant of read access for an additional directory. @@ -302,11 +338,16 @@ pub(crate) enum AppEvent { error: Option, }, + /// Result of the asynchronous Windows world-writable scan. + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + WorldWritableScanCompleted, + /// Enable the Windows sandbox feature and switch to Agent mode. #[cfg_attr(not(target_os = "windows"), allow(dead_code))] EnableWindowsSandboxForAgentMode { preset: ApprovalPreset, mode: WindowsSandboxEnableMode, + approvals_reviewer: ApprovalsReviewer, }, /// Update the Windows sandbox feature mode without changing approval presets. @@ -365,9 +406,6 @@ pub(crate) enum AppEvent { /// Re-open the approval presets popup. OpenApprovalsPopup, - /// Open the skills list popup. - OpenSkillsList, - /// Open the skills enable/disable picker. OpenManageSkillsPopup, diff --git a/codex-rs/tui_app_server/src/app_event_sender.rs b/codex-rs/tui_app_server/src/app_event_sender.rs index ba113656abd..070adf7cfb2 100644 --- a/codex-rs/tui_app_server/src/app_event_sender.rs +++ b/codex-rs/tui_app_server/src/app_event_sender.rs @@ -6,7 +6,6 @@ use codex_protocol::approvals::ElicitationAction; use codex_protocol::mcp::RequestId as McpRequestId; use codex_protocol::protocol::ConversationAudioParams; use codex_protocol::protocol::ReviewDecision; -use codex_protocol::protocol::ReviewRequest; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use tokio::sync::mpsc::UnboundedSender; @@ -51,12 +50,6 @@ impl AppEventSender { )); } - pub(crate) fn review(&self, review_request: ReviewRequest) { - self.send(AppEvent::CodexOp( - AppCommand::review(review_request).into_core(), - )); - } - pub(crate) fn list_skills(&self, cwds: Vec, force_reload: bool) { self.send(AppEvent::CodexOp( AppCommand::list_skills(cwds, force_reload).into_core(), diff --git a/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs b/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs index f796c040d15..e19f9708cda 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs @@ -50,8 +50,8 @@ //! //! The numeric auto-submit path used by the slash popup performs the same pending-paste expansion //! and attachment pruning, and clears pending paste state on success. -//! Slash commands with arguments (like `/plan` and `/review`) reuse the same preparation path so -//! pasted content and text elements are preserved when extracting args. +//! Slash commands with arguments (like `/model`, `/plan`, and `/review`) reuse the same +//! preparation path so pasted content and text elements are preserved when extracting args. //! //! # Remote Image Rows (Up/Down/Delete) //! @@ -572,23 +572,6 @@ impl ChatComposer { self.sync_popups(); } - pub(crate) fn take_mention_bindings(&mut self) -> Vec { - let elements = self.current_mention_elements(); - let mut ordered = Vec::new(); - for (id, mention) in elements { - if let Some(binding) = self.mention_bindings.remove(&id) - && binding.mention == mention - { - ordered.push(MentionBinding { - mention: binding.mention, - path: binding.path, - }); - } - } - self.mention_bindings.clear(); - ordered - } - pub fn set_collaboration_modes_enabled(&mut self, enabled: bool) { self.collaboration_modes_enabled = enabled; } @@ -2546,9 +2529,6 @@ impl ChatComposer { && let Some(cmd) = slash_commands::find_builtin_command(name, self.builtin_command_flags()) { - if self.reject_slash_command_if_unavailable(cmd) { - return Some(InputResult::None); - } self.textarea.set_text_clearing_elements(""); Some(InputResult::Command(cmd)) } else { @@ -2574,13 +2554,6 @@ impl ChatComposer { let cmd = slash_commands::find_builtin_command(name, self.builtin_command_flags())?; - if !cmd.supports_inline_args() { - return None; - } - if self.reject_slash_command_if_unavailable(cmd) { - return Some(InputResult::None); - } - let mut args_elements = Self::slash_command_args_elements(rest, rest_offset, &self.textarea.text_elements()); let trimmed_rest = rest.trim(); @@ -2594,10 +2567,10 @@ impl ChatComposer { /// Expand pending placeholders and extract normalized inline-command args. /// - /// Inline-arg commands are initially dispatched using the raw draft so command rejection does - /// not consume user input. Once a command is accepted, this helper performs the usual - /// submission preparation (paste expansion, element trimming) and rebases element ranges from - /// full-text offsets to command-arg offsets. + /// Inline-arg commands are initially dispatched using the raw draft so command-specific + /// handling can decide whether to consume the input. Once a command is accepted, this helper + /// performs the usual submission preparation (paste expansion, element trimming) and rebases + /// element ranges from full-text offsets to command-arg offsets. pub(crate) fn prepare_inline_args_submission( &mut self, record_history: bool, @@ -2614,20 +2587,6 @@ impl ChatComposer { Some((trimmed_rest.to_string(), args_elements)) } - fn reject_slash_command_if_unavailable(&self, cmd: SlashCommand) -> bool { - if !self.is_task_running || cmd.available_during_task() { - return false; - } - let message = format!( - "'/{}' is disabled while a task is in progress.", - cmd.command() - ); - self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( - history_cell::new_error_event(message), - ))); - true - } - /// Translate full-text element ranges into command-argument ranges. /// /// `rest_offset` is the byte offset where `rest` begins in the full text. @@ -6447,6 +6406,69 @@ mod tests { }); } + #[test] + fn slash_popup_help_first_for_root_ui() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + type_chars_humanlike(&mut composer, &['/']); + + let mut terminal = match Terminal::new(TestBackend::new(60, 8)) { + Ok(t) => t, + Err(e) => panic!("Failed to create terminal: {e}"), + }; + terminal + .draw(|f| composer.render(f.area(), f.buffer_mut())) + .unwrap_or_else(|e| panic!("Failed to draw composer: {e}")); + + if cfg!(target_os = "windows") { + insta::with_settings!({ snapshot_suffix => "windows" }, { + insta::assert_snapshot!("slash_popup_root", terminal.backend()); + }); + } else { + insta::assert_snapshot!("slash_popup_root", terminal.backend()); + } + } + + #[test] + fn slash_popup_help_first_for_root_logic() { + use super::super::command_popup::CommandItem; + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + type_chars_humanlike(&mut composer, &['/']); + + match &composer.active_popup { + ActivePopup::Command(popup) => match popup.selected_item() { + Some(CommandItem::Builtin(cmd)) => { + assert_eq!(cmd.command(), "help") + } + Some(CommandItem::UserPrompt(_)) => { + panic!("unexpected prompt selected for '/'") + } + None => panic!("no selected command for '/'"), + }, + _ => panic!("slash popup not active after typing '/'"), + } + } + #[test] fn slash_popup_model_first_for_mo_ui() { use ratatui::Terminal; @@ -6703,7 +6725,7 @@ mod tests { } #[test] - fn slash_command_disabled_while_task_running_keeps_text() { + fn slash_command_while_task_running_still_dispatches() { use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; @@ -6725,24 +6747,16 @@ mod tests { let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!(InputResult::None, result); + assert_eq!( + InputResult::CommandWithArgs( + SlashCommand::Review, + "these changes".to_string(), + Vec::new(), + ), + result + ); assert_eq!("/review these changes", composer.textarea.text()); - - let mut found_error = false; - while let Ok(event) = rx.try_recv() { - if let AppEvent::InsertHistoryCell(cell) = event { - let message = cell - .display_lines(80) - .into_iter() - .map(|line| line.to_string()) - .collect::>() - .join("\n"); - assert!(message.contains("disabled while a task is in progress")); - found_error = true; - break; - } - } - assert!(found_error, "expected error history cell to be sent"); + assert!(rx.try_recv().is_err(), "no error should be emitted"); } #[test] @@ -7651,7 +7665,7 @@ mod tests { composer.take_recent_submission_mention_bindings(), mention_bindings ); - assert!(composer.take_mention_bindings().is_empty()); + assert!(composer.mention_bindings().is_empty()); } #[test] diff --git a/codex-rs/tui_app_server/src/bottom_pane/command_popup.rs b/codex-rs/tui_app_server/src/bottom_pane/command_popup.rs index 05b15b7935f..1b3ffec0543 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/command_popup.rs @@ -12,12 +12,6 @@ use crate::render::RectExt; use crate::slash_command::SlashCommand; use codex_protocol::custom_prompts::CustomPrompt; use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; -use std::collections::HashSet; - -// Hide alias commands in the default popup list so each unique action appears once. -// `quit` is an alias of `exit`, so we skip `quit` here. -// `approvals` is an alias of `permissions`. -const ALIAS_COMMANDS: &[SlashCommand] = &[SlashCommand::Quit, SlashCommand::Approvals]; /// A selectable item in the popup: either a built-in command or a user prompt. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -29,7 +23,9 @@ pub(crate) enum CommandItem { pub(crate) struct CommandPopup { command_filter: String, - builtins: Vec<(&'static str, SlashCommand)>, + builtins: Vec, + #[cfg(test)] + reserved_builtin_names: std::collections::HashSet, prompts: Vec, state: ScrollState, } @@ -62,18 +58,21 @@ impl From for slash_commands::BuiltinCommandFlags { impl CommandPopup { pub(crate) fn new(mut prompts: Vec, flags: CommandPopupFlags) -> Self { // Keep built-in availability in sync with the composer. - let builtins: Vec<(&'static str, SlashCommand)> = - slash_commands::builtins_for_input(flags.into()) - .into_iter() - .filter(|(name, _)| !name.starts_with("debug")) - .collect(); + let builtin_flags = flags.into(); + let builtins = slash_commands::visible_builtins_for_input(builtin_flags) + .into_iter() + .filter(|cmd| !cmd.command().starts_with("debug")) + .collect(); // Exclude prompts that collide with builtin command names and sort by name. - let exclude: HashSet = builtins.iter().map(|(n, _)| (*n).to_string()).collect(); - prompts.retain(|p| !exclude.contains(&p.name)); + let reserved_builtin_names = + slash_commands::reserved_builtin_names_for_input(builtin_flags); + prompts.retain(|p| !reserved_builtin_names.contains(&p.name)); prompts.sort_by(|a, b| a.name.cmp(&b.name)); Self { command_filter: String::new(), builtins, + #[cfg(test)] + reserved_builtin_names, prompts, state: ScrollState::new(), } @@ -81,12 +80,7 @@ impl CommandPopup { #[cfg(test)] pub(crate) fn set_prompts(&mut self, mut prompts: Vec) { - let exclude: HashSet = self - .builtins - .iter() - .map(|(n, _)| (*n).to_string()) - .collect(); - prompts.retain(|p| !exclude.contains(&p.name)); + prompts.retain(|p| !self.reserved_builtin_names.contains(&p.name)); prompts.sort_by(|a, b| a.name.cmp(&b.name)); self.prompts = prompts; } @@ -143,8 +137,8 @@ impl CommandPopup { let mut out: Vec<(CommandItem, Option>)> = Vec::new(); if filter.is_empty() { // Built-ins first, in presentation order. - for (_, cmd) in self.builtins.iter() { - if ALIAS_COMMANDS.contains(cmd) { + for cmd in self.builtins.iter() { + if !cmd.show_in_command_popup() { continue; } out.push((CommandItem::Builtin(*cmd), None)); @@ -163,6 +157,29 @@ impl CommandPopup { let prompt_prefix_len = PROMPTS_CMD_PREFIX.chars().count() + 1; let indices_for = |offset| Some((offset..offset + filter_chars).collect()); + for cmd in self.builtins.iter() { + if cmd.command() == filter_lower.as_str() { + exact.push((CommandItem::Builtin(*cmd), indices_for(0))); + continue; + } + if cmd.command().starts_with(&filter_lower) { + prefix.push((CommandItem::Builtin(*cmd), indices_for(0))); + continue; + } + // Keep the popup searchable by accepted aliases, but keep rendering the + // canonical command name so the list stays deduplicated and stable. + if cmd.command_aliases().contains(&filter_lower.as_str()) { + exact.push((CommandItem::Builtin(*cmd), None)); + continue; + } + if cmd + .command_aliases() + .iter() + .any(|alias| alias.starts_with(&filter_lower)) + { + prefix.push((CommandItem::Builtin(*cmd), None)); + } + } let mut push_match = |item: CommandItem, display: &str, name: Option<&str>, name_offset: usize| { let display_lower = display.to_lowercase(); @@ -183,10 +200,6 @@ impl CommandPopup { prefix.push((item, indices_for(offset))); } }; - - for (_, cmd) in self.builtins.iter() { - push_match(CommandItem::Builtin(*cmd), cmd.command(), None, 0); - } // Support both search styles: // - Typing "name" should surface "/prompts:name" results. // - Typing "prompts:name" should also work. @@ -341,6 +354,20 @@ mod tests { } } + #[test] + fn help_is_first_suggestion_for_root_popup() { + let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); + popup.on_composer_text_change("/".to_string()); + let matches = popup.filtered_items(); + match matches.first() { + Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "help"), + Some(CommandItem::UserPrompt(_)) => { + panic!("unexpected prompt ranked before '/help' for '/'") + } + None => panic!("expected at least one match for '/'"), + } + } + #[test] fn filtered_commands_keep_presentation_order_for_prefix() { let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); @@ -354,7 +381,7 @@ mod tests { CommandItem::UserPrompt(_) => None, }) .collect(); - assert_eq!(cmds, vec!["model", "mention", "mcp"]); + assert_eq!(cmds, vec!["model", "mention", "mcp", "subagents"]); } #[test] @@ -412,6 +439,31 @@ mod tests { ); } + #[test] + fn prompt_name_collision_with_builtin_alias_is_ignored() { + let popup = CommandPopup::new( + vec![CustomPrompt { + name: "multi-agents".to_string(), + path: "/tmp/multi-agents.md".to_string().into(), + content: "should be ignored".to_string(), + description: None, + argument_hint: None, + }], + CommandPopupFlags::default(), + ); + let items = popup.filtered_items(); + let has_collision_prompt = items.into_iter().any(|it| match it { + CommandItem::UserPrompt(i) => popup + .prompt(i) + .is_some_and(|prompt| prompt.name == "multi-agents"), + CommandItem::Builtin(_) => false, + }); + assert!( + !has_collision_prompt, + "prompt with builtin alias should be ignored" + ); + } + #[test] fn prompt_description_uses_frontmatter_metadata() { let popup = CommandPopup::new( @@ -480,6 +532,32 @@ mod tests { assert!(items.contains(&CommandItem::Builtin(SlashCommand::Quit))); } + #[test] + fn multi_agents_alias_matches_subagents_entry() { + let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); + popup.on_composer_text_change("/multi".to_string()); + assert_eq!( + popup.selected_item(), + Some(CommandItem::Builtin(SlashCommand::MultiAgents)) + ); + + let cmds: Vec<&str> = popup + .filtered_items() + .into_iter() + .filter_map(|item| match item { + CommandItem::Builtin(cmd) => Some(cmd.command()), + CommandItem::UserPrompt(_) => None, + }) + .collect(); + assert_eq!(cmds, vec!["subagents"]); + + popup.on_composer_text_change("/multi-agents".to_string()); + assert_eq!( + popup.selected_item(), + Some(CommandItem::Builtin(SlashCommand::MultiAgents)) + ); + } + #[test] fn collab_command_hidden_when_collaboration_modes_disabled() { let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); diff --git a/codex-rs/tui_app_server/src/bottom_pane/experimental_features_view.rs b/codex-rs/tui_app_server/src/bottom_pane/experimental_features_view.rs index 8a81f1f98d9..a462bae31db 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/experimental_features_view.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/experimental_features_view.rs @@ -17,10 +17,10 @@ use crate::render::Insets; use crate::render::RectExt as _; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; +use crate::slash_command::SlashCommand; +use crate::slash_command_invocation::SlashCommandInvocation; use crate::style::user_message_style; -use codex_core::features::Feature; - use super::CancellationEvent; use super::bottom_pane_view::BottomPaneView; use super::popup_consts::MAX_POPUP_ROWS; @@ -30,7 +30,7 @@ use super::selection_popup_common::measure_rows_height; use super::selection_popup_common::render_rows; pub(crate) struct ExperimentalFeatureItem { - pub feature: Feature, + pub key: String, pub name: String, pub description: String, pub enabled: bool, @@ -198,15 +198,16 @@ impl BottomPaneView for ExperimentalFeaturesView { } fn on_ctrl_c(&mut self) -> CancellationEvent { - // Save the updates if !self.features.is_empty() { - let updates = self - .features - .iter() - .map(|item| (item.feature, item.enabled)) - .collect(); - self.app_event_tx - .send(AppEvent::UpdateFeatureFlags { updates }); + let invocation = SlashCommandInvocation::with_args( + SlashCommand::Experimental, + self.features.iter().map(|item| { + format!("{}={}", item.key, if item.enabled { "on" } else { "off" }) + }), + ); + self.app_event_tx.send(AppEvent::HandleSlashCommandDraft( + invocation.into_user_message(), + )); } self.complete = true; diff --git a/codex-rs/tui_app_server/src/bottom_pane/feedback_view.rs b/codex-rs/tui_app_server/src/bottom_pane/feedback_view.rs index 98667f8f189..55bb3e55a99 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/feedback_view.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/feedback_view.rs @@ -21,6 +21,8 @@ use crate::app_event::FeedbackCategory; use crate::app_event_sender::AppEventSender; use crate::history_cell; use crate::render::renderable::Renderable; +use crate::slash_command::SlashCommand; +use crate::slash_command_invocation::SlashCommandInvocation; use codex_protocol::protocol::SessionSource; use super::CancellationEvent; @@ -485,8 +487,17 @@ fn make_feedback_item( description: &str, category: FeedbackCategory, ) -> super::SelectionItem { + let token = match category { + FeedbackCategory::Bug => "bug", + FeedbackCategory::BadResult => "bad-result", + FeedbackCategory::GoodResult => "good-result", + FeedbackCategory::SafetyCheck => "safety-check", + FeedbackCategory::Other => "other", + }; let action: super::SelectionAction = Box::new(move |_sender: &AppEventSender| { - app_event_tx.send(AppEvent::OpenFeedbackConsent { category }); + app_event_tx.send(AppEvent::HandleSlashCommandDraft( + SlashCommandInvocation::with_args(SlashCommand::Feedback, [token]).into_user_message(), + )); }); super::SelectionItem { name: name.to_string(), diff --git a/codex-rs/tui_app_server/src/bottom_pane/help_view.rs b/codex-rs/tui_app_server/src/bottom_pane/help_view.rs new file mode 100644 index 00000000000..79c11b1daed --- /dev/null +++ b/codex-rs/tui_app_server/src/bottom_pane/help_view.rs @@ -0,0 +1,495 @@ +use std::cell::Cell; + +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Constraint; +use ratatui::layout::Layout; +use ratatui::layout::Rect; +use ratatui::style::Style; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; +use unicode_width::UnicodeWidthStr; + +use crate::bottom_pane::BuiltinCommandFlags; +use crate::bottom_pane::CancellationEvent; +use crate::bottom_pane::bottom_pane_view::BottomPaneView; +use crate::bottom_pane::popup_consts::MAX_POPUP_ROWS; +use crate::bottom_pane::selection_popup_common::render_menu_surface; +use crate::bottom_pane::visible_builtins_for_input; +use crate::key_hint; +use crate::wrapping::RtOptions; +use crate::wrapping::word_wrap_lines; + +const HELP_VIEW_MIN_BODY_ROWS: u16 = 6; + +#[derive(Clone, Copy)] +enum HelpRowWrap { + None, + Note, + Description, + Usage, +} + +#[derive(Clone)] +struct HelpRow { + plain_text: String, + line: Line<'static>, + wrap: HelpRowWrap, +} + +#[derive(Default)] +struct HelpSearch { + active_query: String, + input: Option, + selected_match: usize, +} + +pub(crate) struct SlashHelpView { + complete: bool, + rows: Vec, + scroll_top: Cell, + follow_selected_match: Cell, + search: HelpSearch, +} + +impl SlashHelpView { + pub(crate) fn new(flags: BuiltinCommandFlags) -> Self { + Self { + complete: false, + rows: Self::build_document(flags), + scroll_top: Cell::new(0), + follow_selected_match: Cell::new(false), + search: HelpSearch::default(), + } + } + + fn visible_body_rows(area_height: u16) -> usize { + area_height + .saturating_sub(3) + .max(HELP_VIEW_MIN_BODY_ROWS) + .into() + } + + fn build_document(flags: BuiltinCommandFlags) -> Vec { + let mut rows = vec![ + HelpRow { + plain_text: "Slash Commands".to_string(), + line: Line::from("Slash Commands".bold()), + wrap: HelpRowWrap::None, + }, + HelpRow { + plain_text: String::new(), + line: Line::from(""), + wrap: HelpRowWrap::None, + }, + HelpRow { + plain_text: "Type / to open the command popup. For commands with both a picker and an arg form, bare /command opens the picker and /command ... runs directly.".to_string(), + line: Line::from( + "Type / to open the command popup. For commands with both a picker and an arg form, bare /command opens the picker and /command ... runs directly." + .dim(), + ), + wrap: HelpRowWrap::Note, + }, + HelpRow { + plain_text: "Args use shell-style quoting; quote values with spaces.".to_string(), + line: Line::from("Args use shell-style quoting; quote values with spaces.".dim()), + wrap: HelpRowWrap::Note, + }, + HelpRow { + plain_text: String::new(), + line: Line::from(""), + wrap: HelpRowWrap::None, + }, + ]; + + for cmd in visible_builtins_for_input(flags) { + rows.push(HelpRow { + plain_text: format!("/{}", cmd.command()), + line: Line::from(format!("/{}", cmd.command()).cyan().bold()), + wrap: HelpRowWrap::None, + }); + rows.push(HelpRow { + plain_text: format!(" {}", cmd.description()), + line: Line::from(format!(" {}", cmd.description()).dim()), + wrap: HelpRowWrap::Description, + }); + rows.push(HelpRow { + plain_text: " Usage:".to_string(), + line: Line::from(" Usage:".dim()), + wrap: HelpRowWrap::None, + }); + for form in cmd.help_forms() { + let plain_text = if form.is_empty() { + format!("/{}", cmd.command()) + } else { + format!("/{} {}", cmd.command(), form) + }; + rows.push(HelpRow { + plain_text: plain_text.clone(), + line: Line::from(plain_text.cyan()), + wrap: HelpRowWrap::Usage, + }); + } + rows.push(HelpRow { + plain_text: String::new(), + line: Line::from(""), + wrap: HelpRowWrap::None, + }); + } + + while rows.last().is_some_and(|row| row.plain_text.is_empty()) { + rows.pop(); + } + + rows + } + + fn scroll_by(&mut self, delta: isize) { + self.scroll_top + .set(self.scroll_top.get().saturating_add_signed(delta)); + self.follow_selected_match.set(false); + } + + fn matching_logical_rows(rows: &[HelpRow], query: &str) -> Vec { + let query = query.to_ascii_lowercase(); + rows.iter() + .enumerate() + .filter_map(|(idx, row)| { + row.plain_text + .to_ascii_lowercase() + .contains(query.as_str()) + .then_some(idx) + }) + .collect() + } + + fn current_query(&self) -> Option<&str> { + if let Some(input) = self.search.input.as_deref() { + return (!input.is_empty()).then_some(input); + } + (!self.search.active_query.is_empty()).then_some(self.search.active_query.as_str()) + } + + fn search_indicator( + &self, + rows: &[HelpRow], + total_rows: usize, + visible_rows: usize, + scroll_top: usize, + ) -> String { + let start_row = if total_rows == 0 { 0 } else { scroll_top + 1 }; + let end_row = (scroll_top + visible_rows).min(total_rows); + let viewport = format!("{start_row}-{end_row}/{total_rows}"); + let Some(query) = self.current_query() else { + return viewport; + }; + let match_count = Self::matching_logical_rows(rows, query).len(); + if self.search.input.is_some() { + return format!( + "{} match{} | {viewport}", + match_count, + if match_count == 1 { "" } else { "es" } + ); + } + if match_count == 0 { + return format!("0/0 | {viewport}"); + } + let current_match = self.search.selected_match.min(match_count - 1) + 1; + format!("{current_match}/{match_count} | {viewport}") + } + + fn footer_line(&self) -> Line<'static> { + if let Some(input) = self.search.input.as_deref() { + return Line::from(vec![ + "Search: ".dim(), + format!("/{input}").cyan(), + " | ".dim(), + key_hint::plain(KeyCode::Enter).into(), + " apply | ".dim(), + key_hint::plain(KeyCode::Esc).into(), + " cancel".dim(), + ]); + } + + let mut spans = vec![ + key_hint::plain(KeyCode::Up).into(), + "/".into(), + key_hint::plain(KeyCode::Down).into(), + " scroll | [".dim(), + key_hint::ctrl(KeyCode::Char('p')).into(), + " / ".dim(), + key_hint::ctrl(KeyCode::Char('n')).into(), + "] page | ".dim(), + "/ search".dim(), + ]; + if !self.search.active_query.is_empty() { + spans.push(" | ".dim()); + spans.push("n/p match".dim()); + } + spans.extend([ + " | ".dim(), + key_hint::plain(KeyCode::Esc).into(), + " close".dim(), + ]); + Line::from(spans) + } + + fn wrap_rows(rows: &[HelpRow], width: u16) -> (Vec>, Vec, Vec) { + let width = width.max(24); + let note_opts = RtOptions::new(width as usize) + .initial_indent(Line::from("")) + .subsequent_indent(Line::from("")); + let description_opts = RtOptions::new(width as usize) + .initial_indent(Line::from("")) + .subsequent_indent(Line::from(" ")); + let usage_opts = RtOptions::new(width as usize) + .initial_indent(Line::from(" ")) + .subsequent_indent(Line::from(" ")); + + let mut wrapped_rows = Vec::new(); + let mut row_starts = Vec::with_capacity(rows.len()); + let mut row_ends = Vec::with_capacity(rows.len()); + + for row in rows { + row_starts.push(wrapped_rows.len()); + let wrapped = match row.wrap { + HelpRowWrap::None => vec![row.line.clone()], + HelpRowWrap::Note => word_wrap_lines([row.line.clone()], note_opts.clone()), + HelpRowWrap::Description => { + word_wrap_lines([row.line.clone()], description_opts.clone()) + } + HelpRowWrap::Usage => word_wrap_lines([row.line.clone()], usage_opts.clone()), + }; + wrapped_rows.extend(wrapped); + row_ends.push(wrapped_rows.len()); + } + + (wrapped_rows, row_starts, row_ends) + } + + fn move_to_match(&mut self, delta: isize) { + if self.search.input.is_some() || self.search.active_query.is_empty() { + return; + } + + let matches = Self::matching_logical_rows(&self.rows, &self.search.active_query); + if matches.is_empty() { + return; + } + + let next = (self.search.selected_match as isize + delta).rem_euclid(matches.len() as isize); + self.search.selected_match = next as usize; + self.follow_selected_match.set(true); + } +} + +impl BottomPaneView for SlashHelpView { + fn handle_key_event(&mut self, key_event: KeyEvent) { + if let Some(input) = self.search.input.as_mut() { + match key_event { + KeyEvent { + code: KeyCode::Esc, .. + } => { + self.search.input = None; + } + KeyEvent { + code: KeyCode::Enter, + .. + } => { + self.search.active_query = self.search.input.take().unwrap_or_default(); + self.search.selected_match = 0; + self.follow_selected_match + .set(!self.search.active_query.is_empty()); + } + KeyEvent { + code: KeyCode::Backspace, + .. + } => { + input.pop(); + } + KeyEvent { + code: KeyCode::Char(c), + modifiers, + .. + } if !modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => { + input.push(c); + } + _ => {} + } + return; + } + + match key_event { + KeyEvent { + code: KeyCode::Char('/'), + modifiers: KeyModifiers::NONE, + .. + } => { + self.search.active_query.clear(); + self.search.selected_match = 0; + self.follow_selected_match.set(false); + self.search.input = Some(String::new()); + } + KeyEvent { + code: KeyCode::Up, .. + } + | KeyEvent { + code: KeyCode::Char('k'), + modifiers: KeyModifiers::NONE, + .. + } => self.scroll_by(-1), + KeyEvent { + code: KeyCode::Down, + .. + } + | KeyEvent { + code: KeyCode::Char('j'), + modifiers: KeyModifiers::NONE, + .. + } => self.scroll_by(1), + KeyEvent { + code: KeyCode::PageUp, + .. + } + | KeyEvent { + code: KeyCode::Char('p'), + modifiers: KeyModifiers::CONTROL, + .. + } => self.scroll_by(-(MAX_POPUP_ROWS as isize)), + KeyEvent { + code: KeyCode::PageDown, + .. + } + | KeyEvent { + code: KeyCode::Char('n'), + modifiers: KeyModifiers::CONTROL, + .. + } => self.scroll_by(MAX_POPUP_ROWS as isize), + KeyEvent { + code: KeyCode::Esc, .. + } if !self.search.active_query.is_empty() => { + self.search.active_query.clear(); + self.search.selected_match = 0; + self.follow_selected_match.set(false); + } + KeyEvent { + code: KeyCode::Char('q'), + modifiers: KeyModifiers::NONE, + .. + } => { + self.on_ctrl_c(); + } + KeyEvent { + code: KeyCode::Char('n'), + modifiers: KeyModifiers::NONE, + .. + } => self.move_to_match(1), + KeyEvent { + code: KeyCode::Char('p'), + modifiers: KeyModifiers::NONE, + .. + } + | KeyEvent { + code: KeyCode::Char('N'), + modifiers: KeyModifiers::SHIFT, + .. + } => self.move_to_match(-1), + _ => {} + } + } + + fn is_complete(&self) -> bool { + self.complete + } + + fn on_ctrl_c(&mut self) -> CancellationEvent { + self.complete = true; + CancellationEvent::Handled + } + + fn prefer_esc_to_handle_key_event(&self) -> bool { + self.search.input.is_some() || !self.search.active_query.is_empty() + } +} + +impl crate::render::renderable::Renderable for SlashHelpView { + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.is_empty() { + return; + } + + let content_area = render_menu_surface(area, buf); + let [header_area, body_area, footer_area] = Layout::vertical([ + Constraint::Length(2), + Constraint::Fill(1), + Constraint::Length(2), + ]) + .areas(content_area); + let [_footer_gap_area, footer_line_area] = + Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(footer_area); + + let (lines, row_starts, row_ends) = Self::wrap_rows(&self.rows, body_area.width); + let header_lines = lines.iter().take(2).cloned().collect::>(); + let mut body_lines = lines.iter().skip(2).cloned().collect::>(); + let visible_rows = Self::visible_body_rows(body_area.height); + let max_scroll = body_lines.len().saturating_sub(visible_rows); + let mut scroll_top = self.scroll_top.get().min(max_scroll); + + if self.search.input.is_none() + && !self.search.active_query.is_empty() + && let Some(selected_row_idx) = + Self::matching_logical_rows(&self.rows, &self.search.active_query) + .get(self.search.selected_match) + .copied() + { + let start = row_starts[selected_row_idx].saturating_sub(2); + let end = row_ends[selected_row_idx].saturating_sub(2); + if self.follow_selected_match.get() || start < scroll_top { + scroll_top = start; + } else if end > scroll_top + visible_rows { + scroll_top = end.saturating_sub(visible_rows); + } + scroll_top = scroll_top.min(max_scroll); + self.scroll_top.set(scroll_top); + self.follow_selected_match.set(false); + for line in body_lines.iter_mut().take(end).skip(start) { + *line = line.clone().patch_style(Style::new().reversed()); + } + } + + self.scroll_top.set(scroll_top); + + Paragraph::new(header_lines).render(header_area, buf); + Paragraph::new(body_lines.clone()) + .scroll((scroll_top as u16, 0)) + .render(body_area, buf); + + let footer_line = self.footer_line(); + Paragraph::new(footer_line.clone()).render(footer_line_area, buf); + let indicator = + self.search_indicator(&self.rows, body_lines.len(), visible_rows, scroll_top); + let indicator_width = UnicodeWidthStr::width(indicator.as_str()) as u16; + let footer_width = footer_line.width() as u16; + if footer_width + indicator_width + 2 <= footer_line_area.width { + Paragraph::new(indicator.dim()).render( + Rect::new( + footer_line_area.x + footer_line_area.width - indicator_width, + footer_line_area.y, + indicator_width, + footer_line_area.height, + ), + buf, + ); + } + } + + fn desired_height(&self, width: u16) -> u16 { + let (wrapped_rows, _, _) = Self::wrap_rows(&self.rows, width.saturating_sub(4)); + let content_rows = wrapped_rows.len() as u16; + content_rows.max(HELP_VIEW_MIN_BODY_ROWS + 4) + } +} diff --git a/codex-rs/tui_app_server/src/bottom_pane/mod.rs b/codex-rs/tui_app_server/src/bottom_pane/mod.rs index d15a0f861c3..a1daac99f9f 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/mod.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/mod.rs @@ -15,6 +15,7 @@ //! hint. The pane schedules redraws so those hints can expire even when the UI is otherwise idle. use std::path::PathBuf; +use crate::app_event::AppEvent; use crate::app_event::ConnectorsSnapshot; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::pending_input_preview::PendingInputPreview; @@ -31,6 +32,7 @@ use codex_core::features::Features; use codex_core::plugins::PluginCapabilitySummary; use codex_core::skills::model::SkillMetadata; use codex_file_search::FileMatch; +use codex_protocol::protocol::Op; use codex_protocol::request_user_input::RequestUserInputEvent; use codex_protocol::user_input::TextElement; use crossterm::event::KeyCode; @@ -79,6 +81,7 @@ pub mod custom_prompt_view; mod experimental_features_view; mod file_search_popup; mod footer; +mod help_view; mod list_selection_view; mod prompt_args; mod skill_popup; @@ -95,6 +98,7 @@ pub(crate) use feedback_view::FeedbackAudience; pub(crate) use feedback_view::feedback_disabled_params; pub(crate) use feedback_view::feedback_selection_params; pub(crate) use feedback_view::feedback_upload_consent_params; +pub(crate) use help_view::SlashHelpView; pub(crate) use skills_toggle_view::SkillsToggleItem; pub(crate) use skills_toggle_view::SkillsToggleView; pub(crate) use status_line_setup::StatusLineItem; @@ -137,17 +141,19 @@ pub(crate) enum CancellationEvent { NotHandled, } -use crate::bottom_pane::prompt_args::parse_slash_name; +use crate::status_indicator_widget::StatusDetailsCapitalization; +use crate::status_indicator_widget::StatusIndicatorWidget; pub(crate) use chat_composer::ChatComposer; pub(crate) use chat_composer::ChatComposerConfig; pub(crate) use chat_composer::InputResult; - -use crate::status_indicator_widget::StatusDetailsCapitalization; -use crate::status_indicator_widget::StatusIndicatorWidget; pub(crate) use experimental_features_view::ExperimentalFeatureItem; pub(crate) use experimental_features_view::ExperimentalFeaturesView; pub(crate) use list_selection_view::SelectionAction; pub(crate) use list_selection_view::SelectionItem; +pub(crate) use prompt_args::parse_slash_name; +pub(crate) use slash_commands::BuiltinCommandFlags; +pub(crate) use slash_commands::find_builtin_command; +pub(crate) use slash_commands::visible_builtins_for_input; /// Pane displayed in the lower half of the chat UI. /// @@ -262,22 +268,10 @@ impl BottomPane { self.request_redraw(); } - pub fn take_mention_bindings(&mut self) -> Vec { - self.composer.take_mention_bindings() - } - pub fn take_recent_submission_mention_bindings(&mut self) -> Vec { self.composer.take_recent_submission_mention_bindings() } - /// Clear pending attachments and mention bindings e.g. when a slash command doesn't submit text. - pub(crate) fn drain_pending_submission_state(&mut self) { - let _ = self.take_recent_submission_images_with_placeholders(); - let _ = self.take_remote_image_urls(); - let _ = self.take_recent_submission_mention_bindings(); - let _ = self.take_mention_bindings(); - } - pub fn set_collaboration_modes_enabled(&mut self, enabled: bool) { self.composer.set_collaboration_modes_enabled(enabled); self.request_redraw(); @@ -420,25 +414,15 @@ impl BottomPane { self.request_redraw(); InputResult::None } else { - let is_agent_command = self - .composer_text() - .lines() - .next() - .and_then(parse_slash_name) - .is_some_and(|(name, _, _)| name == "agent"); - - // If a task is running and a status line is visible, allow Esc to - // send an interrupt even while the composer has focus. - // When a popup is active, prefer dismissing it over interrupting the task. + // If a task is running, allow Esc to send an interrupt when no popup is active. + // Final-message streaming can temporarily hide the status widget, but that should not + // disable interrupt. if key_event.code == KeyCode::Esc && matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat) && self.is_task_running - && !is_agent_command && !self.composer.popup_active() - && let Some(status) = &self.status { - // Send Op::Interrupt - status.interrupt(); + self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt)); self.request_redraw(); return InputResult::None; } @@ -722,7 +706,6 @@ impl BottomPane { if !was_running { if self.status.is_none() { self.status = Some(StatusIndicatorWidget::new( - self.app_event_tx.clone(), self.frame_requester.clone(), self.animations_enabled, )); @@ -749,7 +732,6 @@ impl BottomPane { pub(crate) fn ensure_status_indicator(&mut self) { if self.status.is_none() { self.status = Some(StatusIndicatorWidget::new( - self.app_event_tx.clone(), self.frame_requester.clone(), self.animations_enabled, )); @@ -1022,6 +1004,7 @@ impl BottomPane { fn on_active_view_complete(&mut self) { self.resume_status_timer_after_modal(); self.set_composer_input_enabled(/*enabled*/ true, /*placeholder*/ None); + self.app_event_tx.send(AppEvent::BottomPaneViewCompleted); } fn pause_status_timer_for_modal(&mut self) { @@ -1634,29 +1617,6 @@ mod tests { assert!(snapshot.contains("[Image #2]")); } - #[test] - fn drain_pending_submission_state_clears_remote_image_urls() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let mut pane = BottomPane::new(BottomPaneParams { - app_event_tx: tx, - frame_requester: FrameRequester::test_dummy(), - has_input_focus: true, - enhanced_keys_supported: false, - placeholder_text: "Ask Codex to do anything".to_string(), - disable_paste_burst: false, - animations_enabled: true, - skills: Some(Vec::new()), - }); - - pane.set_remote_image_urls(vec!["https://example.com/one.png".to_string()]); - assert_eq!(pane.remote_image_urls().len(), 1); - - pane.drain_pending_submission_state(); - - assert!(pane.remote_image_urls().is_empty()); - } - #[test] fn esc_with_skill_popup_does_not_interrupt_task() { let (tx_raw, mut rx) = unbounded_channel::(); @@ -1742,7 +1702,7 @@ mod tests { } #[test] - fn esc_with_agent_command_without_popup_does_not_interrupt_task() { + fn esc_with_agent_command_without_popup_interrupts_task() { let (tx_raw, mut rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let mut pane = BottomPane::new(BottomPaneParams { @@ -1758,8 +1718,8 @@ mod tests { pane.set_task_running(true); - // Repro: `/agent ` hides the popup (cursor past command name). Esc should - // keep editing command text instead of interrupting the running task. + // `/agent ` hides the popup once the cursor moves past the command name. + // Without an active popup, Esc should interrupt even though the composer has text. pane.insert_str("/agent "); assert!( !pane.composer.popup_active(), @@ -1768,12 +1728,10 @@ mod tests { pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - while let Ok(ev) = rx.try_recv() { - assert!( - !matches!(ev, AppEvent::CodexOp(Op::Interrupt)), - "expected Esc to not send Op::Interrupt while typing `/agent`" - ); - } + assert!( + matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt))), + "expected Esc to send Op::Interrupt while typing `/agent` with no popup" + ); assert_eq!(pane.composer_text(), "/agent "); } @@ -1850,6 +1808,59 @@ mod tests { ); } + #[test] + fn esc_with_nonempty_composer_interrupts_task_when_no_popup() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + pane.set_task_running(true); + pane.insert_str("still editing"); + + pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert!( + matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt))), + "expected Esc to send Op::Interrupt while composer has text and no popup is active" + ); + assert_eq!(pane.composer_text(), "still editing"); + } + + #[test] + fn esc_interrupts_running_task_when_status_indicator_hidden() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + pane.set_task_running(true); + pane.hide_status_indicator(); + + pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert!( + matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt))), + "expected Esc to send Op::Interrupt even when the status indicator is hidden" + ); + } + #[test] fn esc_routes_to_handle_key_event_when_requested() { #[derive(Default)] diff --git a/codex-rs/tui_app_server/src/bottom_pane/slash_commands.rs b/codex-rs/tui_app_server/src/bottom_pane/slash_commands.rs index 15b70f232c2..4835e54cb56 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/slash_commands.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/slash_commands.rs @@ -3,12 +3,13 @@ //! The same sandbox- and feature-gating rules are used by both the composer //! and the command popup. Centralizing them here keeps those call sites small //! and ensures they stay in sync. -use std::str::FromStr; +use std::collections::HashSet; use codex_utils_fuzzy_match::fuzzy_match; use crate::slash_command::SlashCommand; use crate::slash_command::built_in_slash_commands; +use crate::slash_command::visible_built_in_slash_commands; #[derive(Clone, Copy, Debug, Default)] pub(crate) struct BuiltinCommandFlags { @@ -21,30 +22,64 @@ pub(crate) struct BuiltinCommandFlags { pub(crate) allow_elevate_sandbox: bool, } +fn command_enabled_for_input(cmd: SlashCommand, flags: BuiltinCommandFlags) -> bool { + if !flags.allow_elevate_sandbox && cmd == SlashCommand::ElevateSandbox { + return false; + } + if !flags.collaboration_modes_enabled + && matches!(cmd, SlashCommand::Collab | SlashCommand::Plan) + { + return false; + } + if !flags.connectors_enabled && cmd == SlashCommand::Apps { + return false; + } + if !flags.fast_command_enabled && cmd == SlashCommand::Fast { + return false; + } + if !flags.personality_command_enabled && cmd == SlashCommand::Personality { + return false; + } + if !flags.realtime_conversation_enabled && cmd == SlashCommand::Realtime { + return false; + } + if !flags.audio_device_selection_enabled && cmd == SlashCommand::Settings { + return false; + } + true +} + /// Return the built-ins that should be visible/usable for the current input. pub(crate) fn builtins_for_input(flags: BuiltinCommandFlags) -> Vec<(&'static str, SlashCommand)> { built_in_slash_commands() .into_iter() - .filter(|(_, cmd)| flags.allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox) - .filter(|(_, cmd)| { - flags.collaboration_modes_enabled - || !matches!(*cmd, SlashCommand::Collab | SlashCommand::Plan) - }) - .filter(|(_, cmd)| flags.connectors_enabled || *cmd != SlashCommand::Apps) - .filter(|(_, cmd)| flags.fast_command_enabled || *cmd != SlashCommand::Fast) - .filter(|(_, cmd)| flags.personality_command_enabled || *cmd != SlashCommand::Personality) - .filter(|(_, cmd)| flags.realtime_conversation_enabled || *cmd != SlashCommand::Realtime) - .filter(|(_, cmd)| flags.audio_device_selection_enabled || *cmd != SlashCommand::Settings) + .filter(|(_, cmd)| command_enabled_for_input(*cmd, flags)) + .collect() +} + +/// Return the visible built-ins once each, in popup presentation order. +pub(crate) fn visible_builtins_for_input(flags: BuiltinCommandFlags) -> Vec { + visible_built_in_slash_commands() + .into_iter() + .filter(|cmd| command_enabled_for_input(*cmd, flags)) .collect() } /// Find a single built-in command by exact name, after applying the gating rules. pub(crate) fn find_builtin_command(name: &str, flags: BuiltinCommandFlags) -> Option { - let cmd = SlashCommand::from_str(name).ok()?; builtins_for_input(flags) .into_iter() - .any(|(_, visible_cmd)| visible_cmd == cmd) - .then_some(cmd) + .find(|(command_name, _)| *command_name == name) + .map(|(_, cmd)| cmd) +} + +/// Return every builtin name that should be reserved against custom prompt collisions. +pub(crate) fn reserved_builtin_names_for_input(flags: BuiltinCommandFlags) -> HashSet { + visible_builtins_for_input(flags) + .into_iter() + .flat_map(SlashCommand::all_command_names) + .map(str::to_string) + .collect() } /// Whether any visible built-in fuzzily matches the provided prefix. @@ -101,6 +136,29 @@ mod tests { ); } + #[test] + fn multi_agents_alias_still_resolves_for_dispatch() { + assert_eq!( + find_builtin_command("multi-agents", all_enabled_flags()), + Some(SlashCommand::MultiAgents) + ); + assert_eq!( + find_builtin_command("subagents", all_enabled_flags()), + Some(SlashCommand::MultiAgents) + ); + assert_eq!(SlashCommand::MultiAgents.command(), "subagents"); + } + + #[test] + fn visible_builtins_keep_multi_agents_deduplicated() { + let builtins = visible_builtins_for_input(all_enabled_flags()); + let multi_agents: Vec<_> = builtins + .into_iter() + .filter(|cmd| *cmd == SlashCommand::MultiAgents) + .collect(); + assert_eq!(multi_agents, vec![SlashCommand::MultiAgents]); + } + #[test] fn fast_command_is_hidden_when_disabled() { let mut flags = all_enabled_flags(); diff --git a/codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui_app_server__bottom_pane__chat_composer__tests__slash_popup_root.snap b/codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui_app_server__bottom_pane__chat_composer__tests__slash_popup_root.snap new file mode 100644 index 00000000000..3a57abaabeb --- /dev/null +++ b/codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui_app_server__bottom_pane__chat_composer__tests__slash_popup_root.snap @@ -0,0 +1,12 @@ +--- +source: tui_app_server/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› / " +" " +" /help show slash command help " +" /model choose what model and reasoning effort to " +" use " +" /permissions choose what Codex is allowed to do " +" /experimental toggle experimental features " diff --git a/codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui_app_server__bottom_pane__chat_composer__tests__slash_popup_root@windows.snap b/codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui_app_server__bottom_pane__chat_composer__tests__slash_popup_root@windows.snap new file mode 100644 index 00000000000..2eded5bf4d6 --- /dev/null +++ b/codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui_app_server__bottom_pane__chat_composer__tests__slash_popup_root@windows.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› / " +" " +" /help show slash command help " +" /model choose what model and reasoning " +" effort to use " +" /permissions choose what Codex is allowed to do " +" /sandbox-add-read-dir let sandbox read a directory: / " diff --git a/codex-rs/tui_app_server/src/bottom_pane/status_line_setup.rs b/codex-rs/tui_app_server/src/bottom_pane/status_line_setup.rs index 58c7ff7f13e..e3b28a83870 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/status_line_setup.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/status_line_setup.rs @@ -34,6 +34,8 @@ use crate::bottom_pane::bottom_pane_view::BottomPaneView; use crate::bottom_pane::multi_select_picker::MultiSelectItem; use crate::bottom_pane::multi_select_picker::MultiSelectPicker; use crate::render::renderable::Renderable; +use crate::slash_command::SlashCommand; +use crate::slash_command_invocation::SlashCommandInvocation; /// Available items that can be displayed in the status line. /// @@ -231,12 +233,14 @@ impl StatusLineSetupView { .enable_ordering() .on_preview(move |items| preview_data.line_for_items(items)) .on_confirm(|ids, app_event| { - let items = ids - .iter() - .map(|id| id.parse::()) - .collect::, _>>() - .unwrap_or_default(); - app_event.send(AppEvent::StatusLineSetup { items }); + let invocation = if ids.is_empty() { + SlashCommandInvocation::with_args(SlashCommand::Statusline, ["none"]) + } else { + SlashCommandInvocation::with_args(SlashCommand::Statusline, ids) + }; + app_event.send(AppEvent::HandleSlashCommandDraft( + invocation.into_user_message(), + )); }) .on_cancel(|app_event| { app_event.send(AppEvent::StatusLineSetupCancelled); diff --git a/codex-rs/tui_app_server/src/chatwidget.rs b/codex-rs/tui_app_server/src/chatwidget.rs index 0b4fb7c184a..4e775d9f90c 100644 --- a/codex-rs/tui_app_server/src/chatwidget.rs +++ b/codex-rs/tui_app_server/src/chatwidget.rs @@ -29,22 +29,39 @@ use std::collections::BTreeMap; use std::collections::HashMap; use std::collections::HashSet; use std::collections::VecDeque; +use std::ffi::OsString; use std::path::Path; use std::path::PathBuf; +use std::str::FromStr; use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; use std::time::Instant; +use base64::Engine; +#[cfg(unix)] +use std::os::unix::ffi::OsStrExt; +#[cfg(unix)] +use std::os::unix::ffi::OsStringExt; +#[cfg(windows)] +use std::os::windows::ffi::OsStrExt; +#[cfg(windows)] +use std::os::windows::ffi::OsStringExt; + use self::realtime::PendingSteerCompareKey; use crate::app_command::AppCommand; use crate::app_event::RealtimeAudioDeviceKind; +use crate::app_event::WindowsSandboxEnableMode; #[cfg(all(not(target_os = "linux"), feature = "voice-input"))] use crate::audio_device::list_realtime_audio_device_names; +use crate::bottom_pane::BuiltinCommandFlags; use crate::bottom_pane::StatusLineItem; use crate::bottom_pane::StatusLinePreviewData; use crate::bottom_pane::StatusLineSetupView; +use crate::bottom_pane::find_builtin_command; use crate::model_catalog::ModelCatalog; +use crate::slash_command::SlashCommandExecutionKind; +use crate::slash_command_invocation::SlashCommandInvocation; use crate::status::RateLimitWindowDisplay; use crate::status::StatusAccountDisplay; use crate::status::format_directory_display; @@ -147,6 +164,7 @@ use codex_protocol::protocol::WebSearchBeginEvent; use codex_protocol::protocol::WebSearchEndEvent; use codex_protocol::request_permissions::RequestPermissionsEvent; use codex_protocol::request_user_input::RequestUserInputEvent; +use codex_protocol::user_input::ByteRange; use codex_protocol::user_input::TextElement; use codex_protocol::user_input::UserInput; use codex_utils_sleep_inhibitor::SleepInhibitor; @@ -214,8 +232,6 @@ fn queued_message_edit_binding_for_terminal(terminal_name: TerminalName) -> KeyB use crate::app_event::AppEvent; use crate::app_event::ConnectorsSnapshot; use crate::app_event::ExitMode; -#[cfg(target_os = "windows")] -use crate::app_event::WindowsSandboxEnableMode; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::ApprovalRequest; use crate::bottom_pane::BottomPane; @@ -236,6 +252,7 @@ use crate::bottom_pane::SelectionAction; use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionViewParams; use crate::bottom_pane::custom_prompt_view::CustomPromptView; +use crate::bottom_pane::parse_slash_name; use crate::bottom_pane::popup_consts::standard_popup_hint_line; use crate::clipboard_paste::paste_image_to_temp_png; use crate::clipboard_text; @@ -708,6 +725,7 @@ pub(crate) struct ChatWidget { // Set when commentary output completes; once stream queues go idle we restore the status row. pending_status_indicator_restore: bool, suppress_queue_autosend: bool, + resume_queued_inputs_when_idle: bool, thread_id: Option, thread_name: Option, forked_from: Option, @@ -719,7 +737,9 @@ pub(crate) struct ChatWidget { // When resuming an existing session (selected via resume picker), avoid an // immediate redraw on SessionConfigured to prevent a gratuitous UI flicker. suppress_session_configured_redraw: bool, - // User messages queued while a turn is in progress + // User messages queued while a turn is in progress. Some entries are serialized slash-command + // drafts and are replayed through the slash-command evaluator instead of being submitted + // directly as user turns. queued_user_messages: VecDeque, // Steers already submitted to core but not yet committed into history. // @@ -866,6 +886,20 @@ impl ThreadComposerState { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum QueueReplayControl { + Continue, + ResumeWhenIdle, + Stop, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ModelSelectionScope { + Global, + PlanOnly, + AllModes, +} + #[derive(Debug, Clone, PartialEq)] pub(crate) struct ThreadInputState { composer: Option, @@ -1761,7 +1795,7 @@ impl ChatWidget { self.saw_plan_item_this_turn = false; } // If there is a queued user message, send exactly one now to begin the next turn. - self.maybe_send_next_queued_input(); + self.drain_queued_inputs_until_blocked(); // Emit a notification when the turn completes (suppressed if focused). self.notify(Notification::AgentTurnComplete { response: last_agent_message.unwrap_or_default(), @@ -2068,7 +2102,7 @@ impl ChatWidget { self.add_to_history(history_cell::new_warning_event(message)); self.request_redraw(); - self.maybe_send_next_queued_input(); + self.drain_queued_inputs_until_blocked(); } fn on_error(&mut self, message: String) { @@ -2078,7 +2112,7 @@ impl ChatWidget { self.request_redraw(); // After an error ends the turn, try sending the next queued input. - self.maybe_send_next_queued_input(); + self.drain_queued_inputs_until_blocked(); } fn on_warning(&mut self, message: impl Into) { @@ -2150,7 +2184,7 @@ impl ChatWidget { self.mcp_startup_status = None; self.update_task_running_state(); - self.maybe_send_next_queued_input(); + self.drain_queued_inputs_until_blocked(); self.request_redraw(); } @@ -2162,6 +2196,7 @@ impl ChatWidget { self.finalize_turn(); let send_pending_steers_immediately = self.submit_pending_steers_after_interrupt; self.submit_pending_steers_after_interrupt = false; + let mut started_turn_after_interrupt = false; if reason != TurnAbortReason::ReviewEnded { if send_pending_steers_immediately { self.add_to_history(history_cell::new_info_event( @@ -2185,29 +2220,31 @@ impl ChatWidget { .collect(); if !pending_steers.is_empty() { self.submit_user_message(merge_user_messages(pending_steers)); - } else if let Some(combined) = self.drain_pending_messages_for_restore() { + started_turn_after_interrupt = true; + } else if let Some(combined) = self.drain_restorable_messages_for_restore() { self.restore_user_message_to_composer(combined); } - } else if let Some(combined) = self.drain_pending_messages_for_restore() { + } else if let Some(combined) = self.drain_restorable_messages_for_restore() { self.restore_user_message_to_composer(combined); } self.refresh_pending_input_preview(); + if !started_turn_after_interrupt { + self.drain_queued_inputs_until_blocked(); + } self.request_redraw(); } - /// Merge pending steers, queued drafts, and the current composer state into a single message. + /// Merge pending steers, queued user-message drafts, and the current composer state into a + /// single message. /// /// Each pending message numbers attachments from `[Image #1]` relative to its own remote /// images. When we concatenate multiple messages after interrupt, we must renumber local-image /// placeholders in a stable order and rebase text element byte ranges so the restored composer - /// state stays aligned with the merged attachment list. Returns `None` when there is nothing to - /// restore. - fn drain_pending_messages_for_restore(&mut self) -> Option { - if self.pending_steers.is_empty() && self.queued_user_messages.is_empty() { - return None; - } - + /// state stays aligned with the merged attachment list. Slash commands are fully serializable + /// again, so queued slash drafts are restored alongside ordinary queued follow-ups instead of + /// being replayed separately after the interrupt. + fn drain_restorable_messages_for_restore(&mut self) -> Option { let existing_message = UserMessage { text: self.bottom_pane.composer_text(), text_elements: self.bottom_pane.composer_text_elements(), @@ -2216,16 +2253,22 @@ impl ChatWidget { mention_bindings: self.bottom_pane.composer_mention_bindings(), }; + let has_existing_message = !existing_message.text.is_empty() + || !existing_message.local_images.is_empty() + || !existing_message.remote_image_urls.is_empty(); + let has_pending_user_messages = + !self.pending_steers.is_empty() || !self.queued_user_messages.is_empty(); + if !has_pending_user_messages { + return None; + } + let mut to_merge: Vec = self .pending_steers .drain(..) .map(|steer| steer.user_message) .collect(); to_merge.extend(self.queued_user_messages.drain(..)); - if !existing_message.text.is_empty() - || !existing_message.local_images.is_empty() - || !existing_message.remote_image_urls.is_empty() - { + if has_existing_message { to_merge.push(existing_message); } @@ -3614,6 +3657,7 @@ impl ChatWidget { retry_status_header: None, pending_status_indicator_restore: false, suppress_queue_autosend: false, + resume_queued_inputs_when_idle: false, thread_id: None, thread_name: None, forked_from: None, @@ -3806,6 +3850,7 @@ impl ChatWidget { retry_status_header: None, pending_status_indicator_restore: false, suppress_queue_autosend: false, + resume_queued_inputs_when_idle: false, thread_id: None, thread_name: None, forked_from: None, @@ -3944,8 +3989,8 @@ impl ChatWidget { && self.queued_message_edit_binding.is_press(key_event) && !self.queued_user_messages.is_empty() { - if let Some(user_message) = self.queued_user_messages.pop_back() { - self.restore_user_message_to_composer(user_message); + if let Some(queued_message) = self.queued_user_messages.pop_back() { + self.restore_user_message_to_composer(queued_message); self.refresh_pending_input_preview(); self.request_redraw(); } @@ -4005,6 +4050,9 @@ impl ChatWidget { else { return; }; + if self.reject_unavailable_builtin_slash_command(&user_message) { + return; + } let should_submit_now = self.is_session_configured() && !self.is_plan_streaming_in_tui(); if should_submit_now { @@ -4040,6 +4088,9 @@ impl ChatWidget { else { return; }; + if self.reject_unavailable_builtin_slash_command(&user_message) { + return; + } self.queue_user_message(user_message); } InputResult::Command(cmd) => { @@ -4117,42 +4168,64 @@ impl ChatWidget { false } - fn dispatch_command(&mut self, cmd: SlashCommand) { - if !cmd.available_during_task() && self.bottom_pane.is_task_running() { - let message = format!( - "'/{}' is disabled while a task is in progress.", - cmd.command() - ); - self.add_to_history(history_cell::new_error_event(message)); - self.bottom_pane.drain_pending_submission_state(); - self.request_redraw(); - return; + /// Dispatch a built-in slash command for both live input and queued replay. + /// + /// Live callers usually ignore the return value, but queued replay uses it to decide whether + /// draining can continue after this command. `Continue` means the command only changed local + /// state synchronously inside `ChatWidget`. `ResumeWhenIdle` means queued replay should pause + /// until app-side work or popup interaction finishes. `Stop` means it submitted or queued + /// work, changed session/navigation state, or otherwise hit a boundary where queued draining + /// must stop entirely. Commands that require interactive UI are resolved before queueing and + /// should not open that UI during replay. + fn dispatch_command(&mut self, cmd: SlashCommand) -> QueueReplayControl { + if self.bottom_pane.is_task_running() + && !matches!(cmd.execution_kind(), SlashCommandExecutionKind::Immediate) + && !cmd.requires_interaction() + { + self.queue_user_message(SlashCommandInvocation::bare(cmd).into_user_message()); + // This busy-path queueing only happens for live command dispatch. Queued replay + // executes slash drafts only while idle, and handle_serialized_slash_command() queues + // instead of dispatching when a task is already running, so this Stop result is not + // material to replay behavior. + return QueueReplayControl::Stop; } match cmd { + SlashCommand::Help => { + self.bottom_pane + .show_view(Box::new(crate::bottom_pane::SlashHelpView::new( + self.builtin_command_flags(), + ))); + QueueReplayControl::Continue + } SlashCommand::Feedback => { if !self.config.feedback_enabled { let params = crate::bottom_pane::feedback_disabled_params(); self.bottom_pane.show_selection_view(params); self.request_redraw(); - return; + return QueueReplayControl::Stop; } // Step 1: pick a category (UI built in feedback_view) let params = crate::bottom_pane::feedback_selection_params(self.app_event_tx.clone()); self.bottom_pane.show_selection_view(params); self.request_redraw(); + QueueReplayControl::Stop } SlashCommand::New => { self.app_event_tx.send(AppEvent::NewSession); + QueueReplayControl::Stop } SlashCommand::Clear => { self.app_event_tx.send(AppEvent::ClearUi); + QueueReplayControl::Stop } SlashCommand::Resume => { self.app_event_tx.send(AppEvent::OpenResumePicker); + QueueReplayControl::Stop } SlashCommand::Fork => { self.app_event_tx.send(AppEvent::ForkCurrentSession); + QueueReplayControl::Stop } SlashCommand::Init => { let init_target = self.config.cwd.join(DEFAULT_PROJECT_DOC_FILENAME); @@ -4161,25 +4234,30 @@ impl ChatWidget { "{DEFAULT_PROJECT_DOC_FILENAME} already exists here. Skipping /init to avoid overwriting it." ); self.add_info_message(message, /*hint*/ None); - return; + return QueueReplayControl::Stop; } const INIT_PROMPT: &str = include_str!("../prompt_for_init_command.md"); self.submit_user_message(INIT_PROMPT.to_string().into()); + QueueReplayControl::Stop } SlashCommand::Compact => { self.clear_token_usage(); self.app_event_tx.compact(); + QueueReplayControl::Stop } SlashCommand::Review => { self.open_review_popup(); + QueueReplayControl::Stop } SlashCommand::Rename => { self.session_telemetry .counter("codex.thread.rename", /*inc*/ 1, &[]); self.show_rename_prompt(); + QueueReplayControl::Stop } SlashCommand::Model => { self.open_model_popup(); + QueueReplayControl::Stop } SlashCommand::Fast => { let next_tier = if matches!(self.config.service_tier, Some(ServiceTier::Fast)) { @@ -4188,25 +4266,29 @@ impl ChatWidget { Some(ServiceTier::Fast) }; self.set_service_tier_selection(next_tier); + QueueReplayControl::Continue } SlashCommand::Realtime => { if !self.realtime_conversation_enabled() { - return; + return QueueReplayControl::Stop; } if self.realtime_conversation.is_live() { self.request_realtime_conversation_close(/*info_message*/ None); } else { self.start_realtime_conversation(); } + QueueReplayControl::Stop } SlashCommand::Settings => { if !self.realtime_audio_device_selection_enabled() { - return; + return QueueReplayControl::Stop; } self.open_realtime_audio_popup(); + QueueReplayControl::Stop } SlashCommand::Personality => { self.open_personality_popup(); + QueueReplayControl::Stop } SlashCommand::Plan => { if !self.collaboration_modes_enabled() { @@ -4214,15 +4296,17 @@ impl ChatWidget { "Collaboration modes are disabled.".to_string(), Some("Enable collaboration modes to use /plan.".to_string()), ); - return; + return QueueReplayControl::Stop; } if let Some(mask) = collaboration_modes::plan_mask(self.model_catalog.as_ref()) { self.set_collaboration_mask(mask); + QueueReplayControl::Continue } else { self.add_info_message( "Plan mode unavailable right now.".to_string(), /*hint*/ None, ); + QueueReplayControl::Stop } } SlashCommand::Collab => { @@ -4231,18 +4315,22 @@ impl ChatWidget { "Collaboration modes are disabled.".to_string(), Some("Enable collaboration modes to use /collab.".to_string()), ); - return; + return QueueReplayControl::Stop; } self.open_collaboration_modes_popup(); + QueueReplayControl::Stop } SlashCommand::Agent | SlashCommand::MultiAgents => { self.app_event_tx.send(AppEvent::OpenAgentPicker); + QueueReplayControl::Stop } SlashCommand::Approvals => { self.open_permissions_popup(); + QueueReplayControl::Stop } SlashCommand::Permissions => { self.open_permissions_popup(); + QueueReplayControl::Stop } SlashCommand::ElevateSandbox => { #[cfg(target_os = "windows")] @@ -4255,7 +4343,7 @@ impl ChatWidget { { // This command should not be visible/recognized outside degraded mode, // but guard anyway in case something dispatches it directly. - return; + return QueueReplayControl::Stop; } let Some(preset) = builtin_approval_presets() @@ -4267,7 +4355,7 @@ impl ChatWidget { self.add_error_message( "Internal error: missing the 'auto' approval preset.".to_string(), ); - return; + return QueueReplayControl::Stop; }; if let Err(err) = self @@ -4277,7 +4365,7 @@ impl ChatWidget { .can_set(&preset.approval) { self.add_error_message(err.to_string()); - return; + return QueueReplayControl::Stop; } self.session_telemetry.counter( @@ -4286,24 +4374,31 @@ impl ChatWidget { &[], ); self.app_event_tx - .send(AppEvent::BeginWindowsSandboxElevatedSetup { preset }); + .send(AppEvent::BeginWindowsSandboxElevatedSetup { + preset, + approvals_reviewer: self.config.approvals_reviewer, + }); } #[cfg(not(target_os = "windows"))] { let _ = &self.session_telemetry; // Not supported; on non-Windows this command should never be reachable. }; + QueueReplayControl::Stop } SlashCommand::SandboxReadRoot => { self.add_error_message( "Usage: /sandbox-add-read-dir ".to_string(), ); + QueueReplayControl::Stop } SlashCommand::Experimental => { self.open_experimental_popup(); + QueueReplayControl::Stop } SlashCommand::Quit | SlashCommand::Exit => { self.request_quit_without_confirmation(); + QueueReplayControl::Stop } SlashCommand::Logout => { if let Err(e) = codex_core::auth::logout( @@ -4313,6 +4408,7 @@ impl ChatWidget { tracing::error!("failed to logout: {e}"); } self.request_quit_without_confirmation(); + QueueReplayControl::Stop } // SlashCommand::Undo => { // self.app_event_tx.send(AppEvent::CodexOp(Op::Undo)); @@ -4333,6 +4429,7 @@ impl ChatWidget { }; tx.send(AppEvent::DiffResult(text)); }); + QueueReplayControl::Continue } SlashCommand::Copy => { let Some(text) = self.last_copyable_output.as_deref() else { @@ -4341,7 +4438,7 @@ impl ChatWidget { .to_string(), /*hint*/ None, ); - return; + return QueueReplayControl::Continue; }; let copy_result = clipboard_text::copy_text_to_clipboard(text); @@ -4361,42 +4458,55 @@ impl ChatWidget { self.add_error_message(format!("Failed to copy to clipboard: {err}")) } } + QueueReplayControl::Continue } SlashCommand::Mention => { self.insert_str("@"); + QueueReplayControl::Stop } SlashCommand::Skills => { self.open_skills_menu(); + QueueReplayControl::Stop } SlashCommand::Status => { self.add_status_output(); + QueueReplayControl::Continue } SlashCommand::DebugConfig => { self.add_debug_config_output(); + QueueReplayControl::Continue } SlashCommand::Statusline => { self.open_status_line_setup(); + QueueReplayControl::Stop } SlashCommand::Theme => { self.open_theme_picker(); + QueueReplayControl::Stop } SlashCommand::Ps => { self.add_ps_output(); + QueueReplayControl::Continue } SlashCommand::Stop => { self.clean_background_terminals(); + QueueReplayControl::Continue } SlashCommand::MemoryDrop => { self.add_app_server_stub_message("Memory maintenance"); + QueueReplayControl::Stop } SlashCommand::MemoryUpdate => { self.add_app_server_stub_message("Memory maintenance"); + QueueReplayControl::Stop } SlashCommand::Mcp => { self.add_mcp_output(); + QueueReplayControl::Continue } SlashCommand::Apps => { self.add_connectors_output(); + QueueReplayControl::Continue } SlashCommand::Rollout => { if let Some(path) = self.rollout_path() { @@ -4410,6 +4520,7 @@ impl ChatWidget { /*hint*/ None, ); } + QueueReplayControl::Continue } SlashCommand::TestApproval => { use codex_protocol::protocol::EventMsg; @@ -4448,136 +4559,1430 @@ impl ChatWidget { grant_root: Some(PathBuf::from("/tmp")), }), })); + QueueReplayControl::Stop + } + } + } + + fn dispatch_command_with_args( + &mut self, + cmd: SlashCommand, + args: String, + text_elements: Vec, + ) { + let trimmed = args.trim(); + let should_queue = self.bottom_pane.is_task_running() + && !matches!(cmd.execution_kind(), SlashCommandExecutionKind::Immediate); + if trimmed.is_empty() { + if should_queue && !cmd.requires_interaction() { + self.queue_current_inline_bare_slash_command(cmd); + } else { + self.set_composer_text(String::new(), Vec::new(), Vec::new()); + self.set_remote_image_urls(Vec::new()); + self.dispatch_command(cmd); + } + return; + } + + if matches!(cmd, SlashCommand::Plan) && !should_queue { + let draft = Self::inline_slash_command_draft( + cmd, + UserMessage { + text: args, + local_images: self.bottom_pane.composer_local_images(), + remote_image_urls: self.bottom_pane.remote_image_urls(), + text_elements, + mention_bindings: self.bottom_pane.composer_mention_bindings(), + }, + ); + let pending_pastes = self.bottom_pane.composer_pending_pastes(); + self.dispatch_command(cmd); + if self.active_mode_kind() != ModeKind::Plan { + self.restore_user_message_to_composer(draft); + self.bottom_pane.set_composer_pending_pastes(pending_pastes); + return; + } + + let Some((prepared_args, prepared_elements)) = + self.bottom_pane.prepare_inline_args_submission(true) + else { + return; + }; + let args_message = + self.take_prepared_submission_user_message(prepared_args, prepared_elements); + self.submit_plan_user_message(args_message); + return; + } + + let record_history = matches!(cmd, SlashCommand::Plan); + let Some((prepared_args, prepared_elements)) = self + .bottom_pane + .prepare_inline_args_submission(record_history) + else { + return; + }; + let args_message = + self.take_prepared_submission_user_message(prepared_args, prepared_elements); + if should_queue { + self.queue_user_message(Self::inline_slash_command_draft(cmd, args_message)); + return; + } + let _ = self.execute_slash_command_with_args(cmd, args_message); + } + + fn execute_slash_command_with_args( + &mut self, + cmd: SlashCommand, + args_message: UserMessage, + ) -> QueueReplayControl { + match cmd { + SlashCommand::Help => { + self.bottom_pane + .show_view(Box::new(crate::bottom_pane::SlashHelpView::new( + self.builtin_command_flags(), + ))); + QueueReplayControl::Continue + } + SlashCommand::Approvals | SlashCommand::Permissions => { + let args = match SlashCommandInvocation::parse_args( + &args_message.text, + "Usage: /approvals [--smart-approvals] [--confirm-full-access] [--remember-full-access] [--confirm-world-writable] [--remember-world-writable] [--enable-windows-sandbox=elevated|legacy]", + ) { + Ok(args) => args, + Err(message) => { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + message, + ); + } + }; + if args.is_empty() { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Usage: /approvals [--smart-approvals] [--confirm-full-access] [--remember-full-access] [--confirm-world-writable] [--remember-world-writable] [--enable-windows-sandbox=elevated|legacy]".to_string(), + ); + } + let preset_id = args[0].as_str(); + let Some(preset) = builtin_approval_presets() + .into_iter() + .find(|preset| preset.id == preset_id) + else { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + format!("Unknown approval preset: {preset_id}"), + ); + }; + + let mut confirm_full_access = false; + let mut remember_full_access = false; + let mut confirm_world_writable = false; + let mut remember_world_writable = false; + let mut smart_approvals = false; + let mut windows_sandbox_mode = None; + for token in args.iter().skip(1) { + match token.as_str() { + "--smart-approvals" => smart_approvals = true, + "--confirm-full-access" => confirm_full_access = true, + "--remember-full-access" => remember_full_access = true, + "--confirm-world-writable" => confirm_world_writable = true, + "--remember-world-writable" => remember_world_writable = true, + "--enable-windows-sandbox=elevated" => { + windows_sandbox_mode = Some(WindowsSandboxEnableMode::Elevated); + } + "--enable-windows-sandbox=legacy" => { + windows_sandbox_mode = Some(WindowsSandboxEnableMode::Legacy); + } + _ => { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + format!("Unrecognized /approvals option: {token}"), + ); + } + } + } + if smart_approvals && preset.id != "auto" { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Smart Approvals is only available for Default permissions.".to_string(), + ); + } + if smart_approvals && !self.config.features.enabled(Feature::GuardianApproval) { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Smart Approvals is not enabled in this session.".to_string(), + ); + } + let approvals_reviewer = if smart_approvals { + ApprovalsReviewer::GuardianSubagent + } else { + ApprovalsReviewer::User + }; + #[cfg(not(target_os = "windows"))] + let _ = windows_sandbox_mode; + + if remember_full_access && !confirm_full_access { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "--remember-full-access requires --confirm-full-access".to_string(), + ); + } + if remember_world_writable && !confirm_world_writable { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "--remember-world-writable requires --confirm-world-writable".to_string(), + ); + } + + #[cfg(target_os = "windows")] + let label = if preset.id == "auto" + && matches!( + WindowsSandboxLevel::from_config(&self.config), + WindowsSandboxLevel::RestrictedToken + ) { + "Default (non-admin sandbox)".to_string() + } else { + preset.label.to_string() + }; + #[cfg(not(target_os = "windows"))] + let label = preset.label.to_string(); + let label = if smart_approvals { + "Smart Approvals".to_string() + } else { + label + }; + + if preset.id == "full-access" + && !confirm_full_access + && !self + .config + .notices + .hide_full_access_warning + .unwrap_or(false) + { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Full access requires confirmation. Re-run with --confirm-full-access." + .to_string(), + ); + } + + #[cfg(target_os = "windows")] + { + if preset.id == "auto" + && WindowsSandboxLevel::from_config(&self.config) + == WindowsSandboxLevel::Disabled + { + let Some(mode) = windows_sandbox_mode else { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Default permissions require Windows sandbox setup. Re-run with --enable-windows-sandbox=elevated or --enable-windows-sandbox=legacy.".to_string(), + ); + }; + match mode { + WindowsSandboxEnableMode::Elevated => { + self.app_event_tx.send( + AppEvent::BeginWindowsSandboxElevatedSetup { + preset, + approvals_reviewer, + }, + ); + } + WindowsSandboxEnableMode::Legacy => { + self.app_event_tx + .send(AppEvent::BeginWindowsSandboxLegacySetup { + preset, + approvals_reviewer, + }); + } + } + return QueueReplayControl::Stop; + } + if preset.id == "auto" + && self.world_writable_warning_details().is_some() + && !confirm_world_writable + { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Default permissions require confirming the Windows sandbox warning. Re-run with --confirm-world-writable.".to_string(), + ); + } + if confirm_world_writable { + self.app_event_tx.send(AppEvent::SkipNextWorldWritableScan); + if remember_world_writable { + self.app_event_tx + .send(AppEvent::UpdateWorldWritableWarningAcknowledged(true)); + self.app_event_tx + .send(AppEvent::PersistWorldWritableWarningAcknowledged); + } + } + } + + if confirm_full_access { + self.app_event_tx + .send(AppEvent::UpdateFullAccessWarningAcknowledged(true)); + if remember_full_access { + self.app_event_tx + .send(AppEvent::PersistFullAccessWarningAcknowledged); + } + } + + let sandbox = preset.sandbox.clone(); + self.app_event_tx + .send(AppEvent::CodexOp(Op::OverrideTurnContext { + cwd: None, + approval_policy: Some(preset.approval), + approvals_reviewer: Some(approvals_reviewer), + sandbox_policy: Some(sandbox.clone()), + windows_sandbox_level: None, + model: None, + effort: None, + summary: None, + service_tier: None, + collaboration_mode: None, + personality: None, + })); + self.app_event_tx + .send(AppEvent::UpdateAskForApprovalPolicy(preset.approval)); + self.app_event_tx + .send(AppEvent::UpdateSandboxPolicy(sandbox)); + self.app_event_tx + .send(AppEvent::UpdateApprovalsReviewer(approvals_reviewer)); + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_info_event( + format!("Permissions updated to {label}"), + /*hint*/ None, + ), + ))); + QueueReplayControl::ResumeWhenIdle + } + SlashCommand::Agent | SlashCommand::MultiAgents => { + let args = match SlashCommandInvocation::parse_args( + &args_message.text, + "Usage: /agent ", + ) { + Ok(args) => args, + Err(message) => { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + message, + ); + } + }; + if args.len() != 1 { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Usage: /agent ".to_string(), + ); + } + match ThreadId::from_string(&args[0]) { + Ok(thread_id) => { + self.app_event_tx + .send(AppEvent::SelectAgentThread(thread_id)); + QueueReplayControl::Stop + } + Err(_) => self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Usage: /agent ".to_string(), + ), + } + } + SlashCommand::Collab => { + let args = match SlashCommandInvocation::parse_args( + &args_message.text, + "Usage: /collab ", + ) { + Ok(args) => args, + Err(message) => { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + message, + ); + } + }; + if args.len() != 1 { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Usage: /collab ".to_string(), + ); + } + let mode = args[0].to_ascii_lowercase(); + let Some(mask) = collaboration_modes::presets_for_tui(self.model_catalog.as_ref()) + .into_iter() + .find(|mask| mask.name.eq_ignore_ascii_case(&mode)) + else { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Usage: /collab ".to_string(), + ); + }; + self.app_event_tx + .send(AppEvent::UpdateCollaborationMode(mask.clone())); + self.set_collaboration_mask(mask); + QueueReplayControl::Continue + } + SlashCommand::Experimental => { + let tokens = match SlashCommandInvocation::parse_args( + &args_message.text, + "Usage: /experimental =on|off ...", + ) { + Ok(tokens) => tokens, + Err(message) => { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + message, + ); + } + }; + let mut updates = Vec::new(); + for token in tokens { + let Some((key, value)) = token.split_once('=') else { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Usage: /experimental =on|off ...".to_string(), + ); + }; + let Some(spec) = FEATURES.iter().find(|spec| spec.key == key) else { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + format!("Unknown experimental feature: {key}"), + ); + }; + let enabled = match value { + "on" => true, + "off" => false, + _ => { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + format!("Experimental feature {key} must be set to on or off."), + ); + } + }; + updates.push((spec.id, enabled)); + } + self.app_event_tx + .send(AppEvent::UpdateFeatureFlags { updates }); + QueueReplayControl::ResumeWhenIdle + } + SlashCommand::Fast => { + let args = match SlashCommandInvocation::parse_args( + &args_message.text, + "Usage: /fast [on|off|status]", + ) { + Ok(args) => args, + Err(message) => { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + message, + ); + } + }; + if args.len() != 1 { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Usage: /fast [on|off|status]".to_string(), + ); + } + match args[0].to_ascii_lowercase().as_str() { + "on" => { + self.set_service_tier_selection(Some(ServiceTier::Fast)); + QueueReplayControl::Continue + } + "off" => { + self.set_service_tier_selection(None); + QueueReplayControl::Continue + } + "status" => { + let status = if matches!(self.config.service_tier, Some(ServiceTier::Fast)) + { + "on" + } else { + "off" + }; + self.add_info_message( + format!("Fast mode is {status}."), + /*hint*/ None, + ); + QueueReplayControl::Continue + } + _ => self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Usage: /fast [on|off|status]".to_string(), + ), + } + } + SlashCommand::Feedback => { + if !self.config.feedback_enabled { + let params = crate::bottom_pane::feedback_disabled_params(); + self.bottom_pane.show_selection_view(params); + self.request_redraw(); + return QueueReplayControl::Stop; + } + let args = match SlashCommandInvocation::parse_args( + &args_message.text, + "Usage: /feedback ", + ) { + Ok(args) => args, + Err(message) => { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + message, + ); + } + }; + if args.len() != 1 { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Usage: /feedback " + .to_string(), + ); + } + let category = match args[0].to_ascii_lowercase().as_str() { + "bad-result" => crate::app_event::FeedbackCategory::BadResult, + "good-result" => crate::app_event::FeedbackCategory::GoodResult, + "bug" => crate::app_event::FeedbackCategory::Bug, + "safety-check" => crate::app_event::FeedbackCategory::SafetyCheck, + "other" => crate::app_event::FeedbackCategory::Other, + _ => { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Usage: /feedback " + .to_string(), + ); + } + }; + self.app_event_tx + .send(AppEvent::OpenFeedbackConsent { category }); + QueueReplayControl::ResumeWhenIdle } + SlashCommand::Model => match SlashCommandInvocation::parse_args( + &args_message.text, + "Usage: /model [default|none|minimal|low|medium|high|xhigh] [plan-only|all-modes]", + ) { + Ok(args) => match Self::parse_model_selection_args(&args) { + Ok((model, effort, scope)) => { + self.apply_model_selection(model, effort, scope); + QueueReplayControl::Continue + } + Err(message) => { + self.restore_invalid_inline_slash_command(cmd, args_message, message) + } + }, + Err(message) => { + self.restore_invalid_inline_slash_command(cmd, args_message, message) + } + }, + SlashCommand::Personality => { + let args = match SlashCommandInvocation::parse_args( + &args_message.text, + "Usage: /personality ", + ) { + Ok(args) => args, + Err(message) => { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + message, + ); + } + }; + if args.len() != 1 { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Usage: /personality ".to_string(), + ); + } + let personality = match args[0].to_ascii_lowercase().as_str() { + "none" => Personality::None, + "friendly" => Personality::Friendly, + "pragmatic" => Personality::Pragmatic, + _ => { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Usage: /personality ".to_string(), + ); + } + }; + if !self.current_model_supports_personality() { + let current_model = self.current_model(); + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + format!( + "Current model ({current_model}) doesn't support personalities. Try /model to pick a different model." + ), + ); + } + self.app_event_tx + .send(AppEvent::CodexOp(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + approvals_reviewer: None, + sandbox_policy: None, + model: None, + effort: None, + summary: None, + service_tier: None, + collaboration_mode: None, + windows_sandbox_level: None, + personality: Some(personality), + })); + self.app_event_tx + .send(AppEvent::UpdatePersonality(personality)); + self.app_event_tx + .send(AppEvent::PersistPersonalitySelection { personality }); + QueueReplayControl::ResumeWhenIdle + } + SlashCommand::Rename => { + self.session_telemetry + .counter("codex.thread.rename", /*inc*/ 1, &[]); + let args = match SlashCommandInvocation::parse_args( + &args_message.text, + "Thread name cannot be empty.", + ) { + Ok(args) => args, + Err(message) => { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + message, + ); + } + }; + let Some(name) = codex_core::util::normalize_thread_name(&args.join(" ")) else { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Thread name cannot be empty.".to_string(), + ); + }; + let cell = Self::rename_confirmation_cell(&name, self.thread_id); + self.add_boxed_history(Box::new(cell)); + self.request_redraw(); + self.app_event_tx.set_thread_name(name); + QueueReplayControl::Continue + } + SlashCommand::Plan => { + self.dispatch_command(cmd); + if self.active_mode_kind() == ModeKind::Plan { + self.submit_plan_user_message(args_message); + } else { + self.restore_user_message_to_composer(Self::inline_slash_command_draft( + cmd, + args_message, + )); + } + QueueReplayControl::Stop + } + SlashCommand::Review => match SlashCommandInvocation::parse_args( + &args_message.text, + "Usage: /review [uncommitted|branch |commit [title]|]", + ) { + Ok(args) => match Self::parse_review_request(&args) { + Ok(review_request) => { + self.submit_op(AppCommand::review(review_request)); + QueueReplayControl::Stop + } + Err(message) => { + self.restore_invalid_inline_slash_command(cmd, args_message, message) + } + }, + Err(message) => { + self.restore_invalid_inline_slash_command(cmd, args_message, message) + } + }, + SlashCommand::Resume => { + let args = match SlashCommandInvocation::parse_args( + &args_message.text, + "Usage: /resume [--path ]", + ) { + Ok(args) => args, + Err(message) => { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + message, + ); + } + }; + if !(args.len() == 1 + || (args.len() == 3 && matches!(args[1].as_str(), "--path" | "--path-base64"))) + { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Usage: /resume [--path ]".to_string(), + ); + } + match ThreadId::from_string(&args[0]) { + Ok(thread_id) => { + if let Some(path) = args.get(2) { + let path = match args[1].as_str() { + "--path" => PathBuf::from(path), + "--path-base64" => { + let Ok(bytes) = + base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(path) + else { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Invalid encoded rollout path for /resume.".to_string(), + ); + }; + #[cfg(unix)] + let path = PathBuf::from(OsString::from_vec(bytes)); + #[cfg(windows)] + let path = { + let mut chunks = bytes.chunks_exact(2); + if !chunks.remainder().is_empty() { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Invalid encoded rollout path for /resume." + .to_string(), + ); + } + let wide = chunks + .by_ref() + .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) + .collect::>(); + PathBuf::from(OsString::from_wide(&wide)) + }; + path + } + _ => unreachable!("validated resume path flag"), + }; + self.app_event_tx.send(AppEvent::ResumeSessionTarget( + crate::resume_picker::SessionTarget { + path: Some(path), + thread_id, + }, + )); + } else { + self.app_event_tx.send(AppEvent::ResumeSession(thread_id)); + } + QueueReplayControl::Stop + } + Err(_) => self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Usage: /resume [--path ]".to_string(), + ), + } + } + SlashCommand::SandboxReadRoot => { + self.app_event_tx + .send(AppEvent::BeginWindowsSandboxGrantReadRoot { + path: args_message.text, + }); + QueueReplayControl::Stop + } + SlashCommand::Settings => { + let args = match SlashCommandInvocation::parse_args( + &args_message.text, + "Usage: /settings [default|]", + ) { + Ok(args) => args, + Err(message) => { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + message, + ); + } + }; + let Some(kind_name) = args.first() else { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Usage: /settings [default|]".to_string(), + ); + }; + let kind = match kind_name.to_ascii_lowercase().as_str() { + "microphone" => RealtimeAudioDeviceKind::Microphone, + "speaker" => RealtimeAudioDeviceKind::Speaker, + _ => { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Usage: /settings [default|]" + .to_string(), + ); + } + }; + let name = match args.get(1..).map(|rest| rest.join(" ")) { + None => None, + Some(device_name) if device_name.is_empty() || device_name == "default" => None, + Some(device_name) => Some(device_name), + }; + self.app_event_tx + .send(AppEvent::PersistRealtimeAudioDeviceSelection { kind, name }); + QueueReplayControl::ResumeWhenIdle + } + SlashCommand::Skills => { + let args = match SlashCommandInvocation::parse_args( + &args_message.text, + "Usage: /skills ", + ) { + Ok(args) => args, + Err(message) => { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + message, + ); + } + }; + if args.len() != 1 { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Usage: /skills ".to_string(), + ); + } + match args[0].to_ascii_lowercase().as_str() { + "list" => { + self.open_skills_list(); + QueueReplayControl::ResumeWhenIdle + } + "manage" => { + self.app_event_tx.send(AppEvent::OpenManageSkillsPopup); + QueueReplayControl::ResumeWhenIdle + } + _ => self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Usage: /skills ".to_string(), + ), + } + } + SlashCommand::Statusline => { + let item_ids = match SlashCommandInvocation::parse_args( + &args_message.text, + "Usage: /statusline ... | /statusline none", + ) { + Ok(item_ids) => item_ids, + Err(message) => { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + message, + ); + } + }; + let items = if item_ids.len() == 1 && item_ids[0].eq_ignore_ascii_case("none") { + Vec::new() + } else { + match item_ids + .iter() + .map(|item_id| item_id.parse::()) + .collect::, _>>() + { + Ok(items) => items, + Err(_) => { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Usage: /statusline ... | /statusline none".to_string(), + ); + } + } + }; + self.app_event_tx.send(AppEvent::StatusLineSetup { items }); + QueueReplayControl::ResumeWhenIdle + } + SlashCommand::Theme => { + let args = match SlashCommandInvocation::parse_args( + &args_message.text, + "Usage: /theme ", + ) { + Ok(args) => args, + Err(message) => { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + message, + ); + } + }; + if args.len() != 1 + || crate::render::highlight::resolve_theme_by_name( + &args[0], + Some(&self.config.codex_home), + ) + .is_none() + { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Usage: /theme ".to_string(), + ); + } + self.app_event_tx.send(AppEvent::SyntaxThemeSelected { + name: args[0].clone(), + }); + QueueReplayControl::ResumeWhenIdle + } + SlashCommand::New + | SlashCommand::Fork + | SlashCommand::Init + | SlashCommand::Compact + | SlashCommand::Diff + | SlashCommand::Copy + | SlashCommand::Mention + | SlashCommand::Status + | SlashCommand::DebugConfig + | SlashCommand::Mcp + | SlashCommand::Apps + | SlashCommand::Logout + | SlashCommand::Quit + | SlashCommand::Exit + | SlashCommand::Rollout + | SlashCommand::Ps + | SlashCommand::Stop + | SlashCommand::Clear + | SlashCommand::Realtime + | SlashCommand::TestApproval + | SlashCommand::MemoryDrop + | SlashCommand::MemoryUpdate + | SlashCommand::ElevateSandbox => self.restore_invalid_inline_slash_command( + cmd, + args_message, + format!("`/{}` does not accept inline arguments.", cmd.command()), + ), + } + } + + fn restore_invalid_inline_slash_command( + &mut self, + cmd: SlashCommand, + args_message: UserMessage, + message: String, + ) -> QueueReplayControl { + self.add_error_message(message); + self.restore_user_message_to_composer(Self::inline_slash_command_draft(cmd, args_message)); + QueueReplayControl::Stop + } + + fn take_prepared_submission_user_message( + &mut self, + text: String, + text_elements: Vec, + ) -> UserMessage { + UserMessage { + text, + local_images: self + .bottom_pane + .take_recent_submission_images_with_placeholders(), + remote_image_urls: self.take_remote_image_urls(), + text_elements, + mention_bindings: self.bottom_pane.take_recent_submission_mention_bindings(), + } + } + + fn inline_slash_command_draft(cmd: SlashCommand, args_message: UserMessage) -> UserMessage { + let prefix = format!("/{}", cmd.command()); + let UserMessage { + text, + local_images, + remote_image_urls, + text_elements, + mention_bindings, + } = args_message; + + if text.is_empty() { + return UserMessage { + text: prefix, + local_images, + remote_image_urls, + text_elements, + mention_bindings, + }; + } + + let mut draft_text = format!("{prefix} "); + let offset = draft_text.len(); + draft_text.push_str(&text); + let text_elements = text_elements + .into_iter() + .map(|element| { + let start = element.byte_range.start + offset; + let end = element.byte_range.end + offset; + element.map_range(|_| ByteRange { start, end }) + }) + .collect(); + + UserMessage { + text: draft_text, + local_images, + remote_image_urls, + text_elements, + mention_bindings, + } + } + + fn approval_preset_draft_for_reviewer( + preset_id: &str, + approvals_reviewer: ApprovalsReviewer, + flags: &[&str], + ) -> UserMessage { + let mut args = vec![preset_id.to_string()]; + if approvals_reviewer == ApprovalsReviewer::GuardianSubagent { + args.push("--smart-approvals".to_string()); + } + args.extend(flags.iter().map(|flag| (*flag).to_string())); + SlashCommandInvocation::with_args(SlashCommand::Approvals, args).into_user_message() + } + + fn approval_preset_draft(preset_id: &str, flags: &[&str]) -> UserMessage { + Self::approval_preset_draft_for_reviewer(preset_id, ApprovalsReviewer::User, flags) + } + + #[cfg(all(not(target_os = "linux"), feature = "voice-input"))] + fn settings_device_draft(kind: RealtimeAudioDeviceKind, name: Option<&str>) -> UserMessage { + let kind_name = match kind { + RealtimeAudioDeviceKind::Microphone => "microphone", + RealtimeAudioDeviceKind::Speaker => "speaker", + }; + let device_name = name.unwrap_or("default"); + SlashCommandInvocation::with_args(SlashCommand::Settings, [kind_name, device_name]) + .into_user_message() + } + + fn queue_current_inline_bare_slash_command(&mut self, cmd: SlashCommand) { + let Some((prepared_args, prepared_elements)) = + self.bottom_pane.prepare_inline_args_submission(false) + else { + return; + }; + let args_message = + self.take_prepared_submission_user_message(prepared_args, prepared_elements); + self.queue_user_message(Self::inline_slash_command_draft(cmd, args_message)); + } + + fn model_selection_draft( + model: &str, + effort: Option, + scope: ModelSelectionScope, + ) -> UserMessage { + let mut args = vec![model.to_string()]; + if let Some(token) = Self::model_reasoning_effort_token(effort) { + args.push(token.to_string()); + } + if let Some(token) = Self::model_selection_scope_token(scope) { + args.push(token.to_string()); + } + SlashCommandInvocation::with_args(SlashCommand::Model, args).into_user_message() + } + + fn apply_model_selection( + &mut self, + model: String, + effort: Option, + scope: ModelSelectionScope, + ) { + match scope { + ModelSelectionScope::Global => { + self.set_model(&model); + self.set_reasoning_effort(effort); + self.app_event_tx.send(AppEvent::UpdateModel(model.clone())); + self.app_event_tx + .send(AppEvent::UpdateReasoningEffort(effort)); + self.app_event_tx + .send(AppEvent::PersistModelSelection { model, effort }); + } + ModelSelectionScope::PlanOnly => { + self.set_model(&model); + self.set_plan_mode_reasoning_effort(effort); + self.app_event_tx.send(AppEvent::UpdateModel(model)); + self.app_event_tx + .send(AppEvent::UpdatePlanModeReasoningEffort(effort)); + self.app_event_tx + .send(AppEvent::PersistPlanModeReasoningEffort(effort)); + } + ModelSelectionScope::AllModes => { + self.set_model(&model); + self.set_reasoning_effort(effort); + self.set_plan_mode_reasoning_effort(effort); + self.app_event_tx.send(AppEvent::UpdateModel(model.clone())); + self.app_event_tx + .send(AppEvent::UpdateReasoningEffort(effort)); + self.app_event_tx + .send(AppEvent::UpdatePlanModeReasoningEffort(effort)); + self.app_event_tx + .send(AppEvent::PersistPlanModeReasoningEffort(effort)); + self.app_event_tx + .send(AppEvent::PersistModelSelection { model, effort }); + } + } + } + + fn review_request_draft(review_request: &ReviewRequest) -> UserMessage { + match &review_request.target { + ReviewTarget::UncommittedChanges => { + SlashCommandInvocation::with_args(SlashCommand::Review, ["uncommitted"]) + .into_user_message() + } + ReviewTarget::BaseBranch { branch } => { + SlashCommandInvocation::with_args(SlashCommand::Review, ["branch", branch.as_str()]) + .into_user_message() + } + ReviewTarget::Commit { sha, title } => { + let mut args = vec!["commit".to_string(), sha.clone()]; + if let Some(title) = title.as_deref().map(str::trim) + && !title.is_empty() + { + args.push(title.to_string()); + } + SlashCommandInvocation::with_args(SlashCommand::Review, args).into_user_message() + } + ReviewTarget::Custom { instructions } => { + SlashCommandInvocation::with_args(SlashCommand::Review, [instructions.as_str()]) + .into_user_message() + } + } + } + + pub(crate) fn resume_selection_draft( + target_session: &crate::resume_picker::SessionTarget, + ) -> UserMessage { + let mut args = vec![target_session.thread_id.to_string()]; + let Some(path) = target_session.path.as_ref() else { + return SlashCommandInvocation::with_args(SlashCommand::Resume, args) + .into_user_message(); + }; + let (path_flag, path_value) = if let Some(path) = path.to_str() { + ("--path".to_string(), path.to_string()) + } else { + #[cfg(unix)] + let bytes = path.as_os_str().as_bytes().to_vec(); + #[cfg(windows)] + let bytes = path + .as_os_str() + .encode_wide() + .flat_map(u16::to_le_bytes) + .collect::>(); + + ( + "--path-base64".to_string(), + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes), + ) + }; + args.push(path_flag); + args.push(path_value); + SlashCommandInvocation::with_args(SlashCommand::Resume, args).into_user_message() + } + + pub(crate) fn handle_serialized_slash_command(&mut self, draft: UserMessage) { + let Some((cmd, _, _)) = self.parse_builtin_slash_command(&draft.text) else { + if self.reject_unavailable_builtin_slash_command(&draft) { + return; + } + self.add_error_message(format!("Failed to handle slash command: {}", draft.text)); + self.restore_user_message_to_composer(draft); + return; + }; + if !matches!(cmd.execution_kind(), SlashCommandExecutionKind::Immediate) + && (self.bottom_pane.is_task_running() || !self.queued_user_messages.is_empty()) + { + self.queue_user_message(draft); + return; + } + let _ = self.execute_serialized_slash_command(draft); + } + + fn submit_plan_user_message(&mut self, user_message: UserMessage) { + if self.is_session_configured() { + self.reasoning_buffer.clear(); + self.full_reasoning_buffer.clear(); + self.set_status_header(String::from("Working")); + self.submit_user_message(user_message); + } else { + self.queue_user_message(user_message); + } + } + + fn parse_builtin_slash_command<'a>( + &self, + text: &'a str, + ) -> Option<(SlashCommand, &'a str, usize)> { + let (name, rest, rest_offset) = parse_slash_name(text)?; + let cmd = find_builtin_command(name, self.builtin_command_flags())?; + Some((cmd, rest, rest_offset)) + } + + fn builtin_command_flags(&self) -> BuiltinCommandFlags { + BuiltinCommandFlags { + collaboration_modes_enabled: self.collaboration_modes_enabled(), + connectors_enabled: self.connectors_enabled(), + fast_command_enabled: self.fast_mode_enabled(), + personality_command_enabled: self.config.features.enabled(Feature::Personality), + realtime_conversation_enabled: self.realtime_conversation_enabled(), + audio_device_selection_enabled: self.realtime_audio_device_selection_enabled(), + allow_elevate_sandbox: { + #[cfg(target_os = "windows")] + { + codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED + && matches!( + WindowsSandboxLevel::from_config(&self.config), + WindowsSandboxLevel::RestrictedToken + ) + } + #[cfg(not(target_os = "windows"))] + { + false + } + }, + } + } + + fn is_known_slash_draft(&self, draft: &UserMessage) -> bool { + let Some((name, _, _)) = parse_slash_name(&draft.text) else { + return false; + }; + !name.contains('/') && SlashCommand::from_str(name).is_ok() + } + + fn reject_unavailable_builtin_slash_command(&mut self, user_message: &UserMessage) -> bool { + let Some((name, _, _)) = parse_slash_name(&user_message.text) else { + return false; + }; + if name.contains('/') + || self + .parse_builtin_slash_command(&user_message.text) + .is_some() + || SlashCommand::from_str(name).is_err() + { + return false; + } + + self.add_error_message(format!("/{name} is not available in this session.")); + self.restore_user_message_to_composer(user_message.clone()); + true + } + + fn execute_serialized_slash_command(&mut self, draft: UserMessage) -> QueueReplayControl { + let preview = draft.text.clone(); + let Some((cmd, rest, rest_offset)) = self.parse_builtin_slash_command(&preview) else { + if self.reject_unavailable_builtin_slash_command(&draft) { + return QueueReplayControl::Stop; + } + self.add_error_message(format!("Failed to replay queued slash command: {preview}")); + self.restore_user_message_to_composer(draft); + return QueueReplayControl::Stop; + }; + if rest.trim().is_empty() { + if cmd.requires_interaction() { + self.add_error_message(format!( + "Failed to replay queued slash command requiring interaction: {preview}" + )); + self.restore_user_message_to_composer(draft); + return QueueReplayControl::Stop; + } + let replay_control = self.dispatch_command(cmd); + if replay_control == QueueReplayControl::Continue + && self.bottom_pane.no_modal_or_popup_active() + { + return QueueReplayControl::Continue; + } + return replay_control; + } + let args_message = Self::slash_command_args_message_from_draft(draft, rest_offset); + self.execute_slash_command_with_args(cmd, args_message) + } + + pub(crate) fn maybe_resume_queued_inputs_when_idle(&mut self) { + if !self.resume_queued_inputs_when_idle + || self.suppress_queue_autosend + || self.bottom_pane.is_task_running() + || !self.bottom_pane.no_modal_or_popup_active() + { + return; + } + + self.resume_queued_inputs_when_idle = false; + self.drain_queued_inputs_until_blocked(); + } + + fn slash_command_args_message_from_draft( + draft: UserMessage, + rest_offset: usize, + ) -> UserMessage { + let UserMessage { + text, + local_images, + remote_image_urls, + text_elements, + mention_bindings, + } = draft; + let rest = &text[rest_offset..]; + let trimmed_start = rest.len() - rest.trim_start().len(); + let trimmed_rest = rest.trim(); + let args_start = rest_offset + trimmed_start; + let args_end = args_start + trimmed_rest.len(); + let text_elements = text_elements + .into_iter() + .filter_map(|element| { + if element.byte_range.end <= args_start || element.byte_range.start >= args_end { + return None; + } + let start = element.byte_range.start.saturating_sub(args_start); + let end = element.byte_range.end.min(args_end) - args_start; + (start < end).then_some(element.map_range(|_| ByteRange { start, end })) + }) + .collect(); + + UserMessage { + text: trimmed_rest.to_string(), + local_images, + remote_image_urls, + text_elements, + mention_bindings, } } - fn dispatch_command_with_args( - &mut self, - cmd: SlashCommand, - args: String, - _text_elements: Vec, - ) { - if !cmd.supports_inline_args() { - self.dispatch_command(cmd); - return; - } - if !cmd.available_during_task() && self.bottom_pane.is_task_running() { - let message = format!( - "'/{}' is disabled while a task is in progress.", - cmd.command() - ); - self.add_to_history(history_cell::new_error_event(message)); - self.request_redraw(); - return; - } - - let trimmed = args.trim(); - match cmd { - SlashCommand::Fast => { - if trimmed.is_empty() { - self.dispatch_command(cmd); - return; - } - match trimmed.to_ascii_lowercase().as_str() { - "on" => self.set_service_tier_selection(Some(ServiceTier::Fast)), - "off" => self.set_service_tier_selection(/*service_tier*/ None), - "status" => { - let status = if matches!(self.config.service_tier, Some(ServiceTier::Fast)) - { - "on" - } else { - "off" - }; - self.add_info_message( - format!("Fast mode is {status}."), - /*hint*/ None, - ); - } - _ => { - self.add_error_message("Usage: /fast [on|off|status]".to_string()); - } + fn parse_review_request(args: &[String]) -> Result { + const REVIEW_USAGE: &str = + "Usage: /review [uncommitted|branch |commit [title]|]"; + let target = if args.len() == 1 + && matches!( + args[0].to_ascii_lowercase().as_str(), + "uncommitted" | "current" | "current changes" | "current-changes" + ) { + ReviewTarget::UncommittedChanges + } else if let Some(keyword) = args.first() { + match keyword.to_ascii_lowercase().as_str() { + "branch" if args.len() > 1 => ReviewTarget::BaseBranch { + branch: args[1..].join(" "), + }, + "branch" => { + return Err(REVIEW_USAGE.to_string()); } - } - SlashCommand::Rename if !trimmed.is_empty() => { - self.session_telemetry - .counter("codex.thread.rename", /*inc*/ 1, &[]); - let Some((prepared_args, _prepared_elements)) = self - .bottom_pane - .prepare_inline_args_submission(/*record_history*/ false) - else { - return; - }; - let Some(name) = codex_core::util::normalize_thread_name(&prepared_args) else { - self.add_error_message("Thread name cannot be empty.".to_string()); - return; - }; - let cell = Self::rename_confirmation_cell(&name, self.thread_id); - self.add_boxed_history(Box::new(cell)); - self.request_redraw(); - self.app_event_tx.set_thread_name(name); - self.bottom_pane.drain_pending_submission_state(); - } - SlashCommand::Plan if !trimmed.is_empty() => { - self.dispatch_command(cmd); - if self.active_mode_kind() != ModeKind::Plan { - return; + "commit" if args.len() > 1 => { + let sha = args[1].clone(); + let title = (!args[2..].is_empty()).then(|| args[2..].join(" ")); + ReviewTarget::Commit { sha, title } } - let Some((prepared_args, prepared_elements)) = self - .bottom_pane - .prepare_inline_args_submission(/*record_history*/ true) - else { - return; - }; - let local_images = self - .bottom_pane - .take_recent_submission_images_with_placeholders(); - let remote_image_urls = self.take_remote_image_urls(); - let user_message = UserMessage { - text: prepared_args, - local_images, - remote_image_urls, - text_elements: prepared_elements, - mention_bindings: self.bottom_pane.take_recent_submission_mention_bindings(), - }; - if self.is_session_configured() { - self.reasoning_buffer.clear(); - self.full_reasoning_buffer.clear(); - self.set_status_header(String::from("Working")); - self.submit_user_message(user_message); - } else { - self.queue_user_message(user_message); + "commit" => { + return Err(REVIEW_USAGE.to_string()); } + _ => ReviewTarget::Custom { + instructions: args.join(" "), + }, } - SlashCommand::Review if !trimmed.is_empty() => { - let Some((prepared_args, _prepared_elements)) = self - .bottom_pane - .prepare_inline_args_submission(/*record_history*/ false) - else { - return; - }; - self.submit_op(AppCommand::review(ReviewRequest { - target: ReviewTarget::Custom { - instructions: prepared_args, - }, - user_facing_hint: None, - })); - self.bottom_pane.drain_pending_submission_state(); + } else { + return Err(REVIEW_USAGE.to_string()); + }; + + Ok(ReviewRequest { + target, + user_facing_hint: None, + }) + } + + fn parse_model_selection_args( + args: &[String], + ) -> Result<(String, Option, ModelSelectionScope), String> { + const MODEL_USAGE: &str = "Usage: /model [default|none|minimal|low|medium|high|xhigh] [plan-only|all-modes]"; + let Some(model) = args.first() else { + return Err(MODEL_USAGE.to_string()); + }; + + let mut effort = None; + let mut saw_effort = false; + let mut scope = ModelSelectionScope::Global; + let mut saw_scope = false; + for token in &args[1..] { + if let Some(parsed_effort) = Self::parse_model_reasoning_effort_token(token) { + if saw_effort { + return Err(MODEL_USAGE.to_string()); + } + saw_effort = true; + effort = parsed_effort; + continue; } - SlashCommand::SandboxReadRoot if !trimmed.is_empty() => { - let Some((prepared_args, _prepared_elements)) = self - .bottom_pane - .prepare_inline_args_submission(/*record_history*/ false) - else { - return; - }; - self.app_event_tx - .send(AppEvent::BeginWindowsSandboxGrantReadRoot { - path: prepared_args, - }); - self.bottom_pane.drain_pending_submission_state(); + if let Some(parsed_scope) = Self::parse_model_scope_token(token) { + if saw_scope { + return Err(MODEL_USAGE.to_string()); + } + saw_scope = true; + scope = parsed_scope; + continue; } - _ => self.dispatch_command(cmd), + return Err(MODEL_USAGE.to_string()); + } + + Ok((model.clone(), effort, scope)) + } + + fn parse_model_reasoning_effort_token(token: &str) -> Option> { + match token.to_ascii_lowercase().as_str() { + "default" => Some(None), + "none" => Some(Some(ReasoningEffortConfig::None)), + "minimal" => Some(Some(ReasoningEffortConfig::Minimal)), + "low" => Some(Some(ReasoningEffortConfig::Low)), + "medium" => Some(Some(ReasoningEffortConfig::Medium)), + "high" => Some(Some(ReasoningEffortConfig::High)), + "xhigh" => Some(Some(ReasoningEffortConfig::XHigh)), + _ => None, + } + } + + fn parse_model_scope_token(token: &str) -> Option { + match token.to_ascii_lowercase().as_str() { + "plan-only" => Some(ModelSelectionScope::PlanOnly), + "all-modes" => Some(ModelSelectionScope::AllModes), + "global" => Some(ModelSelectionScope::Global), + _ => None, + } + } + + fn model_reasoning_effort_token(effort: Option) -> Option<&'static str> { + match effort { + Some(ReasoningEffortConfig::None) => Some("none"), + Some(ReasoningEffortConfig::Minimal) => Some("minimal"), + Some(ReasoningEffortConfig::Low) => Some("low"), + Some(ReasoningEffortConfig::Medium) => Some("medium"), + Some(ReasoningEffortConfig::High) => Some("high"), + Some(ReasoningEffortConfig::XHigh) => Some("xhigh"), + None => None, + } + } + + fn model_selection_scope_token(scope: ModelSelectionScope) -> Option<&'static str> { + match scope { + ModelSelectionScope::Global => None, + ModelSelectionScope::PlanOnly => Some("plan-only"), + ModelSelectionScope::AllModes => Some("all-modes"), } } @@ -5453,18 +6858,44 @@ impl ChatWidget { } } - // If idle and there are queued inputs, submit exactly one to start the next turn. - pub(crate) fn maybe_send_next_queued_input(&mut self) { + // If idle and there are queued inputs, dispatch queued work in order until a turn starts or + // a popup takes focus. + pub(crate) fn drain_queued_inputs_until_blocked(&mut self) { if self.suppress_queue_autosend { return; } if self.bottom_pane.is_task_running() { return; } - if let Some(user_message) = self.queued_user_messages.pop_front() { - self.submit_user_message(user_message); + if !self.bottom_pane.no_modal_or_popup_active() { + self.resume_queued_inputs_when_idle = !self.queued_user_messages.is_empty(); + self.refresh_pending_input_preview(); + return; + } + let mut resume_when_idle = false; + while !self.bottom_pane.is_task_running() { + let Some(queued_message) = self.queued_user_messages.pop_front() else { + break; + }; + let replay_control = if self.is_known_slash_draft(&queued_message) { + self.execute_serialized_slash_command(queued_message) + } else { + self.submit_user_message(queued_message); + QueueReplayControl::Stop + }; + if replay_control == QueueReplayControl::Stop { + break; + } + if replay_control == QueueReplayControl::ResumeWhenIdle + || !self.bottom_pane.no_modal_or_popup_active() + { + resume_when_idle = true; + break; + } } - // Update the list to reflect the remaining queued messages (if any). + self.resume_queued_inputs_when_idle = resume_when_idle + && !self.bottom_pane.is_task_running() + && !self.queued_user_messages.is_empty(); self.refresh_pending_input_preview(); } @@ -5473,7 +6904,7 @@ impl ChatWidget { let queued_messages: Vec = self .queued_user_messages .iter() - .map(|m| m.text.clone()) + .map(|message| message.text.clone()) .collect(); let pending_steers: Vec = self .pending_steers @@ -6189,7 +7620,9 @@ impl ChatWidget { description: Some("Use your operating system default device.".to_string()), is_current: current_selection.is_none(), actions: vec![Box::new(move |tx| { - tx.send(AppEvent::PersistRealtimeAudioDeviceSelection { kind, name: None }); + tx.send(AppEvent::HandleSlashCommandDraft( + Self::settings_device_draft(kind, None), + )); })], dismiss_on_select: true, ..Default::default() @@ -6211,10 +7644,9 @@ impl ChatWidget { items.extend(device_names.into_iter().map(|device_name| { let persisted_name = device_name.clone(); let actions: Vec = vec![Box::new(move |tx| { - tx.send(AppEvent::PersistRealtimeAudioDeviceSelection { - kind, - name: Some(persisted_name.clone()), - }); + tx.send(AppEvent::HandleSlashCommandDraft( + Self::settings_device_draft(kind, Some(persisted_name.as_str())), + )); })]; SelectionItem { is_current: current_selection.as_deref() == Some(device_name.as_str()), @@ -6481,8 +7913,15 @@ impl ChatWidget { .map(|mask| { let name = mask.name.clone(); let is_current = current_kind == mask.mode; + let command_name = name.to_ascii_lowercase(); let actions: Vec = vec![Box::new(move |tx| { - tx.send(AppEvent::UpdateCollaborationMode(mask.clone())); + tx.send(AppEvent::HandleSlashCommandDraft( + SlashCommandInvocation::with_args( + SlashCommand::Collab, + [command_name.clone()], + ) + .into_user_message(), + )); })]; SelectionItem { name, @@ -6517,12 +7956,13 @@ impl ChatWidget { return; } - tx.send(AppEvent::UpdateModel(model_for_action.clone())); - tx.send(AppEvent::UpdateReasoningEffort(effort_for_action)); - tx.send(AppEvent::PersistModelSelection { - model: model_for_action.clone(), - effort: effort_for_action, - }); + tx.send(AppEvent::HandleSlashCommandDraft( + Self::model_selection_draft( + &model_for_action, + effort_for_action, + ModelSelectionScope::Global, + ), + )); })] } @@ -6589,20 +8029,15 @@ impl ChatWidget { let plan_only_actions: Vec = vec![Box::new({ let model = model.clone(); move |tx| { - tx.send(AppEvent::UpdateModel(model.clone())); - tx.send(AppEvent::UpdatePlanModeReasoningEffort(effort)); - tx.send(AppEvent::PersistPlanModeReasoningEffort(effort)); + tx.send(AppEvent::HandleSlashCommandDraft( + Self::model_selection_draft(&model, effort, ModelSelectionScope::PlanOnly), + )); } })]; let all_modes_actions: Vec = vec![Box::new(move |tx| { - tx.send(AppEvent::UpdateModel(model.clone())); - tx.send(AppEvent::UpdateReasoningEffort(effort)); - tx.send(AppEvent::UpdatePlanModeReasoningEffort(effort)); - tx.send(AppEvent::PersistPlanModeReasoningEffort(effort)); - tx.send(AppEvent::PersistModelSelection { - model: model.clone(), - effort, - }); + tx.send(AppEvent::HandleSlashCommandDraft( + Self::model_selection_draft(&model, effort, ModelSelectionScope::AllModes), + )); })]; self.bottom_pane.show_selection_view(SelectionViewParams { @@ -6690,7 +8125,13 @@ impl ChatWidget { effort: selected_effort, }); } else { - self.apply_model_and_effort(selected_model, selected_effort); + self.app_event_tx.send(AppEvent::HandleSlashCommandDraft( + Self::model_selection_draft( + selected_model.as_str(), + selected_effort, + ModelSelectionScope::Global, + ), + )); } return; } @@ -6765,12 +8206,13 @@ impl ChatWidget { effort: choice_effort, }); } else { - tx.send(AppEvent::UpdateModel(model_for_action.clone())); - tx.send(AppEvent::UpdateReasoningEffort(choice_effort)); - tx.send(AppEvent::PersistModelSelection { - model: model_for_action.clone(), - effort: choice_effort, - }); + tx.send(AppEvent::HandleSlashCommandDraft( + Self::model_selection_draft( + &model_for_action, + choice_effort, + ModelSelectionScope::Global, + ), + )); } })]; @@ -6810,22 +8252,6 @@ impl ChatWidget { } } - fn apply_model_and_effort_without_persist( - &self, - model: String, - effort: Option, - ) { - self.app_event_tx.send(AppEvent::UpdateModel(model)); - self.app_event_tx - .send(AppEvent::UpdateReasoningEffort(effort)); - } - - fn apply_model_and_effort(&self, model: String, effort: Option) { - self.apply_model_and_effort_without_persist(model.clone(), effort); - self.app_event_tx - .send(AppEvent::PersistModelSelection { model, effort }); - } - /// Open the permissions popup (alias for /permissions). pub(crate) fn open_approvals_popup(&mut self) { self.open_permissions_popup(); @@ -6913,15 +8339,18 @@ impl ChatWidget { ) { vec![Box::new(move |tx| { - tx.send(AppEvent::EnableWindowsSandboxForAgentMode { - preset: preset_clone.clone(), - mode: WindowsSandboxEnableMode::Elevated, - }); + tx.send(AppEvent::HandleSlashCommandDraft( + Self::approval_preset_draft( + preset_clone.id, + &["--enable-windows-sandbox=elevated"], + ), + )); })] } else { vec![Box::new(move |tx| { tx.send(AppEvent::OpenWindowsSandboxEnablePrompt { preset: preset_clone.clone(), + approvals_reviewer: ApprovalsReviewer::User, }); })] } @@ -6932,36 +8361,22 @@ impl ChatWidget { vec![Box::new(move |tx| { tx.send(AppEvent::OpenWorldWritableWarningConfirmation { preset: Some(preset_clone.clone()), + approvals_reviewer: Some(ApprovalsReviewer::User), sample_paths: sample_paths.clone(), extra_count, failed_scan, }); })] } else { - Self::approval_preset_actions( - preset.approval, - preset.sandbox.clone(), - base_name.clone(), - ApprovalsReviewer::User, - ) + Self::approval_preset_actions(preset.id, &[]) } } #[cfg(not(target_os = "windows"))] { - Self::approval_preset_actions( - preset.approval, - preset.sandbox.clone(), - base_name.clone(), - ApprovalsReviewer::User, - ) + Self::approval_preset_actions(preset.id, &[]) } } else { - Self::approval_preset_actions( - preset.approval, - preset.sandbox.clone(), - base_name.clone(), - ApprovalsReviewer::User, - ) + Self::approval_preset_actions(preset.id, &[]) }; if preset.id == "auto" { items.push(SelectionItem { @@ -6976,6 +8391,64 @@ impl ChatWidget { }); if guardian_approval_enabled { + let guardian_preset = preset.clone(); + let guardian_actions: Vec = { + #[cfg(target_os = "windows")] + { + if WindowsSandboxLevel::from_config(&self.config) + == WindowsSandboxLevel::Disabled + { + let preset_clone = guardian_preset.clone(); + if codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED + && codex_core::windows_sandbox::sandbox_setup_is_complete( + self.config.codex_home.as_path(), + ) + { + Self::approval_preset_actions_for_reviewer( + guardian_preset.id, + ApprovalsReviewer::GuardianSubagent, + &["--enable-windows-sandbox=elevated"], + ) + } else { + vec![Box::new(move |tx| { + tx.send(AppEvent::OpenWindowsSandboxEnablePrompt { + preset: preset_clone.clone(), + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + }); + })] + } + } else if let Some((sample_paths, extra_count, failed_scan)) = + self.world_writable_warning_details() + { + let preset_clone = guardian_preset.clone(); + vec![Box::new(move |tx| { + tx.send(AppEvent::OpenWorldWritableWarningConfirmation { + preset: Some(preset_clone.clone()), + approvals_reviewer: Some( + ApprovalsReviewer::GuardianSubagent, + ), + sample_paths: sample_paths.clone(), + extra_count, + failed_scan, + }); + })] + } else { + Self::approval_preset_actions_for_reviewer( + guardian_preset.id, + ApprovalsReviewer::GuardianSubagent, + &[], + ) + } + } + #[cfg(not(target_os = "windows"))] + { + Self::approval_preset_actions_for_reviewer( + guardian_preset.id, + ApprovalsReviewer::GuardianSubagent, + &[], + ) + } + }; items.push(SelectionItem { name: "Guardian Approvals".to_string(), description: Some( @@ -6988,12 +8461,7 @@ impl ChatWidget { current_sandbox, &preset, ), - actions: Self::approval_preset_actions( - preset.approval, - preset.sandbox.clone(), - "Guardian Approvals".to_string(), - ApprovalsReviewer::GuardianSubagent, - ), + actions: guardian_actions, dismiss_on_select: true, disabled_reason: approval_disabled_reason .or_else(|| guardian_disabled_reason(true)), @@ -7043,7 +8511,7 @@ impl ChatWidget { let name = spec.stage.experimental_menu_name()?; let description = spec.stage.experimental_menu_description()?; Some(ExperimentalFeatureItem { - feature: spec.id, + key: spec.key.to_string(), name: name.to_string(), description: description.to_string(), enabled: self.config.features.enabled(spec.id), @@ -7056,38 +8524,25 @@ impl ChatWidget { } fn approval_preset_actions( - approval: AskForApproval, - sandbox: SandboxPolicy, - label: String, + preset_id: &'static str, + flags: &'static [&'static str], + ) -> Vec { + vec![Box::new(move |tx| { + tx.send(AppEvent::HandleSlashCommandDraft( + Self::approval_preset_draft(preset_id, flags), + )); + })] + } + + fn approval_preset_actions_for_reviewer( + preset_id: &'static str, approvals_reviewer: ApprovalsReviewer, + flags: &'static [&'static str], ) -> Vec { vec![Box::new(move |tx| { - let sandbox_clone = sandbox.clone(); - tx.send(AppEvent::CodexOp( - AppCommand::override_turn_context( - /*cwd*/ None, - Some(approval), - Some(approvals_reviewer), - Some(sandbox_clone.clone()), - /*windows_sandbox_level*/ None, - /*model*/ None, - /*effort*/ None, - /*summary*/ None, - /*service_tier*/ None, - /*collaboration_mode*/ None, - /*personality*/ None, - ) - .into_core(), + tx.send(AppEvent::HandleSlashCommandDraft( + Self::approval_preset_draft_for_reviewer(preset_id, approvals_reviewer, flags), )); - tx.send(AppEvent::UpdateAskForApprovalPolicy(approval)); - tx.send(AppEvent::UpdateSandboxPolicy(sandbox_clone)); - tx.send(AppEvent::UpdateApprovalsReviewer(approvals_reviewer)); - tx.send(AppEvent::InsertHistoryCell(Box::new( - history_cell::new_info_event( - format!("Permissions updated to {label}"), - /*hint*/ None, - ), - ))); })] } @@ -7161,9 +8616,6 @@ impl ChatWidget { preset: ApprovalPreset, return_to_permissions: bool, ) { - let selected_name = preset.label.to_string(); - let approval = preset.approval; - let sandbox = preset.sandbox; let mut header_children: Vec> = Vec::new(); let title_line = Line::from("Enable full access?").bold(); let info_line = Line::from(vec![ @@ -7178,26 +8630,11 @@ impl ChatWidget { )); let header = ColumnRenderable::with(header_children); - let mut accept_actions = Self::approval_preset_actions( - approval, - sandbox.clone(), - selected_name.clone(), - ApprovalsReviewer::User, - ); - accept_actions.push(Box::new(|tx| { - tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true)); - })); - - let mut accept_and_remember_actions = Self::approval_preset_actions( - approval, - sandbox, - selected_name, - ApprovalsReviewer::User, + let accept_actions = Self::approval_preset_actions(preset.id, &["--confirm-full-access"]); + let accept_and_remember_actions = Self::approval_preset_actions( + preset.id, + &["--confirm-full-access", "--remember-full-access"], ); - accept_and_remember_actions.push(Box::new(|tx| { - tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true)); - tx.send(AppEvent::PersistFullAccessWarningAcknowledged); - })); let deny_actions: Vec = vec![Box::new(move |tx| { if return_to_permissions { @@ -7243,14 +8680,11 @@ impl ChatWidget { pub(crate) fn open_world_writable_warning_confirmation( &mut self, preset: Option, + approvals_reviewer: Option, sample_paths: Vec, extra_count: usize, failed_scan: bool, ) { - let (approval, sandbox) = match &preset { - Some(p) => (Some(p.approval), Some(p.sandbox.clone())), - None => (None, None), - }; let mut header_children: Vec> = Vec::new(); let describe_policy = |policy: &SandboxPolicy| match policy { SandboxPolicy::WorkspaceWrite { .. } => "Agent mode", @@ -7294,36 +8728,29 @@ impl ChatWidget { // Build actions ensuring acknowledgement happens before applying the new sandbox policy, // so downstream policy-change hooks don't re-trigger the warning. - let mut accept_actions: Vec = Vec::new(); - // Suppress the immediate re-scan only when a preset will be applied (i.e., via /approvals or - // /permissions), to avoid duplicate warnings from the ensuing policy change. - if preset.is_some() { - accept_actions.push(Box::new(|tx| { - tx.send(AppEvent::SkipNextWorldWritableScan); - })); - } - if let (Some(approval), Some(sandbox)) = (approval, sandbox.clone()) { - accept_actions.extend(Self::approval_preset_actions( - approval, - sandbox, - mode_label.to_string(), - ApprovalsReviewer::User, - )); - } - - let mut accept_and_remember_actions: Vec = Vec::new(); - accept_and_remember_actions.push(Box::new(|tx| { - tx.send(AppEvent::UpdateWorldWritableWarningAcknowledged(true)); - tx.send(AppEvent::PersistWorldWritableWarningAcknowledged); - })); - if let (Some(approval), Some(sandbox)) = (approval, sandbox) { - accept_and_remember_actions.extend(Self::approval_preset_actions( - approval, - sandbox, - mode_label.to_string(), - ApprovalsReviewer::User, - )); - } + let accept_actions = preset + .as_ref() + .map(|preset| { + Self::approval_preset_actions_for_reviewer( + preset.id, + approvals_reviewer.unwrap_or(ApprovalsReviewer::User), + &["--confirm-world-writable"], + ) + }) + .unwrap_or_default(); + let accept_and_remember_actions: Vec = + if let Some(preset) = preset.as_ref() { + Self::approval_preset_actions_for_reviewer( + preset.id, + approvals_reviewer.unwrap_or(ApprovalsReviewer::User), + &["--confirm-world-writable", "--remember-world-writable"], + ) + } else { + vec![Box::new(|tx| { + tx.send(AppEvent::UpdateWorldWritableWarningAcknowledged(true)); + tx.send(AppEvent::PersistWorldWritableWarningAcknowledged); + })] + }; let items = vec![ SelectionItem { @@ -7354,6 +8781,7 @@ impl ChatWidget { pub(crate) fn open_world_writable_warning_confirmation( &mut self, _preset: Option, + _approvals_reviewer: Option, _sample_paths: Vec, _extra_count: usize, _failed_scan: bool, @@ -7361,7 +8789,11 @@ impl ChatWidget { } #[cfg(target_os = "windows")] - pub(crate) fn open_windows_sandbox_enable_prompt(&mut self, preset: ApprovalPreset) { + pub(crate) fn open_windows_sandbox_enable_prompt( + &mut self, + preset: ApprovalPreset, + approvals_reviewer: ApprovalsReviewer, + ) { use ratatui_macros::line; if !codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED { @@ -7382,9 +8814,9 @@ impl ChatWidget { name: "Enable experimental sandbox".to_string(), description: None, actions: vec![Box::new(move |tx| { - tx.send(AppEvent::EnableWindowsSandboxForAgentMode { + tx.send(AppEvent::BeginWindowsSandboxLegacySetup { preset: preset_clone.clone(), - mode: WindowsSandboxEnableMode::Legacy, + approvals_reviewer, }); })], dismiss_on_select: true, @@ -7432,9 +8864,13 @@ impl ChatWidget { description: None, actions: vec![Box::new(move |tx| { accept_otel.counter("codex.windows_sandbox.elevated_prompt_accept", 1, &[]); - tx.send(AppEvent::BeginWindowsSandboxElevatedSetup { - preset: preset.clone(), - }); + tx.send(AppEvent::HandleSlashCommandDraft( + Self::approval_preset_draft_for_reviewer( + preset.id, + approvals_reviewer, + &["--enable-windows-sandbox=elevated"], + ), + )); })], dismiss_on_select: true, ..Default::default() @@ -7444,9 +8880,13 @@ impl ChatWidget { description: None, actions: vec![Box::new(move |tx| { legacy_otel.counter("codex.windows_sandbox.elevated_prompt_use_legacy", 1, &[]); - tx.send(AppEvent::BeginWindowsSandboxLegacySetup { - preset: legacy_preset.clone(), - }); + tx.send(AppEvent::HandleSlashCommandDraft( + Self::approval_preset_draft_for_reviewer( + legacy_preset.id, + approvals_reviewer, + &["--enable-windows-sandbox=legacy"], + ), + )); })], dismiss_on_select: true, ..Default::default() @@ -7473,10 +8913,19 @@ impl ChatWidget { } #[cfg(not(target_os = "windows"))] - pub(crate) fn open_windows_sandbox_enable_prompt(&mut self, _preset: ApprovalPreset) {} + pub(crate) fn open_windows_sandbox_enable_prompt( + &mut self, + _preset: ApprovalPreset, + _approvals_reviewer: ApprovalsReviewer, + ) { + } #[cfg(target_os = "windows")] - pub(crate) fn open_windows_sandbox_fallback_prompt(&mut self, preset: ApprovalPreset) { + pub(crate) fn open_windows_sandbox_fallback_prompt( + &mut self, + preset: ApprovalPreset, + approvals_reviewer: ApprovalsReviewer, + ) { use ratatui_macros::line; let mut lines = Vec::new(); @@ -7506,9 +8955,13 @@ impl ChatWidget { let preset = elevated_preset; move |tx| { otel.counter("codex.windows_sandbox.fallback_retry_elevated", 1, &[]); - tx.send(AppEvent::BeginWindowsSandboxElevatedSetup { - preset: preset.clone(), - }); + tx.send(AppEvent::HandleSlashCommandDraft( + Self::approval_preset_draft_for_reviewer( + preset.id, + approvals_reviewer, + &["--enable-windows-sandbox=elevated"], + ), + )); } })], dismiss_on_select: true, @@ -7522,9 +8975,13 @@ impl ChatWidget { let preset = legacy_preset; move |tx| { otel.counter("codex.windows_sandbox.fallback_use_legacy", 1, &[]); - tx.send(AppEvent::BeginWindowsSandboxLegacySetup { - preset: preset.clone(), - }); + tx.send(AppEvent::HandleSlashCommandDraft( + Self::approval_preset_draft_for_reviewer( + preset.id, + approvals_reviewer, + &["--enable-windows-sandbox=legacy"], + ), + )); } })], dismiss_on_select: true, @@ -7552,7 +9009,12 @@ impl ChatWidget { } #[cfg(not(target_os = "windows"))] - pub(crate) fn open_windows_sandbox_fallback_prompt(&mut self, _preset: ApprovalPreset) {} + pub(crate) fn open_windows_sandbox_fallback_prompt( + &mut self, + _preset: ApprovalPreset, + _approvals_reviewer: ApprovalsReviewer, + ) { + } #[cfg(target_os = "windows")] pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self, show_now: bool) { @@ -7562,7 +9024,7 @@ impl ChatWidget { .into_iter() .find(|preset| preset.id == "auto") { - self.open_windows_sandbox_enable_prompt(preset); + self.open_windows_sandbox_enable_prompt(preset, self.config.approvals_reviewer); } } @@ -8830,10 +10292,12 @@ impl ChatWidget { items.push(SelectionItem { name: "Review uncommitted changes".to_string(), actions: vec![Box::new(move |tx: &AppEventSender| { - tx.review(ReviewRequest { - target: ReviewTarget::UncommittedChanges, - user_facing_hint: None, - }); + tx.send(AppEvent::HandleSlashCommandDraft( + Self::review_request_draft(&ReviewRequest { + target: ReviewTarget::UncommittedChanges, + user_facing_hint: None, + }), + )); })], dismiss_on_select: true, ..Default::default() @@ -8881,12 +10345,14 @@ impl ChatWidget { items.push(SelectionItem { name: format!("{current_branch} -> {branch}"), actions: vec![Box::new(move |tx3: &AppEventSender| { - tx3.review(ReviewRequest { - target: ReviewTarget::BaseBranch { - branch: branch.clone(), - }, - user_facing_hint: None, - }); + tx3.send(AppEvent::HandleSlashCommandDraft( + Self::review_request_draft(&ReviewRequest { + target: ReviewTarget::BaseBranch { + branch: branch.clone(), + }, + user_facing_hint: None, + }), + )); })], dismiss_on_select: true, search_value: Some(option), @@ -8916,13 +10382,15 @@ impl ChatWidget { items.push(SelectionItem { name: subject.clone(), actions: vec![Box::new(move |tx3: &AppEventSender| { - tx3.review(ReviewRequest { - target: ReviewTarget::Commit { - sha: sha.clone(), - title: Some(subject.clone()), - }, - user_facing_hint: None, - }); + tx3.send(AppEvent::HandleSlashCommandDraft( + Self::review_request_draft(&ReviewRequest { + target: ReviewTarget::Commit { + sha: sha.clone(), + title: Some(subject.clone()), + }, + user_facing_hint: None, + }), + )); })], dismiss_on_select: true, search_value: Some(search_val), @@ -8951,12 +10419,14 @@ impl ChatWidget { if trimmed.is_empty() { return; } - tx.review(ReviewRequest { - target: ReviewTarget::Custom { - instructions: trimmed, - }, - user_facing_hint: None, - }); + tx.send(AppEvent::HandleSlashCommandDraft( + Self::review_request_draft(&ReviewRequest { + target: ReviewTarget::Custom { + instructions: trimmed, + }, + user_facing_hint: None, + }), + )); }), ); self.bottom_pane.show_view(Box::new(view)); @@ -9296,13 +10766,15 @@ pub(crate) fn show_review_commit_picker_with_entries( items.push(SelectionItem { name: subject.clone(), actions: vec![Box::new(move |tx3: &AppEventSender| { - tx3.review(ReviewRequest { - target: ReviewTarget::Commit { - sha: sha.clone(), - title: Some(subject.clone()), - }, - user_facing_hint: None, - }); + tx3.send(AppEvent::HandleSlashCommandDraft( + ChatWidget::review_request_draft(&ReviewRequest { + target: ReviewTarget::Commit { + sha: sha.clone(), + title: Some(subject.clone()), + }, + user_facing_hint: None, + }), + )); })], dismiss_on_select: true, search_value: Some(search_val), diff --git a/codex-rs/tui_app_server/src/chatwidget/skills.rs b/codex-rs/tui_app_server/src/chatwidget/skills.rs index 24273b69763..c09c6f88c3d 100644 --- a/codex-rs/tui_app_server/src/chatwidget/skills.rs +++ b/codex-rs/tui_app_server/src/chatwidget/skills.rs @@ -12,6 +12,8 @@ use crate::bottom_pane::SkillsToggleView; use crate::bottom_pane::popup_consts::standard_popup_hint_line; use crate::skills_helpers::skill_description; use crate::skills_helpers::skill_display_name; +use crate::slash_command::SlashCommand; +use crate::slash_command_invocation::SlashCommandInvocation; use codex_chatgpt::connectors::AppInfo; use codex_core::connectors::connector_mention_slug; use codex_core::mention_syntax::TOOL_MENTION_SIGIL; @@ -34,7 +36,10 @@ impl ChatWidget { name: "List skills".to_string(), description: Some("Tip: press $ to open this list directly.".to_string()), actions: vec![Box::new(|tx| { - tx.send(AppEvent::OpenSkillsList); + tx.send(AppEvent::HandleSlashCommandDraft( + SlashCommandInvocation::with_args(SlashCommand::Skills, ["list"]) + .into_user_message(), + )); })], dismiss_on_select: true, ..Default::default() @@ -43,7 +48,10 @@ impl ChatWidget { name: "Enable/Disable Skills".to_string(), description: Some("Enable or disable skills.".to_string()), actions: vec![Box::new(|tx| { - tx.send(AppEvent::OpenManageSkillsPopup); + tx.send(AppEvent::HandleSlashCommandDraft( + SlashCommandInvocation::with_args(SlashCommand::Skills, ["manage"]) + .into_user_message(), + )); })], dismiss_on_select: true, ..Default::default() diff --git a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__disabled_slash_command_while_task_running_snapshot.snap b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__disabled_slash_command_while_task_running_snapshot.snap deleted file mode 100644 index 9368ebf1f88..00000000000 --- a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__disabled_slash_command_while_task_running_snapshot.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui_app_server/src/chatwidget/tests.rs -expression: blob ---- -■ '/model' is disabled while a task is in progress. diff --git a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__model_slash_command_while_task_running_popup.snap b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__model_slash_command_while_task_running_popup.snap new file mode 100644 index 00000000000..d08a8340917 --- /dev/null +++ b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__model_slash_command_while_task_running_popup.snap @@ -0,0 +1,22 @@ +--- +source: tui_app_server/src/chatwidget/tests.rs +expression: term.backend().vt100().screen().contents() +--- + + + + + Select Model and Effort + Access legacy models by running codex -m or in your config.toml + +› 1. gpt-5.3-codex (default) Latest frontier agentic coding model. + 2. gpt-5.4 Latest frontier agentic coding model. + 3. gpt-5.2-codex Frontier agentic coding model. + 4. gpt-5.1-codex-max Codex-optimized flagship for deep and fast + reasoning. + 5. gpt-5.2 Latest frontier model with improvements across + knowledge, reasoning and coding + 6. gpt-5.1-codex-mini Optimized for codex. Cheaper, faster, but less + capable. + + Press enter to select reasoning effort, or esc to dismiss. diff --git a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__slash_help_output.snap b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__slash_help_output.snap new file mode 100644 index 00000000000..f9fc5f64235 --- /dev/null +++ b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__slash_help_output.snap @@ -0,0 +1,223 @@ +--- +source: tui_app_server/src/chatwidget/tests.rs +expression: popup +--- + Slash Commands + + Type / to open the command popup. For commands with both a picker and an arg form, bare /command + opens the picker and /command ... runs directly. + Args use shell-style quoting; quote values with spaces. + + /help + show slash command help + Usage: + /help + + /model + choose what model and reasoning effort to use + Usage: + /model + /model [default|none|minimal|low|medium|high|xhigh] [plan-only|all-modes] + + /fast + toggle Fast mode to enable fastest inference at 2X plan usage + Usage: + /fast + /fast + + /approvals + choose what Codex is allowed to do + Usage: + /approvals + /approvals [--smart-approvals] [--confirm-full-access] + [--remember-full-access] [--confirm-world-writable] [--remember-world-writable] + [--enable-windows-sandbox=elevated|legacy] + + /permissions + choose what Codex is allowed to do + Usage: + /permissions + /permissions [--smart-approvals] [--confirm-full-access] + [--remember-full-access] [--confirm-world-writable] [--remember-world-writable] + [--enable-windows-sandbox=elevated|legacy] + + /experimental + toggle experimental features + Usage: + /experimental + /experimental =on|off ... + + /skills + use skills to improve how Codex performs specific tasks + Usage: + /skills + /skills + + /review + review my current changes and find issues + Usage: + /review + /review uncommitted + /review branch + /review commit [title] + /review + + /rename + rename the current thread + Usage: + /rename + /rename + + /new + start a new chat during a conversation + Usage: + /new + + /resume + resume a saved chat + Usage: + /resume + /resume + /resume --path + + /fork + fork the current chat + Usage: + /fork + + /init + create an AGENTS.md file with instructions for Codex + Usage: + /init + + /compact + summarize conversation to prevent hitting the context limit + Usage: + /compact + + /plan + switch to Plan mode + Usage: + /plan + /plan + + /collab + change collaboration mode (experimental) + Usage: + /collab + /collab + + /agent + switch the active agent thread + Usage: + /agent + /agent + + /diff + show git diff (including untracked files) + Usage: + /diff + + /copy + copy the latest Codex output to your clipboard + Usage: + /copy + + /mention + mention a file + Usage: + /mention + + /status + show current session configuration and token usage + Usage: + /status + + /debug-config + show config layers and requirement sources for debugging + Usage: + /debug-config + + /statusline + configure which items appear in the status line + Usage: + /statusline + /statusline ... + /statusline none + + /theme + choose a syntax highlighting theme + Usage: + /theme + /theme + + /mcp + list configured MCP tools + Usage: + /mcp + + /logout + log out of Codex + Usage: + /logout + + /quit + exit Codex + Usage: + /quit + + /exit + exit Codex + Usage: + /exit + + /feedback + send logs to maintainers + Usage: + /feedback + /feedback + + /rollout + print the rollout file path + Usage: + /rollout + + /ps + list background terminals + Usage: + /ps + + /stop + stop all background terminals + Usage: + /stop + + /clear + clear the terminal and start a new chat + Usage: + /clear + + /personality + choose a communication style for Codex + Usage: + /personality + /personality + + /test-approval + test approval request + Usage: + /test-approval + + /subagents + switch the active agent thread + Usage: + /subagents + /subagents + + /debug-m-drop + DO NOT USE + Usage: + /debug-m-drop + + + ↑/↓ scroll | [ctrl + p / ctrl + n] page | / search | esc close 1-212/219 diff --git a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__slash_help_output@windows.snap b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__slash_help_output@windows.snap new file mode 100644 index 00000000000..2ab30c98041 --- /dev/null +++ b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__slash_help_output@windows.snap @@ -0,0 +1,228 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: popup +--- + Slash Commands + + Type / to open the command popup. For commands with both a picker and an arg form, bare /command + opens the picker and /command ... runs directly. + Args use shell-style quoting; quote values with spaces. + + /help + show slash command help + Usage: + /help + + /model + choose what model and reasoning effort to use + Usage: + /model + /model [default|none|minimal|low|medium|high|xhigh] [plan-only|all-modes] + + /fast + toggle Fast mode to enable fastest inference at 2X plan usage + Usage: + /fast + /fast + + /approvals + choose what Codex is allowed to do + Usage: + /approvals + /approvals [--smart-approvals] [--confirm-full-access] + [--remember-full-access] [--confirm-world-writable] [--remember-world-writable] + [--enable-windows-sandbox=elevated|legacy] + + /permissions + choose what Codex is allowed to do + Usage: + /permissions + /permissions [--smart-approvals] [--confirm-full-access] + [--remember-full-access] [--confirm-world-writable] [--remember-world-writable] + [--enable-windows-sandbox=elevated|legacy] + + /sandbox-add-read-dir + let sandbox read a directory: /sandbox-add-read-dir + Usage: + /sandbox-add-read-dir + + /experimental + toggle experimental features + Usage: + /experimental + /experimental =on|off ... + + /skills + use skills to improve how Codex performs specific tasks + Usage: + /skills + /skills + + /review + review my current changes and find issues + Usage: + /review + /review uncommitted + /review branch + /review commit [title] + /review + + /rename + rename the current thread + Usage: + /rename + /rename + + /new + start a new chat during a conversation + Usage: + /new + + /resume + resume a saved chat + Usage: + /resume + /resume + /resume --path + + /fork + fork the current chat + Usage: + /fork + + /init + create an AGENTS.md file with instructions for Codex + Usage: + /init + + /compact + summarize conversation to prevent hitting the context limit + Usage: + /compact + + /plan + switch to Plan mode + Usage: + /plan + /plan + + /collab + change collaboration mode (experimental) + Usage: + /collab + /collab + + /agent + switch the active agent thread + Usage: + /agent + /agent + + /diff + show git diff (including untracked files) + Usage: + /diff + + /copy + copy the latest Codex output to your clipboard + Usage: + /copy + + /mention + mention a file + Usage: + /mention + + /status + show current session configuration and token usage + Usage: + /status + + /debug-config + show config layers and requirement sources for debugging + Usage: + /debug-config + + /statusline + configure which items appear in the status line + Usage: + /statusline + /statusline ... + /statusline none + + /theme + choose a syntax highlighting theme + Usage: + /theme + /theme + + /mcp + list configured MCP tools + Usage: + /mcp + + /logout + log out of Codex + Usage: + /logout + + /quit + exit Codex + Usage: + /quit + + /exit + exit Codex + Usage: + /exit + + /feedback + send logs to maintainers + Usage: + /feedback + /feedback + + /rollout + print the rollout file path + Usage: + /rollout + + /ps + list background terminals + Usage: + /ps + + /clean + stop all background terminals + Usage: + /clean + + /clear + clear the terminal and start a new chat + Usage: + /clear + + /personality + choose a communication style for Codex + Usage: + /personality + /personality + + /test-approval + test approval request + Usage: + /test-approval + + /subagents + switch the active agent thread + Usage: + /subagents + /subagents + + /debug-m-drop + DO NOT USE + Usage: + /debug-m-drop + + + ↑/↓ scroll | [ctrl + p / ctrl + n] page | / search | esc close 1-217/224 diff --git a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__slash_help_search_output.snap b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__slash_help_search_output.snap new file mode 100644 index 00000000000..c146e7b4028 --- /dev/null +++ b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__slash_help_search_output.snap @@ -0,0 +1,223 @@ +--- +source: tui_app_server/src/chatwidget/tests.rs +expression: searching +--- + Slash Commands + + Type / to open the command popup. For commands with both a picker and an arg form, bare /command + opens the picker and /command ... runs directly. + Args use shell-style quoting; quote values with spaces. + + /help + show slash command help + Usage: + /help + + /model + choose what model and reasoning effort to use + Usage: + /model + /model [default|none|minimal|low|medium|high|xhigh] [plan-only|all-modes] + + /fast + toggle Fast mode to enable fastest inference at 2X plan usage + Usage: + /fast + /fast + + /approvals + choose what Codex is allowed to do + Usage: + /approvals + /approvals [--smart-approvals] [--confirm-full-access] + [--remember-full-access] [--confirm-world-writable] [--remember-world-writable] + [--enable-windows-sandbox=elevated|legacy] + + /permissions + choose what Codex is allowed to do + Usage: + /permissions + /permissions [--smart-approvals] [--confirm-full-access] + [--remember-full-access] [--confirm-world-writable] [--remember-world-writable] + [--enable-windows-sandbox=elevated|legacy] + + /experimental + toggle experimental features + Usage: + /experimental + /experimental =on|off ... + + /skills + use skills to improve how Codex performs specific tasks + Usage: + /skills + /skills + + /review + review my current changes and find issues + Usage: + /review + /review uncommitted + /review branch + /review commit [title] + /review + + /rename + rename the current thread + Usage: + /rename + /rename + + /new + start a new chat during a conversation + Usage: + /new + + /resume + resume a saved chat + Usage: + /resume + /resume + /resume --path + + /fork + fork the current chat + Usage: + /fork + + /init + create an AGENTS.md file with instructions for Codex + Usage: + /init + + /compact + summarize conversation to prevent hitting the context limit + Usage: + /compact + + /plan + switch to Plan mode + Usage: + /plan + /plan + + /collab + change collaboration mode (experimental) + Usage: + /collab + /collab + + /agent + switch the active agent thread + Usage: + /agent + /agent + + /diff + show git diff (including untracked files) + Usage: + /diff + + /copy + copy the latest Codex output to your clipboard + Usage: + /copy + + /mention + mention a file + Usage: + /mention + + /status + show current session configuration and token usage + Usage: + /status + + /debug-config + show config layers and requirement sources for debugging + Usage: + /debug-config + + /statusline + configure which items appear in the status line + Usage: + /statusline + /statusline ... + /statusline none + + /theme + choose a syntax highlighting theme + Usage: + /theme + /theme + + /mcp + list configured MCP tools + Usage: + /mcp + + /logout + log out of Codex + Usage: + /logout + + /quit + exit Codex + Usage: + /quit + + /exit + exit Codex + Usage: + /exit + + /feedback + send logs to maintainers + Usage: + /feedback + /feedback + + /rollout + print the rollout file path + Usage: + /rollout + + /ps + list background terminals + Usage: + /ps + + /stop + stop all background terminals + Usage: + /stop + + /clear + clear the terminal and start a new chat + Usage: + /clear + + /personality + choose a communication style for Codex + Usage: + /personality + /personality + + /test-approval + test approval request + Usage: + /test-approval + + /subagents + switch the active agent thread + Usage: + /subagents + /subagents + + /debug-m-drop + DO NOT USE + Usage: + /debug-m-drop + + + Search: /maintainers | enter apply | esc cancel 1 match | 1-212/219 diff --git a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__slash_help_search_output@windows.snap b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__slash_help_search_output@windows.snap new file mode 100644 index 00000000000..3cf8b7db2d7 --- /dev/null +++ b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__slash_help_search_output@windows.snap @@ -0,0 +1,228 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: searching +--- + Slash Commands + + Type / to open the command popup. For commands with both a picker and an arg form, bare /command + opens the picker and /command ... runs directly. + Args use shell-style quoting; quote values with spaces. + + /help + show slash command help + Usage: + /help + + /model + choose what model and reasoning effort to use + Usage: + /model + /model [default|none|minimal|low|medium|high|xhigh] [plan-only|all-modes] + + /fast + toggle Fast mode to enable fastest inference at 2X plan usage + Usage: + /fast + /fast + + /approvals + choose what Codex is allowed to do + Usage: + /approvals + /approvals [--smart-approvals] [--confirm-full-access] + [--remember-full-access] [--confirm-world-writable] [--remember-world-writable] + [--enable-windows-sandbox=elevated|legacy] + + /permissions + choose what Codex is allowed to do + Usage: + /permissions + /permissions [--smart-approvals] [--confirm-full-access] + [--remember-full-access] [--confirm-world-writable] [--remember-world-writable] + [--enable-windows-sandbox=elevated|legacy] + + /sandbox-add-read-dir + let sandbox read a directory: /sandbox-add-read-dir + Usage: + /sandbox-add-read-dir + + /experimental + toggle experimental features + Usage: + /experimental + /experimental =on|off ... + + /skills + use skills to improve how Codex performs specific tasks + Usage: + /skills + /skills + + /review + review my current changes and find issues + Usage: + /review + /review uncommitted + /review branch + /review commit [title] + /review + + /rename + rename the current thread + Usage: + /rename + /rename + + /new + start a new chat during a conversation + Usage: + /new + + /resume + resume a saved chat + Usage: + /resume + /resume + /resume --path + + /fork + fork the current chat + Usage: + /fork + + /init + create an AGENTS.md file with instructions for Codex + Usage: + /init + + /compact + summarize conversation to prevent hitting the context limit + Usage: + /compact + + /plan + switch to Plan mode + Usage: + /plan + /plan + + /collab + change collaboration mode (experimental) + Usage: + /collab + /collab + + /agent + switch the active agent thread + Usage: + /agent + /agent + + /diff + show git diff (including untracked files) + Usage: + /diff + + /copy + copy the latest Codex output to your clipboard + Usage: + /copy + + /mention + mention a file + Usage: + /mention + + /status + show current session configuration and token usage + Usage: + /status + + /debug-config + show config layers and requirement sources for debugging + Usage: + /debug-config + + /statusline + configure which items appear in the status line + Usage: + /statusline + /statusline ... + /statusline none + + /theme + choose a syntax highlighting theme + Usage: + /theme + /theme + + /mcp + list configured MCP tools + Usage: + /mcp + + /logout + log out of Codex + Usage: + /logout + + /quit + exit Codex + Usage: + /quit + + /exit + exit Codex + Usage: + /exit + + /feedback + send logs to maintainers + Usage: + /feedback + /feedback + + /rollout + print the rollout file path + Usage: + /rollout + + /ps + list background terminals + Usage: + /ps + + /clean + stop all background terminals + Usage: + /clean + + /clear + clear the terminal and start a new chat + Usage: + /clear + + /personality + choose a communication style for Codex + Usage: + /personality + /personality + + /test-approval + test approval request + Usage: + /test-approval + + /subagents + switch the active agent thread + Usage: + /subagents + /subagents + + /debug-m-drop + DO NOT USE + Usage: + /debug-m-drop + + + Search: /maintainers | enter apply | esc cancel 1 match | 1-217/224 diff --git a/codex-rs/tui_app_server/src/chatwidget/tests.rs b/codex-rs/tui_app_server/src/chatwidget/tests.rs index 07770182b22..d417ccdf1cb 100644 --- a/codex-rs/tui_app_server/src/chatwidget/tests.rs +++ b/codex-rs/tui_app_server/src/chatwidget/tests.rs @@ -127,6 +127,10 @@ use pretty_assertions::assert_eq; use serial_test::serial; use std::collections::BTreeMap; use std::collections::HashSet; +#[cfg(unix)] +use std::ffi::OsString; +#[cfg(unix)] +use std::os::unix::ffi::OsStringExt; use std::path::PathBuf; use tempfile::NamedTempFile; use tempfile::tempdir; @@ -1105,7 +1109,10 @@ async fn blocked_image_restore_preserves_mention_bindings() { chat.bottom_pane.composer_local_image_paths(), vec![local_images[0].path.clone()], ); - assert_eq!(chat.bottom_pane.take_mention_bindings(), mention_bindings); + assert_eq!( + chat.bottom_pane.composer_mention_bindings(), + mention_bindings + ); let cells = drain_insert_history(&mut rx); let warning = cells @@ -1887,6 +1894,7 @@ async fn make_chatwidget_manual( retry_status_header: None, pending_status_indicator_restore: false, suppress_queue_autosend: false, + resume_queued_inputs_when_idle: false, thread_id: None, thread_name: None, forked_from: None, @@ -2021,6 +2029,19 @@ fn drain_insert_history( out } +fn run_next_serialized_slash_draft( + chat: &mut ChatWidget, + rx: &mut tokio::sync::mpsc::UnboundedReceiver, +) { + while let Ok(event) = rx.try_recv() { + if let AppEvent::HandleSlashCommandDraft(draft) = event { + chat.handle_serialized_slash_command(draft); + return; + } + } + panic!("expected serialized slash draft event"); +} + fn lines_to_single_string(lines: &[ratatui::text::Line<'static>]) -> String { let mut s = String::new(); for line in lines { @@ -2528,15 +2549,16 @@ async fn reasoning_selection_in_plan_mode_without_effort_change_does_not_open_sc assert!( events.iter().any(|event| matches!( event, - AppEvent::UpdateModel(model) if model == "gpt-5.1-codex-max" + AppEvent::HandleSlashCommandDraft(UserMessage { text, .. }) + if text == "/model gpt-5.1-codex-max medium" )), - "expected model update event; events: {events:?}" + "expected model selection event; events: {events:?}" ); assert!( events .iter() - .any(|event| matches!(event, AppEvent::UpdateReasoningEffort(Some(_)))), - "expected reasoning update event; events: {events:?}" + .all(|event| !matches!(event, AppEvent::OpenPlanReasoningScopePrompt { .. })), + "did not expect plan reasoning scope prompt event; events: {events:?}" ); } @@ -2613,15 +2635,16 @@ async fn reasoning_selection_in_plan_mode_model_switch_does_not_open_scope_promp assert!( events.iter().any(|event| matches!( event, - AppEvent::UpdateModel(model) if model == "gpt-5" + AppEvent::HandleSlashCommandDraft(UserMessage { text, .. }) + if text == "/model gpt-5 medium" )), - "expected model update event; events: {events:?}" + "expected model selection event; events: {events:?}" ); assert!( events .iter() - .any(|event| matches!(event, AppEvent::UpdateReasoningEffort(Some(_)))), - "expected reasoning update event; events: {events:?}" + .all(|event| !matches!(event, AppEvent::OpenPlanReasoningScopePrompt { .. })), + "did not expect plan reasoning scope prompt event; events: {events:?}" ); } @@ -2640,24 +2663,16 @@ async fn plan_reasoning_scope_popup_all_modes_persists_global_and_plan_override( assert!( events.iter().any(|event| matches!( event, - AppEvent::UpdatePlanModeReasoningEffort(Some(ReasoningEffortConfig::High)) - )), - "expected plan override to be updated; events: {events:?}" - ); - assert!( - events.iter().any(|event| matches!( - event, - AppEvent::PersistPlanModeReasoningEffort(Some(ReasoningEffortConfig::High)) + AppEvent::HandleSlashCommandDraft(UserMessage { text, .. }) + if text == "/model gpt-5.1-codex-max high all-modes" )), - "expected updated plan override to be persisted; events: {events:?}" + "expected all-modes model selection event; events: {events:?}" ); assert!( - events.iter().any(|event| matches!( - event, - AppEvent::PersistModelSelection { model, effort: Some(ReasoningEffortConfig::High) } - if model == "gpt-5.1-codex-max" - )), - "expected global model reasoning selection persistence; events: {events:?}" + events + .iter() + .all(|event| !matches!(event, AppEvent::PersistPlanModeReasoningEffort(_))), + "did not expect persistence events before app handling; events: {events:?}" ); } @@ -2847,9 +2862,10 @@ async fn plan_reasoning_scope_popup_plan_only_does_not_update_all_modes_reasonin assert!( events.iter().any(|event| matches!( event, - AppEvent::UpdatePlanModeReasoningEffort(Some(ReasoningEffortConfig::High)) + AppEvent::HandleSlashCommandDraft(UserMessage { text, .. }) + if text == "/model gpt-5.1-codex-max high plan-only" )), - "expected plan-only reasoning update; events: {events:?}" + "expected plan-only model selection event; events: {events:?}" ); assert!( events @@ -4574,7 +4590,10 @@ async fn manual_interrupt_restores_pending_steer_mention_bindings_to_composer() chat.on_interrupted_turn(TurnAbortReason::Interrupted); assert_eq!(chat.bottom_pane.composer_text(), "please use $figma"); - assert_eq!(chat.bottom_pane.take_mention_bindings(), mention_bindings); + assert_eq!( + chat.bottom_pane.composer_mention_bindings(), + mention_bindings + ); assert_no_submit_op(&mut op_rx); } @@ -5357,6 +5376,56 @@ async fn slash_init_skips_when_project_doc_exists() { ); } +#[tokio::test] +async fn queued_init_replay_stops_after_submitting_user_turn() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; + let tempdir = tempdir().expect("tempdir"); + let configured = codex_protocol::protocol::SessionConfiguredEvent { + session_id: ThreadId::new(), + forked_from_id: None, + thread_name: None, + model: "test-model".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: ApprovalsReviewer::User, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + cwd: tempdir.path().to_path_buf(), + reasoning_effort: Some(ReasoningEffortConfig::default()), + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: None, + }; + chat.handle_codex_event(Event { + id: "configured".into(), + msg: EventMsg::SessionConfigured(configured), + }); + + chat.queued_user_messages.push_back("/init".into()); + chat.queued_user_messages.push_back("after init".into()); + + chat.drain_queued_inputs_until_blocked(); + + let items = match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => items, + other => panic!("expected Op::UserTurn, got {other:?}"), + }; + assert_eq!( + items, + vec![UserInput::Text { + text: include_str!("../../prompt_for_init_command.md").to_string(), + text_elements: Vec::new(), + }] + ); + assert_eq!( + chat.queued_user_message_texts(), + vec!["after init".to_string()] + ); + assert_no_submit_op(&mut op_rx); +} + #[tokio::test] async fn collab_mode_shift_tab_cycles_only_when_idle() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; @@ -5450,11 +5519,7 @@ async fn collab_slash_command_opens_picker_and_updates_mode() { ); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); - let selected_mask = match rx.try_recv() { - Ok(AppEvent::UpdateCollaborationMode(mask)) => mask, - other => panic!("expected UpdateCollaborationMode event, got {other:?}"), - }; - chat.set_collaboration_mask(selected_mask); + run_next_serialized_slash_draft(&mut chat, &mut rx); chat.bottom_pane .set_composer_text("hello".to_string(), Vec::new(), Vec::new()); @@ -5558,6 +5623,109 @@ async fn plan_slash_command_with_args_submits_prompt_in_plan_mode() { assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan); } +#[tokio::test] +async fn queued_plan_replay_stops_after_submitting_user_turn() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::CollaborationModes, true); + + let configured = codex_protocol::protocol::SessionConfiguredEvent { + session_id: ThreadId::new(), + forked_from_id: None, + thread_name: None, + model: "test-model".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: ApprovalsReviewer::User, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + cwd: PathBuf::from("/home/user/project"), + reasoning_effort: Some(ReasoningEffortConfig::default()), + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: None, + }; + chat.handle_codex_event(Event { + id: "configured".into(), + msg: EventMsg::SessionConfigured(configured), + }); + + chat.queued_user_messages + .push_back("/plan build the plan".into()); + chat.queued_user_messages + .push_back("after plan replay".into()); + + chat.drain_queued_inputs_until_blocked(); + + let items = match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => items, + other => panic!("expected Op::UserTurn, got {other:?}"), + }; + assert_eq!( + items, + vec![UserInput::Text { + text: "build the plan".to_string(), + text_elements: Vec::new(), + }] + ); + assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan); + assert_eq!( + chat.queued_user_message_texts(), + vec!["after plan replay".to_string()] + ); + assert_no_submit_op(&mut op_rx); +} + +#[tokio::test] +async fn queued_resume_picker_selection_replays_exact_targets_in_order() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + chat.bottom_pane.set_task_running(true); + + let thread_id = ThreadId::new(); + let first = crate::resume_picker::SessionTarget { + path: Some(PathBuf::from("/tmp/first.rollout")), + thread_id, + }; + let second = crate::resume_picker::SessionTarget { + path: Some(PathBuf::from("/tmp/second.rollout")), + thread_id, + }; + + chat.handle_serialized_slash_command(ChatWidget::resume_selection_draft(&first)); + chat.handle_serialized_slash_command(ChatWidget::resume_selection_draft(&second)); + + chat.bottom_pane.set_task_running(false); + chat.drain_queued_inputs_until_blocked(); + assert_matches!(rx.try_recv(), Ok(AppEvent::ResumeSessionTarget(target)) if target == first); + + chat.drain_queued_inputs_until_blocked(); + assert_matches!(rx.try_recv(), Ok(AppEvent::ResumeSessionTarget(target)) if target == second); +} + +#[cfg(unix)] +#[tokio::test] +async fn queued_resume_picker_selection_preserves_non_utf8_path() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + chat.bottom_pane.set_task_running(true); + + let target = crate::resume_picker::SessionTarget { + path: Some(PathBuf::from(OsString::from_vec( + b"/tmp/non-utf8-\xff.rollout".to_vec(), + ))), + thread_id: ThreadId::new(), + }; + + let draft = ChatWidget::resume_selection_draft(&target); + assert!(draft.text.contains("--path-base64")); + + chat.handle_serialized_slash_command(draft); + chat.bottom_pane.set_task_running(false); + chat.drain_queued_inputs_until_blocked(); + + assert_matches!(rx.try_recv(), Ok(AppEvent::ResumeSessionTarget(replayed)) if replayed == target); +} + #[tokio::test] async fn collaboration_modes_defaults_to_code_on_startup() { let codex_home = tempdir().expect("tempdir"); @@ -5833,6 +6001,183 @@ async fn slash_copy_reports_when_no_copyable_output_exists() { ); } +#[tokio::test] +async fn slash_help_opens_reference_popup() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.dispatch_command(SlashCommand::Help); + + assert!(chat.bottom_pane.has_active_view()); + let popup = render_bottom_popup(&chat, 100); + if cfg!(target_os = "windows") { + insta::with_settings!({ snapshot_suffix => "windows" }, { + assert_snapshot!("slash_help_output", popup); + }); + } else { + assert_snapshot!("slash_help_output", popup); + } + assert!(popup.contains("/help")); + assert!(popup.contains("/model ")); + + let mut scrolled = popup; + for _ in 0..8 { + if scrolled.contains("/review ") { + break; + } + chat.handle_key_event(KeyEvent::from(KeyCode::PageDown)); + scrolled = render_bottom_popup(&chat, 100); + } + assert!(scrolled.contains("/review ")); +} + +#[tokio::test] +async fn slash_help_with_whitespace_only_args_clears_composer() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.bottom_pane + .set_composer_text("/help ".to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert!(chat.bottom_pane.has_active_view()); + assert_eq!(chat.bottom_pane.composer_text(), ""); + assert!(chat.remote_image_urls().is_empty()); +} + +#[tokio::test] +async fn slash_help_search_jumps_to_lower_match() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.dispatch_command(SlashCommand::Help); + let _ = render_bottom_popup(&chat, 100); + + chat.handle_key_event(KeyEvent::from(KeyCode::Char('/'))); + for ch in "maintainers".chars() { + chat.handle_key_event(KeyEvent::from(KeyCode::Char(ch))); + } + let searching = render_bottom_popup(&chat, 100); + if cfg!(target_os = "windows") { + insta::with_settings!({ snapshot_suffix => "windows" }, { + assert_snapshot!("slash_help_search_output", searching); + }); + } else { + assert_snapshot!("slash_help_search_output", searching); + } + assert!(searching.contains("Search: /maintainers")); + assert!(searching.contains("1 match")); + + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + let popup = render_bottom_popup(&chat, 100); + assert!(popup.contains("/feedback")); + assert!(popup.contains("n/p match")); +} + +#[tokio::test] +async fn slash_help_search_navigates_matches_with_n_and_p() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.dispatch_command(SlashCommand::Help); + let _ = render_bottom_popup(&chat, 100); + + chat.handle_key_event(KeyEvent::from(KeyCode::Char('/'))); + for ch in "show".chars() { + chat.handle_key_event(KeyEvent::from(KeyCode::Char(ch))); + } + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + let first = render_bottom_popup(&chat, 100); + assert!(first.contains("n/p match")); + assert!(first.contains("/help")); + + chat.handle_key_event(KeyEvent::from(KeyCode::Char('n'))); + let second = render_bottom_popup(&chat, 100); + assert!(second.contains("/diff")); + + chat.handle_key_event(KeyEvent::from(KeyCode::Char('n'))); + let third = render_bottom_popup(&chat, 100); + assert!(third.contains("/status")); + + chat.handle_key_event(KeyEvent::from(KeyCode::Char('p'))); + let previous = render_bottom_popup(&chat, 100); + assert!(previous.contains("/diff")); + + chat.handle_key_event(KeyEvent::new(KeyCode::Char('N'), KeyModifiers::SHIFT)); + let shifted_previous = render_bottom_popup(&chat, 100); + assert!(shifted_previous.contains("/help")); +} + +#[tokio::test] +async fn slash_help_search_restarts_from_empty_input() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.dispatch_command(SlashCommand::Help); + chat.handle_key_event(KeyEvent::from(KeyCode::Char('/'))); + for ch in "maintainers".chars() { + chat.handle_key_event(KeyEvent::from(KeyCode::Char(ch))); + } + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + let active = render_bottom_popup(&chat, 100); + assert!(active.contains("n/p match")); + + chat.handle_key_event(KeyEvent::from(KeyCode::Char('/'))); + let restarted = render_bottom_popup(&chat, 100); + assert!(restarted.contains("Search: /")); + assert!(!restarted.contains("Search: /maintainers")); + assert!(!restarted.contains("1/1 |")); +} + +#[tokio::test] +async fn slash_help_esc_dismisses_popup() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.dispatch_command(SlashCommand::Help); + assert!(chat.bottom_pane.has_active_view()); + + chat.handle_key_event(KeyEvent::from(KeyCode::Esc)); + + assert!(!chat.bottom_pane.has_active_view()); +} + +#[tokio::test] +async fn slash_help_esc_clears_active_search_before_dismissing_popup() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.dispatch_command(SlashCommand::Help); + chat.handle_key_event(KeyEvent::from(KeyCode::Char('/'))); + for ch in "toggle".chars() { + chat.handle_key_event(KeyEvent::from(KeyCode::Char(ch))); + } + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + let active = render_bottom_popup(&chat, 100); + assert!(active.contains("n/p match")); + assert!(chat.bottom_pane.has_active_view()); + + chat.handle_key_event(KeyEvent::from(KeyCode::Esc)); + + let cleared = render_bottom_popup(&chat, 100); + assert!(chat.bottom_pane.has_active_view()); + assert!(!cleared.contains("1/3 |")); + assert!(!cleared.contains("n/p match")); + + chat.handle_key_event(KeyEvent::from(KeyCode::Esc)); + + assert!(!chat.bottom_pane.has_active_view()); +} + +#[tokio::test] +async fn slash_help_q_dismisses_popup() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.dispatch_command(SlashCommand::Help); + assert!(chat.bottom_pane.has_active_view()); + + chat.handle_key_event(KeyEvent::from(KeyCode::Char('q'))); + + assert!(!chat.bottom_pane.has_active_view()); +} + #[tokio::test] async fn slash_copy_state_is_preserved_during_running_task() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; @@ -6001,24 +6346,19 @@ async fn slash_clear_requests_ui_clear_when_idle() { } #[tokio::test] -async fn slash_clear_is_disabled_while_task_running() { +async fn slash_clear_queues_while_task_running() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - chat.bottom_pane.set_task_running(true); + chat.on_task_started(); chat.dispatch_command(SlashCommand::Clear); - let event = rx.try_recv().expect("expected disabled command error"); - match event { - AppEvent::InsertHistoryCell(cell) => { - let rendered = lines_to_single_string(&cell.display_lines(80)); - assert!( - rendered.contains("'/clear' is disabled while a task is in progress."), - "expected /clear task-running error, got {rendered:?}" - ); - } - other => panic!("expected InsertHistoryCell error, got {other:?}"), - } - assert!(rx.try_recv().is_err(), "expected no follow-up events"); + assert_eq!(chat.queued_user_message_texts(), vec!["/clear".to_string()]); + assert!(rx.try_recv().is_err(), "expected no immediate app events"); + + chat.on_task_complete(None, false); + + assert!(chat.queued_user_messages.is_empty()); + assert_matches!(rx.try_recv(), Ok(AppEvent::ClearUi)); } #[tokio::test] @@ -6270,7 +6610,7 @@ async fn review_commit_picker_shows_subjects_without_timestamps() { /// Submitting the custom prompt view sends Op::Review with the typed prompt /// and uses the same text for the user-facing hint. #[tokio::test] -async fn custom_prompt_submit_sends_review_op() { +async fn custom_prompt_submit_serializes_review_draft() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; chat.show_review_custom_prompt(); @@ -6278,18 +6618,18 @@ async fn custom_prompt_submit_sends_review_op() { chat.handle_paste(" please audit dependencies ".to_string()); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - // Expect AppEvent::CodexOp(Op::Review { .. }) with trimmed prompt + // Expect a serialized /review draft with trimmed prompt. let evt = rx.try_recv().expect("expected one app event"); match evt { - AppEvent::CodexOp(Op::Review { review_request }) => { + AppEvent::HandleSlashCommandDraft(UserMessage { text, .. }) => { assert_eq!( - review_request, - ReviewRequest { - target: ReviewTarget::Custom { - instructions: "please audit dependencies".to_string(), - }, - user_facing_hint: None, - } + text, + SlashCommandInvocation::with_args( + SlashCommand::Review, + ["please audit dependencies"], + ) + .into_user_message() + .text ); } other => panic!("unexpected app event: {other:?}"), @@ -7429,13 +7769,13 @@ async fn experimental_features_popup_snapshot() { let features = vec![ ExperimentalFeatureItem { - feature: Feature::GhostCommit, + key: Feature::GhostCommit.key().to_string(), name: "Ghost snapshots".to_string(), description: "Capture undo snapshots each turn.".to_string(), enabled: false, }, ExperimentalFeatureItem { - feature: Feature::ShellTool, + key: Feature::ShellTool.key().to_string(), name: "Shell tool".to_string(), description: "Allow the model to run shell commands.".to_string(), enabled: true, @@ -7455,7 +7795,7 @@ async fn experimental_features_toggle_saves_on_exit() { let expected_feature = Feature::GhostCommit; let view = ExperimentalFeaturesView::new( vec![ExperimentalFeatureItem { - feature: expected_feature, + key: expected_feature.key().to_string(), name: "Ghost snapshots".to_string(), description: "Capture undo snapshots each turn.".to_string(), enabled: false, @@ -7472,6 +7812,7 @@ async fn experimental_features_toggle_saves_on_exit() { ); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + run_next_serialized_slash_draft(&mut chat, &mut rx); let mut updates = None; while let Ok(event) = rx.try_recv() { @@ -7626,7 +7967,7 @@ async fn realtime_microphone_picker_popup_snapshot() { #[cfg(all(not(target_os = "linux"), feature = "voice-input"))] #[tokio::test] -async fn realtime_audio_picker_emits_persist_event() { +async fn realtime_audio_picker_emits_serialized_slash_draft() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2-codex")).await; chat.open_realtime_audio_device_selection_with_names( RealtimeAudioDeviceKind::Speaker, @@ -7639,10 +7980,8 @@ async fn realtime_audio_picker_emits_persist_event() { assert_matches!( rx.try_recv(), - Ok(AppEvent::PersistRealtimeAudioDeviceSelection { - kind: RealtimeAudioDeviceKind::Speaker, - name: Some(name), - }) if name == "Headphones" + Ok(AppEvent::HandleSlashCommandDraft(UserMessage { text, .. })) + if text == "/settings speaker Headphones" ); } @@ -7809,7 +8148,7 @@ async fn windows_auto_mode_prompt_requests_enabling_sandbox_feature() { .into_iter() .find(|preset| preset.id == "auto") .expect("auto preset"); - chat.open_windows_sandbox_enable_prompt(preset); + chat.open_windows_sandbox_enable_prompt(preset, ApprovalsReviewer::User); let popup = render_bottom_popup(&chat, 120); assert!( @@ -7951,24 +8290,114 @@ async fn single_reasoning_option_skips_selection() { } assert!( - events - .iter() - .any(|ev| matches!(ev, AppEvent::UpdateReasoningEffort(Some(effort)) if *effort == ReasoningEffortConfig::High)), + events.iter().any(|event| matches!( + event, + AppEvent::HandleSlashCommandDraft(UserMessage { text, .. }) + if text == "/model model-with-single-reasoning high" + )), "expected reasoning effort to be applied automatically; events: {events:?}" ); } -#[tokio::test] -async fn feedback_selection_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; +#[test] +fn model_parser_rejects_repeated_effort_when_first_token_is_default() { + let parsed = ChatWidget::parse_model_selection_args(&[ + "gpt-5".to_string(), + "default".to_string(), + "high".to_string(), + ]); - // Open the feedback category selection popup via slash command. - chat.dispatch_command(SlashCommand::Feedback); + assert_eq!( + parsed, + Err( + "Usage: /model [default|none|minimal|low|medium|high|xhigh] [plan-only|all-modes]" + .to_string(), + ) + ); +} + +#[test] +fn model_parser_rejects_repeated_scope_when_first_token_is_global() { + let parsed = ChatWidget::parse_model_selection_args(&[ + "gpt-5".to_string(), + "global".to_string(), + "plan-only".to_string(), + ]); + + assert_eq!( + parsed, + Err( + "Usage: /model [default|none|minimal|low|medium|high|xhigh] [plan-only|all-modes]" + .to_string(), + ) + ); +} + +#[tokio::test] +async fn feedback_selection_popup_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + + // Open the feedback category selection popup via slash command. + chat.dispatch_command(SlashCommand::Feedback); let popup = render_bottom_popup(&chat, 80); assert_snapshot!("feedback_selection_popup", popup); } +#[tokio::test] +async fn feedback_selection_popup_emits_serialized_slash_draft() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.dispatch_command(SlashCommand::Feedback); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_matches!( + rx.try_recv(), + Ok(AppEvent::HandleSlashCommandDraft(UserMessage { text, .. })) if text == "/feedback bug" + ); + assert_matches!(rx.try_recv(), Ok(AppEvent::BottomPaneViewCompleted)); +} + +#[tokio::test] +async fn feedback_inline_args_respect_feedback_disabled_flag() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.config.feedback_enabled = false; + + chat.handle_serialized_slash_command(UserMessage::from("/feedback bug".to_string())); + + let popup = render_bottom_popup(&chat, 80); + assert!(popup.contains("Sending feedback is disabled")); + assert!(popup.contains("This action is disabled by configuration.")); + assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); + assert_no_submit_op(&mut op_rx); +} + +#[tokio::test] +async fn skills_menu_emits_serialized_slash_drafts() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.open_skills_menu(); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_matches!( + rx.try_recv(), + Ok(AppEvent::HandleSlashCommandDraft(UserMessage { text, .. })) + if text == "/skills list" + ); + assert_matches!(rx.try_recv(), Ok(AppEvent::BottomPaneViewCompleted)); + + chat.open_skills_menu(); + chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_matches!( + rx.try_recv(), + Ok(AppEvent::HandleSlashCommandDraft(UserMessage { text, .. })) + if text == "/skills manage" + ); + assert_matches!(rx.try_recv(), Ok(AppEvent::BottomPaneViewCompleted)); +} + #[tokio::test] async fn feedback_upload_consent_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; @@ -8134,22 +8563,31 @@ async fn bang_shell_command_is_disabled_in_app_server_tui() { } #[tokio::test] -async fn disabled_slash_command_while_task_running_snapshot() { - // Build a chat widget and simulate an active task - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - chat.bottom_pane.set_task_running(true); +async fn model_slash_command_while_task_running_opens_popup_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex")).await; + chat.thread_id = Some(ThreadId::new()); + chat.on_task_started(); - // Dispatch a command that is unavailable while a task runs (e.g., /model) chat.dispatch_command(SlashCommand::Model); - // Drain history and snapshot the rendered error line(s) - let cells = drain_insert_history(&mut rx); - assert!( - !cells.is_empty(), - "expected an error message history cell to be emitted", + assert!(chat.queued_user_messages.is_empty()); + assert!(chat.has_active_view(), "expected /model popup to open"); + assert!(drain_insert_history(&mut rx).is_empty()); + + let width: u16 = 80; + let height: u16 = 18; + let backend = VT100Backend::new(width, height); + let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); + let desired_height = chat.desired_height(width).min(height); + term.set_viewport_area(Rect::new(0, height - desired_height, width, desired_height)); + term.draw(|f| { + chat.render(f.area(), f.buffer_mut()); + }) + .unwrap(); + assert_snapshot!( + "model_slash_command_while_task_running_popup", + term.backend().vt100().screen().contents() ); - let blob = lines_to_single_string(cells.last().unwrap()); - assert_snapshot!(blob); } #[tokio::test] @@ -8315,6 +8753,7 @@ async fn approvals_popup_navigation_skips_disabled() { // Press Enter; selection should land on an enabled preset and dispatch updates. chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + run_next_serialized_slash_draft(&mut chat, &mut rx); let mut app_events = Vec::new(); while let Ok(ev) = rx.try_recv() { app_events.push(ev); @@ -8356,6 +8795,7 @@ async fn permissions_selection_emits_history_cell_when_selection_changes() { chat.open_permissions_popup(); chat.handle_key_event(KeyEvent::from(KeyCode::Down)); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + run_next_serialized_slash_draft(&mut chat, &mut rx); let cells = drain_insert_history(&mut rx); assert_eq!( @@ -8385,6 +8825,7 @@ async fn permissions_selection_history_snapshot_after_mode_switch() { #[cfg(target_os = "windows")] chat.handle_key_event(KeyEvent::from(KeyCode::Down)); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + run_next_serialized_slash_draft(&mut chat, &mut rx); let cells = drain_insert_history(&mut rx); assert_eq!(cells.len(), 1, "expected one mode-switch history cell"); @@ -8421,6 +8862,7 @@ async fn permissions_selection_history_snapshot_full_access_to_default() { chat.handle_key_event(KeyEvent::from(KeyCode::Up)); } chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + run_next_serialized_slash_draft(&mut chat, &mut rx); let cells = drain_insert_history(&mut rx); assert_eq!(cells.len(), 1, "expected one mode-switch history cell"); @@ -8459,6 +8901,7 @@ async fn permissions_selection_emits_history_cell_when_current_is_selected() { chat.open_permissions_popup(); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + run_next_serialized_slash_draft(&mut chat, &mut rx); let cells = drain_insert_history(&mut rx); assert_eq!( @@ -8633,6 +9076,7 @@ async fn permissions_selection_can_disable_guardian_approvals() { } chat.config.notices.hide_full_access_warning = Some(true); chat.set_feature_enabled(Feature::GuardianApproval, true); + chat.config.approvals_reviewer = ApprovalsReviewer::GuardianSubagent; chat.config .permissions .approval_policy @@ -8650,11 +9094,14 @@ async fn permissions_selection_can_disable_guardian_approvals() { let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); assert!( - events.iter().any(|event| matches!( - event, - AppEvent::UpdateApprovalsReviewer(ApprovalsReviewer::User) - )), - "expected selecting Default from Guardian Approvals to switch back to manual approval review: {events:?}" + events.iter().any(|event| { + matches!( + event, + AppEvent::HandleSlashCommandDraft(draft) + if *draft == ChatWidget::approval_preset_draft("auto", &[]) + ) + }), + "expected selecting Default from Guardian Approvals to queue the default approvals draft: {events:?}" ); assert!( !events @@ -8665,7 +9112,7 @@ async fn permissions_selection_can_disable_guardian_approvals() { } #[tokio::test] -async fn permissions_selection_sends_approvals_reviewer_in_override_turn_context() { +async fn permissions_selection_emits_smart_approvals_draft_before_replay() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; #[cfg(target_os = "windows")] { @@ -8705,12 +9152,26 @@ async fn permissions_selection_sends_approvals_reviewer_in_override_turn_context ); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + let draft = match rx.try_recv() { + Ok(AppEvent::HandleSlashCommandDraft(draft)) => draft, + other => panic!("expected serialized smart approvals draft, got {other:?}"), + }; + assert_eq!( + draft, + ChatWidget::approval_preset_draft_for_reviewer( + "auto", + ApprovalsReviewer::GuardianSubagent, + &[], + ) + ); + chat.handle_serialized_slash_command(draft); + let op = std::iter::from_fn(|| rx.try_recv().ok()) .find_map(|event| match event { AppEvent::CodexOp(op @ Op::OverrideTurnContext { .. }) => Some(op), _ => None, }) - .expect("expected OverrideTurnContext op"); + .expect("expected OverrideTurnContext op after replay"); assert_eq!( op, @@ -8779,6 +9240,7 @@ async fn permissions_full_access_history_cell_emitted_only_after_confirmation() ); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + run_next_serialized_slash_draft(&mut chat, &mut rx); let cells_after_confirmation = drain_insert_history(&mut rx); let total_history_cells = cells_before_confirmation.len() + cells_after_confirmation.len(); assert_eq!( @@ -11055,6 +11517,445 @@ async fn enter_queues_user_messages_while_review_is_running() { assert!(drain_insert_history(&mut rx).is_empty()); } +#[tokio::test] +async fn review_slash_command_opens_popup_while_task_running() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + chat.on_task_started(); + + chat.dispatch_command(SlashCommand::Review); + + assert!(chat.queued_user_messages.is_empty()); + assert!(chat.has_active_view(), "expected /review popup to open"); + assert!(drain_insert_history(&mut rx).is_empty()); +} + +#[tokio::test] +async fn review_slash_command_with_args_queues_while_task_running_and_submits_after_turn_complete() +{ + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.on_task_started(); + chat.bottom_pane.set_composer_text( + "/review audit dependency changes".to_string(), + Vec::new(), + Vec::new(), + ); + + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!( + chat.queued_user_message_texts(), + vec!["/review audit dependency changes".to_string()] + ); + assert_no_submit_op(&mut op_rx); + + chat.on_task_complete(None, false); + + loop { + match op_rx.try_recv() { + Ok(Op::Review { review_request }) => { + assert_eq!( + review_request, + ReviewRequest { + target: ReviewTarget::Custom { + instructions: "audit dependency changes".to_string(), + }, + user_facing_hint: None, + } + ); + break; + } + Ok(_) => continue, + Err(TryRecvError::Empty) => panic!("expected queued /review op"), + Err(TryRecvError::Disconnected) => panic!("expected queued /review op"), + } + } +} + +#[tokio::test] +async fn queued_review_selection_replays_after_turn_complete() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.on_task_started(); + + chat.handle_serialized_slash_command(ChatWidget::review_request_draft(&ReviewRequest { + target: ReviewTarget::BaseBranch { + branch: "main".to_string(), + }, + user_facing_hint: None, + })); + + assert_eq!( + chat.queued_user_message_texts(), + vec!["/review branch main".to_string()] + ); + + chat.on_task_complete(None, false); + + loop { + match op_rx.try_recv() { + Ok(Op::Review { review_request }) => { + assert_eq!( + review_request, + ReviewRequest { + target: ReviewTarget::BaseBranch { + branch: "main".to_string(), + }, + user_facing_hint: None, + } + ); + break; + } + Ok(_) => continue, + Err(TryRecvError::Empty) => panic!("expected queued /review branch op"), + Err(TryRecvError::Disconnected) => panic!("expected queued /review branch op"), + } + } +} + +#[tokio::test] +async fn queued_bare_theme_command_restores_to_composer_instead_of_opening_popup() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.on_task_started(); + chat.queued_user_messages + .push_back(UserMessage::from("/theme".to_string())); + + chat.on_task_complete(None, false); + + assert_eq!(chat.bottom_pane.composer_text(), "/theme"); + assert!(!chat.has_active_view(), "expected no popup during replay"); + assert!(chat.queued_user_messages.is_empty()); + assert_no_submit_op(&mut op_rx); + assert!( + !drain_insert_history(&mut rx).is_empty(), + "expected replay failure to be surfaced to the user" + ); +} + +#[tokio::test] +async fn queued_theme_selection_resumes_followup_after_idle_resume() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.handle_codex_event(Event { + id: "configured".into(), + msg: EventMsg::SessionConfigured(SessionConfiguredEvent { + session_id: ThreadId::new(), + forked_from_id: None, + thread_name: None, + model: "test-model".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: ApprovalsReviewer::User, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + cwd: PathBuf::from("/home/user/project"), + reasoning_effort: Some(ReasoningEffortConfig::default()), + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: None, + }), + }); + chat.on_task_started(); + + chat.handle_serialized_slash_command(UserMessage::from("/theme ansi".to_string())); + chat.queued_user_messages + .push_back(UserMessage::from("followup".to_string())); + + chat.on_task_complete(None, false); + + assert_eq!( + chat.queued_user_message_texts(), + vec!["followup".to_string()] + ); + assert!(chat.resume_queued_inputs_when_idle); + loop { + match rx.try_recv() { + Ok(AppEvent::SyntaxThemeSelected { name }) => { + assert_eq!(name, "ansi"); + break; + } + Ok(_) => continue, + Err(TryRecvError::Empty) => panic!("expected AppEvent::SyntaxThemeSelected"), + Err(TryRecvError::Disconnected) => { + panic!("expected AppEvent::SyntaxThemeSelected") + } + } + } + assert_no_submit_op(&mut op_rx); + + chat.maybe_resume_queued_inputs_when_idle(); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "followup".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected Op::UserTurn, got {other:?}"), + } + assert!(chat.queued_user_messages.is_empty()); + assert!(!chat.resume_queued_inputs_when_idle); +} + +#[tokio::test] +async fn queued_personality_selection_resumes_followup_after_idle_resume() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.2-codex")).await; + chat.set_feature_enabled(Feature::Personality, true); + chat.handle_codex_event(Event { + id: "configured".into(), + msg: EventMsg::SessionConfigured(SessionConfiguredEvent { + session_id: ThreadId::new(), + forked_from_id: None, + thread_name: None, + model: "gpt-5.2-codex".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: ApprovalsReviewer::User, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + cwd: PathBuf::from("/home/user/project"), + reasoning_effort: Some(ReasoningEffortConfig::default()), + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: None, + }), + }); + chat.on_task_started(); + + chat.handle_serialized_slash_command(UserMessage::from("/personality pragmatic".to_string())); + chat.queued_user_messages + .push_back(UserMessage::from("followup".to_string())); + + chat.on_task_complete(None, false); + + assert_eq!( + chat.queued_user_message_texts(), + vec!["followup".to_string()] + ); + assert!(chat.resume_queued_inputs_when_idle); + assert_no_submit_op(&mut op_rx); + + loop { + match rx.try_recv() { + Ok(AppEvent::CodexOp(Op::OverrideTurnContext { + personality: Some(Personality::Pragmatic), + .. + })) => continue, + Ok(AppEvent::UpdatePersonality(Personality::Pragmatic)) => { + chat.set_personality(Personality::Pragmatic); + break; + } + Ok(AppEvent::PersistPersonalitySelection { + personality: Personality::Pragmatic, + }) => continue, + Ok(_) => continue, + Err(TryRecvError::Empty) => panic!("expected personality update events"), + Err(TryRecvError::Disconnected) => panic!("expected personality update events"), + } + } + + assert_no_submit_op(&mut op_rx); + + chat.maybe_resume_queued_inputs_when_idle(); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { + items, + personality: Some(Personality::Pragmatic), + .. + } => assert_eq!( + items, + vec![UserInput::Text { + text: "followup".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected Op::UserTurn with pragmatic personality, got {other:?}"), + } + assert!(chat.queued_user_messages.is_empty()); + assert!(!chat.resume_queued_inputs_when_idle); +} + +#[tokio::test] +async fn queued_followup_waits_for_popup_dismissal_before_idle_resume() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.handle_codex_event(Event { + id: "configured".into(), + msg: EventMsg::SessionConfigured(SessionConfiguredEvent { + session_id: ThreadId::new(), + forked_from_id: None, + thread_name: None, + model: "test-model".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: ApprovalsReviewer::User, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + cwd: PathBuf::from("/home/user/project"), + reasoning_effort: Some(ReasoningEffortConfig::default()), + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: None, + }), + }); + chat.queued_user_messages + .push_back(UserMessage::from("/theme ansi".to_string())); + chat.queued_user_messages + .push_back(UserMessage::from("followup".to_string())); + + chat.drain_queued_inputs_until_blocked(); + + assert_eq!( + chat.queued_user_message_texts(), + vec!["followup".to_string()] + ); + assert!(chat.resume_queued_inputs_when_idle); + loop { + match rx.try_recv() { + Ok(AppEvent::SyntaxThemeSelected { name }) => { + assert_eq!(name, "ansi"); + break; + } + Ok(_) => continue, + Err(TryRecvError::Empty) => panic!("expected AppEvent::SyntaxThemeSelected"), + Err(TryRecvError::Disconnected) => { + panic!("expected AppEvent::SyntaxThemeSelected") + } + } + } + + chat.open_feedback_consent(crate::app_event::FeedbackCategory::Bug); + chat.maybe_resume_queued_inputs_when_idle(); + + assert_eq!( + chat.queued_user_message_texts(), + vec!["followup".to_string()] + ); + assert_no_submit_op(&mut op_rx); + + let _ = chat + .bottom_pane + .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(!chat.has_active_view()); + assert_matches!(rx.try_recv(), Ok(AppEvent::BottomPaneViewCompleted)); + + chat.maybe_resume_queued_inputs_when_idle(); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "followup".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected Op::UserTurn, got {other:?}"), + } + assert!(chat.queued_user_messages.is_empty()); + assert!(!chat.resume_queued_inputs_when_idle); +} + +#[tokio::test] +async fn queued_custom_review_selection_preserves_branch_like_instructions_after_turn_complete() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.on_task_started(); + + chat.handle_serialized_slash_command(ChatWidget::review_request_draft(&ReviewRequest { + target: ReviewTarget::Custom { + instructions: "branch main but focus on risky migrations".to_string(), + }, + user_facing_hint: None, + })); + + assert_eq!( + chat.queued_user_message_texts(), + vec![ + SlashCommandInvocation::with_args( + SlashCommand::Review, + ["branch main but focus on risky migrations"], + ) + .into_user_message() + .text, + ] + ); + + chat.on_task_complete(None, false); + + loop { + match op_rx.try_recv() { + Ok(Op::Review { review_request }) => { + assert_eq!( + review_request, + ReviewRequest { + target: ReviewTarget::Custom { + instructions: "branch main but focus on risky migrations".to_string(), + }, + user_facing_hint: None, + } + ); + break; + } + Ok(_) => continue, + Err(TryRecvError::Empty) => panic!("expected queued custom /review op"), + Err(TryRecvError::Disconnected) => panic!("expected queued custom /review op"), + } + } +} + +#[tokio::test] +async fn queued_commit_review_selection_preserves_title_after_turn_complete() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.on_task_started(); + + chat.handle_serialized_slash_command(ChatWidget::review_request_draft(&ReviewRequest { + target: ReviewTarget::Commit { + sha: "abc123".to_string(), + title: Some("Preserve commit subject".to_string()), + }, + user_facing_hint: None, + })); + + assert_eq!( + chat.queued_user_message_texts(), + vec![ + SlashCommandInvocation::with_args( + SlashCommand::Review, + ["commit", "abc123", "Preserve commit subject"], + ) + .into_user_message() + .text, + ] + ); + + chat.on_task_complete(None, false); + + loop { + match op_rx.try_recv() { + Ok(Op::Review { review_request }) => { + assert_eq!( + review_request, + ReviewRequest { + target: ReviewTarget::Commit { + sha: "abc123".to_string(), + title: Some("Preserve commit subject".to_string()), + }, + user_facing_hint: None, + } + ); + break; + } + Ok(_) => continue, + Err(TryRecvError::Empty) => panic!("expected queued /review commit op"), + Err(TryRecvError::Disconnected) => panic!("expected queued /review commit op"), + } + } +} + #[tokio::test] async fn review_queues_user_messages_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; diff --git a/codex-rs/tui_app_server/src/lib.rs b/codex-rs/tui_app_server/src/lib.rs index 0546d88487a..24761a69910 100644 --- a/codex-rs/tui_app_server/src/lib.rs +++ b/codex-rs/tui_app_server/src/lib.rs @@ -119,6 +119,7 @@ mod session_log; mod shimmer; mod skills_helpers; mod slash_command; +mod slash_command_invocation; mod status; mod status_indicator_widget; mod streaming; diff --git a/codex-rs/tui_app_server/src/resume_picker.rs b/codex-rs/tui_app_server/src/resume_picker.rs index b1dab17f5f5..7d4f8394725 100644 --- a/codex-rs/tui_app_server/src/resume_picker.rs +++ b/codex-rs/tui_app_server/src/resume_picker.rs @@ -46,7 +46,7 @@ use unicode_width::UnicodeWidthStr; const PAGE_SIZE: usize = 25; const LOAD_NEAR_THRESHOLD: usize = 5; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct SessionTarget { pub path: Option, pub thread_id: ThreadId, diff --git a/codex-rs/tui_app_server/src/slash_command.rs b/codex-rs/tui_app_server/src/slash_command.rs index d83135c2ffd..3cf459321bd 100644 --- a/codex-rs/tui_app_server/src/slash_command.rs +++ b/codex-rs/tui_app_server/src/slash_command.rs @@ -12,6 +12,7 @@ use strum_macros::IntoStaticStr; pub enum SlashCommand { // DO NOT ALPHA-SORT! Enum order is presentation order in the popup, so // more frequently used commands should be listed first. + Help, Model, Fast, Approvals, @@ -55,7 +56,7 @@ pub enum SlashCommand { Realtime, Settings, TestApproval, - #[strum(serialize = "subagents")] + #[strum(serialize = "subagents", serialize = "multi-agents")] MultiAgents, // Debugging commands. #[strum(serialize = "debug-m-drop")] @@ -65,121 +66,415 @@ pub enum SlashCommand { } impl SlashCommand { - /// User-visible description shown in the popup. - pub fn description(self) -> &'static str { + fn spec(self) -> SlashCommandSpec { match self { - SlashCommand::Feedback => "send logs to maintainers", - SlashCommand::New => "start a new chat during a conversation", - SlashCommand::Init => "create an AGENTS.md file with instructions for Codex", - SlashCommand::Compact => "summarize conversation to prevent hitting the context limit", - SlashCommand::Review => "review my current changes and find issues", - SlashCommand::Rename => "rename the current thread", - SlashCommand::Resume => "resume a saved chat", - SlashCommand::Clear => "clear the terminal and start a new chat", - SlashCommand::Fork => "fork the current chat", - // SlashCommand::Undo => "ask Codex to undo a turn", - SlashCommand::Quit | SlashCommand::Exit => "exit Codex", - SlashCommand::Diff => "show git diff (including untracked files)", - SlashCommand::Copy => "copy the latest Codex output to your clipboard", - SlashCommand::Mention => "mention a file", - SlashCommand::Skills => "use skills to improve how Codex performs specific tasks", - SlashCommand::Status => "show current session configuration and token usage", - SlashCommand::DebugConfig => "show config layers and requirement sources for debugging", - SlashCommand::Statusline => "configure which items appear in the status line", - SlashCommand::Theme => "choose a syntax highlighting theme", - SlashCommand::Ps => "list background terminals", - SlashCommand::Stop => "stop all background terminals", - SlashCommand::MemoryDrop => "DO NOT USE", - SlashCommand::MemoryUpdate => "DO NOT USE", - SlashCommand::Model => "choose what model and reasoning effort to use", - SlashCommand::Fast => "toggle Fast mode to enable fastest inference at 2X plan usage", - SlashCommand::Personality => "choose a communication style for Codex", - SlashCommand::Realtime => "toggle realtime voice mode (experimental)", - SlashCommand::Settings => "configure realtime microphone/speaker", - SlashCommand::Plan => "switch to Plan mode", - SlashCommand::Collab => "change collaboration mode (experimental)", - SlashCommand::Agent | SlashCommand::MultiAgents => "switch the active agent thread", - SlashCommand::Approvals => "choose what Codex is allowed to do", - SlashCommand::Permissions => "choose what Codex is allowed to do", - SlashCommand::ElevateSandbox => "set up elevated agent sandbox", - SlashCommand::SandboxReadRoot => { - "let sandbox read a directory: /sandbox-add-read-dir " - } - SlashCommand::Experimental => "toggle experimental features", - SlashCommand::Mcp => "list configured MCP tools", - SlashCommand::Apps => "manage apps", - SlashCommand::Logout => "log out of Codex", - SlashCommand::Rollout => "print the rollout file path", - SlashCommand::TestApproval => "test approval request", + SlashCommand::Help => SlashCommandSpec { + description: "show slash command help", + help_forms: &[""], + requires_interaction: false, + execution_kind: SlashCommandExecutionKind::Immediate, + show_in_command_popup: true, + }, + SlashCommand::Model => SlashCommandSpec { + description: "choose what model and reasoning effort to use", + help_forms: &[ + "", + " [default|none|minimal|low|medium|high|xhigh] [plan-only|all-modes]", + ], + requires_interaction: true, + execution_kind: SlashCommandExecutionKind::ChangesTurnContext, + show_in_command_popup: true, + }, + SlashCommand::Fast => SlashCommandSpec { + description: "toggle Fast mode to enable fastest inference at 2X plan usage", + help_forms: &["", ""], + requires_interaction: false, + execution_kind: SlashCommandExecutionKind::ChangesTurnContext, + show_in_command_popup: true, + }, + SlashCommand::Approvals => SlashCommandSpec { + description: "choose what Codex is allowed to do", + help_forms: &[ + "", + " [--smart-approvals] [--confirm-full-access] [--remember-full-access] [--confirm-world-writable] [--remember-world-writable] [--enable-windows-sandbox=elevated|legacy]", + ], + requires_interaction: true, + execution_kind: SlashCommandExecutionKind::ChangesTurnContext, + show_in_command_popup: false, + }, + SlashCommand::Permissions => SlashCommandSpec { + description: "choose what Codex is allowed to do", + help_forms: &[ + "", + " [--smart-approvals] [--confirm-full-access] [--remember-full-access] [--confirm-world-writable] [--remember-world-writable] [--enable-windows-sandbox=elevated|legacy]", + ], + requires_interaction: true, + execution_kind: SlashCommandExecutionKind::ChangesTurnContext, + show_in_command_popup: true, + }, + SlashCommand::ElevateSandbox => SlashCommandSpec { + description: "set up elevated agent sandbox", + help_forms: &[""], + requires_interaction: false, + execution_kind: SlashCommandExecutionKind::ChangesTurnContext, + show_in_command_popup: true, + }, + SlashCommand::SandboxReadRoot => SlashCommandSpec { + description: "let sandbox read a directory: /sandbox-add-read-dir ", + help_forms: &[""], + requires_interaction: false, + execution_kind: SlashCommandExecutionKind::ChangesTurnContext, + show_in_command_popup: true, + }, + SlashCommand::Experimental => SlashCommandSpec { + description: "toggle experimental features", + help_forms: &["", "=on|off ..."], + requires_interaction: true, + execution_kind: SlashCommandExecutionKind::ChangesTurnContext, + show_in_command_popup: true, + }, + SlashCommand::Skills => SlashCommandSpec { + description: "use skills to improve how Codex performs specific tasks", + help_forms: &["", ""], + requires_interaction: true, + execution_kind: SlashCommandExecutionKind::Immediate, + show_in_command_popup: true, + }, + SlashCommand::Review => SlashCommandSpec { + description: "review my current changes and find issues", + help_forms: &[ + "", + "uncommitted", + "branch ", + "commit [title]", + "", + ], + requires_interaction: true, + execution_kind: SlashCommandExecutionKind::ChangesTurnContext, + show_in_command_popup: true, + }, + SlashCommand::Rename => SlashCommandSpec { + description: "rename the current thread", + help_forms: &["", ""], + requires_interaction: true, + execution_kind: SlashCommandExecutionKind::Immediate, + show_in_command_popup: true, + }, + SlashCommand::New => SlashCommandSpec { + description: "start a new chat during a conversation", + help_forms: &[""], + requires_interaction: false, + execution_kind: SlashCommandExecutionKind::ChangesTurnContext, + show_in_command_popup: true, + }, + SlashCommand::Resume => SlashCommandSpec { + description: "resume a saved chat", + help_forms: &["", "", " --path "], + requires_interaction: true, + execution_kind: SlashCommandExecutionKind::ChangesTurnContext, + show_in_command_popup: true, + }, + SlashCommand::Fork => SlashCommandSpec { + description: "fork the current chat", + help_forms: &[""], + requires_interaction: false, + execution_kind: SlashCommandExecutionKind::ChangesTurnContext, + show_in_command_popup: true, + }, + SlashCommand::Init => SlashCommandSpec { + description: "create an AGENTS.md file with instructions for Codex", + help_forms: &[""], + requires_interaction: false, + execution_kind: SlashCommandExecutionKind::JustLikeUserMessage, + show_in_command_popup: true, + }, + SlashCommand::Compact => SlashCommandSpec { + description: "summarize conversation to prevent hitting the context limit", + help_forms: &[""], + requires_interaction: false, + execution_kind: SlashCommandExecutionKind::ChangesTurnContext, + show_in_command_popup: true, + }, + SlashCommand::Plan => SlashCommandSpec { + description: "switch to Plan mode", + help_forms: &["", ""], + requires_interaction: false, + execution_kind: SlashCommandExecutionKind::JustLikeUserMessage, + show_in_command_popup: true, + }, + SlashCommand::Collab => SlashCommandSpec { + description: "change collaboration mode (experimental)", + help_forms: &["", ""], + requires_interaction: true, + execution_kind: SlashCommandExecutionKind::Immediate, + show_in_command_popup: true, + }, + SlashCommand::Agent => SlashCommandSpec { + description: "switch the active agent thread", + help_forms: &["", ""], + requires_interaction: true, + execution_kind: SlashCommandExecutionKind::Immediate, + show_in_command_popup: true, + }, + SlashCommand::Diff => SlashCommandSpec { + description: "show git diff (including untracked files)", + help_forms: &[""], + requires_interaction: false, + execution_kind: SlashCommandExecutionKind::Immediate, + show_in_command_popup: true, + }, + SlashCommand::Copy => SlashCommandSpec { + description: "copy the latest Codex output to your clipboard", + help_forms: &[""], + requires_interaction: false, + execution_kind: SlashCommandExecutionKind::Immediate, + show_in_command_popup: true, + }, + SlashCommand::Mention => SlashCommandSpec { + description: "mention a file", + help_forms: &[""], + requires_interaction: false, + execution_kind: SlashCommandExecutionKind::Immediate, + show_in_command_popup: true, + }, + SlashCommand::Status => SlashCommandSpec { + description: "show current session configuration and token usage", + help_forms: &[""], + requires_interaction: false, + execution_kind: SlashCommandExecutionKind::Immediate, + show_in_command_popup: true, + }, + SlashCommand::DebugConfig => SlashCommandSpec { + description: "show config layers and requirement sources for debugging", + help_forms: &[""], + requires_interaction: false, + execution_kind: SlashCommandExecutionKind::Immediate, + show_in_command_popup: true, + }, + SlashCommand::Statusline => SlashCommandSpec { + description: "configure which items appear in the status line", + help_forms: &["", "...", "none"], + requires_interaction: true, + execution_kind: SlashCommandExecutionKind::ChangesTurnContext, + show_in_command_popup: true, + }, + SlashCommand::Theme => SlashCommandSpec { + description: "choose a syntax highlighting theme", + help_forms: &["", ""], + requires_interaction: true, + execution_kind: SlashCommandExecutionKind::ChangesTurnContext, + show_in_command_popup: true, + }, + SlashCommand::Mcp => SlashCommandSpec { + description: "list configured MCP tools", + help_forms: &[""], + requires_interaction: false, + execution_kind: SlashCommandExecutionKind::Immediate, + show_in_command_popup: true, + }, + SlashCommand::Apps => SlashCommandSpec { + description: "manage apps", + help_forms: &[""], + requires_interaction: false, + execution_kind: SlashCommandExecutionKind::Immediate, + show_in_command_popup: true, + }, + SlashCommand::Logout => SlashCommandSpec { + description: "log out of Codex", + help_forms: &[""], + requires_interaction: false, + execution_kind: SlashCommandExecutionKind::ChangesTurnContext, + show_in_command_popup: true, + }, + SlashCommand::Quit => SlashCommandSpec { + description: "exit Codex", + help_forms: &[""], + requires_interaction: false, + execution_kind: SlashCommandExecutionKind::Immediate, + show_in_command_popup: false, + }, + SlashCommand::Exit => SlashCommandSpec { + description: "exit Codex", + help_forms: &[""], + requires_interaction: false, + execution_kind: SlashCommandExecutionKind::Immediate, + show_in_command_popup: true, + }, + SlashCommand::Feedback => SlashCommandSpec { + description: "send logs to maintainers", + help_forms: &["", ""], + requires_interaction: true, + execution_kind: SlashCommandExecutionKind::Immediate, + show_in_command_popup: true, + }, + SlashCommand::Rollout => SlashCommandSpec { + description: "print the rollout file path", + help_forms: &[""], + requires_interaction: false, + execution_kind: SlashCommandExecutionKind::Immediate, + show_in_command_popup: true, + }, + SlashCommand::Ps => SlashCommandSpec { + description: "list background terminals", + help_forms: &[""], + requires_interaction: false, + execution_kind: SlashCommandExecutionKind::Immediate, + show_in_command_popup: true, + }, + SlashCommand::Stop => SlashCommandSpec { + description: "stop all background terminals", + help_forms: &[""], + requires_interaction: false, + execution_kind: SlashCommandExecutionKind::Immediate, + show_in_command_popup: true, + }, + SlashCommand::Clear => SlashCommandSpec { + description: "clear the terminal and start a new chat", + help_forms: &[""], + requires_interaction: false, + execution_kind: SlashCommandExecutionKind::ChangesTurnContext, + show_in_command_popup: true, + }, + SlashCommand::Personality => SlashCommandSpec { + description: "choose a communication style for Codex", + help_forms: &["", ""], + requires_interaction: true, + execution_kind: SlashCommandExecutionKind::ChangesTurnContext, + show_in_command_popup: true, + }, + SlashCommand::Realtime => SlashCommandSpec { + description: "toggle realtime voice mode (experimental)", + help_forms: &[""], + requires_interaction: false, + execution_kind: SlashCommandExecutionKind::Immediate, + show_in_command_popup: true, + }, + SlashCommand::Settings => SlashCommandSpec { + description: "configure realtime microphone/speaker", + help_forms: &["", " [default|]"], + requires_interaction: true, + execution_kind: SlashCommandExecutionKind::Immediate, + show_in_command_popup: true, + }, + SlashCommand::TestApproval => SlashCommandSpec { + description: "test approval request", + help_forms: &[""], + requires_interaction: false, + execution_kind: SlashCommandExecutionKind::Immediate, + show_in_command_popup: true, + }, + SlashCommand::MultiAgents => SlashCommandSpec { + description: "switch the active agent thread", + help_forms: &["", ""], + requires_interaction: true, + execution_kind: SlashCommandExecutionKind::Immediate, + show_in_command_popup: true, + }, + SlashCommand::MemoryDrop => SlashCommandSpec { + description: "DO NOT USE", + help_forms: &[""], + requires_interaction: false, + execution_kind: SlashCommandExecutionKind::ChangesTurnContext, + show_in_command_popup: true, + }, + SlashCommand::MemoryUpdate => SlashCommandSpec { + description: "DO NOT USE", + help_forms: &[""], + requires_interaction: false, + execution_kind: SlashCommandExecutionKind::ChangesTurnContext, + show_in_command_popup: true, + }, } } + /// User-visible description shown in the popup. + pub fn description(self) -> &'static str { + self.spec().description + } + /// Command string without the leading '/'. Provided for compatibility with /// existing code that expects a method named `command()`. pub fn command(self) -> &'static str { - self.into() - } - - /// Whether this command supports inline args (for example `/review ...`). - pub fn supports_inline_args(self) -> bool { - matches!( - self, - SlashCommand::Review - | SlashCommand::Rename - | SlashCommand::Plan - | SlashCommand::Fast - | SlashCommand::SandboxReadRoot - ) + match self { + SlashCommand::MultiAgents => "subagents", + _ => self.into(), + } } - /// Whether this command can be run while a task is in progress. - pub fn available_during_task(self) -> bool { + /// Additional accepted built-in names besides `command()`. + pub fn command_aliases(self) -> &'static [&'static str] { match self { - SlashCommand::New - | SlashCommand::Resume - | SlashCommand::Fork - | SlashCommand::Init - | SlashCommand::Compact - // | SlashCommand::Undo + SlashCommand::Help | SlashCommand::Model | SlashCommand::Fast - | SlashCommand::Personality | SlashCommand::Approvals | SlashCommand::Permissions | SlashCommand::ElevateSandbox | SlashCommand::SandboxReadRoot | SlashCommand::Experimental + | SlashCommand::Skills | SlashCommand::Review + | SlashCommand::Rename + | SlashCommand::New + | SlashCommand::Resume + | SlashCommand::Fork + | SlashCommand::Init + | SlashCommand::Compact | SlashCommand::Plan - | SlashCommand::Clear - | SlashCommand::Logout - | SlashCommand::MemoryDrop - | SlashCommand::MemoryUpdate => false, - SlashCommand::Diff + | SlashCommand::Collab + | SlashCommand::Agent + | SlashCommand::Diff | SlashCommand::Copy - | SlashCommand::Rename | SlashCommand::Mention - | SlashCommand::Skills | SlashCommand::Status | SlashCommand::DebugConfig - | SlashCommand::Ps - | SlashCommand::Stop + | SlashCommand::Statusline + | SlashCommand::Theme | SlashCommand::Mcp | SlashCommand::Apps - | SlashCommand::Feedback + | SlashCommand::Logout | SlashCommand::Quit - | SlashCommand::Exit => true, - SlashCommand::Rollout => true, - SlashCommand::TestApproval => true, - SlashCommand::Realtime => true, - SlashCommand::Settings => true, - SlashCommand::Collab => true, - SlashCommand::Agent | SlashCommand::MultiAgents => true, - SlashCommand::Statusline => false, - SlashCommand::Theme => false, + | SlashCommand::Exit + | SlashCommand::Feedback + | SlashCommand::Rollout + | SlashCommand::Ps + | SlashCommand::Clear + | SlashCommand::Personality + | SlashCommand::Realtime + | SlashCommand::Settings + | SlashCommand::TestApproval + | SlashCommand::MemoryDrop + | SlashCommand::MemoryUpdate => &[], + SlashCommand::Stop => &["clean"], + SlashCommand::MultiAgents => &["multi-agents"], } } + pub fn all_command_names(self) -> impl Iterator { + std::iter::once(self.command()).chain(self.command_aliases().iter().copied()) + } + + /// Human-facing forms accepted by the TUI. + /// + /// An empty string represents the bare `/command` form. + pub fn help_forms(self) -> &'static [&'static str] { + self.spec().help_forms + } + + /// Whether bare dispatch opens interactive UI that should be resolved before queueing. + pub fn requires_interaction(self) -> bool { + self.spec().requires_interaction + } + + /// How this command should behave when dispatched while another turn is running. + pub fn execution_kind(self) -> SlashCommandExecutionKind { + self.spec().execution_kind + } + + pub fn show_in_command_popup(self) -> bool { + self.spec().show_in_command_popup + } + fn is_visible(self) -> bool { match self { SlashCommand::SandboxReadRoot => cfg!(target_os = "windows"), @@ -190,11 +485,46 @@ impl SlashCommand { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SlashCommandExecutionKind { + /// Behaves like a normal user message. + /// + /// Enter should submit immediately when idle, and queue while a turn is running. + /// Use this for commands whose effect is "ask the model to do work now". + JustLikeUserMessage, + + /// Does not become a user message, but changes state that affects future turns. + /// + /// While a turn is running, it must queue and apply later in order. + ChangesTurnContext, + + /// Does not submit model work and does not need to wait for the current turn. + /// + /// Run it immediately, even while a turn is in progress. + Immediate, +} + +#[derive(Clone, Copy)] +struct SlashCommandSpec { + description: &'static str, + help_forms: &'static [&'static str], + requires_interaction: bool, + execution_kind: SlashCommandExecutionKind, + show_in_command_popup: bool, +} + /// Return all built-in commands in a Vec paired with their command string. pub fn built_in_slash_commands() -> Vec<(&'static str, SlashCommand)> { SlashCommand::iter() .filter(|command| command.is_visible()) - .map(|c| (c.command(), c)) + .flat_map(|command| command.all_command_names().map(move |name| (name, command))) + .collect() +} + +/// Return all visible built-in commands once each, in presentation order. +pub fn visible_built_in_slash_commands() -> Vec { + SlashCommand::iter() + .filter(|command| command.is_visible()) .collect() } diff --git a/codex-rs/tui_app_server/src/slash_command_invocation.rs b/codex-rs/tui_app_server/src/slash_command_invocation.rs new file mode 100644 index 00000000000..2dcae03225f --- /dev/null +++ b/codex-rs/tui_app_server/src/slash_command_invocation.rs @@ -0,0 +1,78 @@ +use crate::chatwidget::UserMessage; +use crate::slash_command::SlashCommand; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SlashCommandInvocation { + pub(crate) command: SlashCommand, + pub(crate) args: Vec, +} + +impl SlashCommandInvocation { + pub(crate) fn bare(command: SlashCommand) -> Self { + Self { + command, + args: Vec::new(), + } + } + + pub(crate) fn with_args(command: SlashCommand, args: I) -> Self + where + I: IntoIterator, + S: Into, + { + Self { + command, + args: args.into_iter().map(Into::into).collect(), + } + } + + pub(crate) fn parse_args(args: &str, usage: &str) -> Result, String> { + shlex::split(args).ok_or_else(|| usage.to_string()) + } + + pub(crate) fn into_user_message(self) -> UserMessage { + let command = self.command.command(); + let joined = match shlex::try_join( + std::iter::once(command).chain(self.args.iter().map(String::as_str)), + ) { + Ok(joined) => joined, + Err(err) => panic!("slash command invocation should serialize: {err}"), + }; + format!("/{joined}").into() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn serializes_quoted_args() { + let draft = SlashCommandInvocation::with_args( + SlashCommand::Review, + ["branch main needs coverage".to_string()], + ) + .into_user_message(); + + assert_eq!( + draft, + UserMessage::from("/review 'branch main needs coverage'") + ); + } + + #[test] + fn parses_shlex_args() { + let parsed = SlashCommandInvocation::parse_args("'branch main' --flag key=value", "usage") + .expect("quoted args should parse"); + + assert_eq!( + parsed, + vec![ + "branch main".to_string(), + "--flag".to_string(), + "key=value".to_string() + ] + ); + } +} diff --git a/codex-rs/tui_app_server/src/status_indicator_widget.rs b/codex-rs/tui_app_server/src/status_indicator_widget.rs index 3cd1c188ac8..a1d7d2b355a 100644 --- a/codex-rs/tui_app_server/src/status_indicator_widget.rs +++ b/codex-rs/tui_app_server/src/status_indicator_widget.rs @@ -18,7 +18,6 @@ use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; use unicode_width::UnicodeWidthStr; -use crate::app_event_sender::AppEventSender; use crate::exec_cell::spinner; use crate::key_hint; use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; @@ -51,7 +50,6 @@ pub(crate) struct StatusIndicatorWidget { elapsed_running: Duration, last_resume_at: Instant, is_paused: bool, - app_event_tx: AppEventSender, frame_requester: FrameRequester, animations_enabled: bool, } @@ -74,11 +72,7 @@ pub fn fmt_elapsed_compact(elapsed_secs: u64) -> String { } impl StatusIndicatorWidget { - pub(crate) fn new( - app_event_tx: AppEventSender, - frame_requester: FrameRequester, - animations_enabled: bool, - ) -> Self { + pub(crate) fn new(frame_requester: FrameRequester, animations_enabled: bool) -> Self { Self { header: String::from("Working"), details: None, @@ -88,17 +82,11 @@ impl StatusIndicatorWidget { elapsed_running: Duration::ZERO, last_resume_at: Instant::now(), is_paused: false, - - app_event_tx, frame_requester, animations_enabled, } } - pub(crate) fn interrupt(&self) { - self.app_event_tx.interrupt(); - } - /// Update the animated header label (left of the brackets). pub(crate) fn update_header(&mut self, header: String) { self.header = header; @@ -290,13 +278,10 @@ impl Renderable for StatusIndicatorWidget { #[cfg(test)] mod tests { use super::*; - use crate::app_event::AppEvent; - use crate::app_event_sender::AppEventSender; use ratatui::Terminal; use ratatui::backend::TestBackend; use std::time::Duration; use std::time::Instant; - use tokio::sync::mpsc::unbounded_channel; use pretty_assertions::assert_eq; @@ -316,9 +301,7 @@ mod tests { #[test] fn renders_with_working_header() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true); + let w = StatusIndicatorWidget::new(crate::tui::FrameRequester::test_dummy(), true); // Render into a fixed-size test terminal and snapshot the backend. let mut terminal = Terminal::new(TestBackend::new(80, 2)).expect("terminal"); @@ -330,9 +313,7 @@ mod tests { #[test] fn renders_truncated() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true); + let w = StatusIndicatorWidget::new(crate::tui::FrameRequester::test_dummy(), true); // Render into a fixed-size test terminal and snapshot the backend. let mut terminal = Terminal::new(TestBackend::new(20, 2)).expect("terminal"); @@ -344,9 +325,7 @@ mod tests { #[test] fn renders_wrapped_details_panama_two_lines() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), false); + let mut w = StatusIndicatorWidget::new(crate::tui::FrameRequester::test_dummy(), false); w.update_details( Some("A man a plan a canal panama".to_string()), StatusDetailsCapitalization::CapitalizeFirst, @@ -369,10 +348,7 @@ mod tests { #[test] fn timer_pauses_when_requested() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let mut widget = - StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true); + let mut widget = StatusIndicatorWidget::new(crate::tui::FrameRequester::test_dummy(), true); let baseline = Instant::now(); widget.last_resume_at = baseline; @@ -391,9 +367,7 @@ mod tests { #[test] fn details_overflow_adds_ellipsis() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true); + let mut w = StatusIndicatorWidget::new(crate::tui::FrameRequester::test_dummy(), true); w.update_details( Some("abcd abcd abcd abcd".to_string()), StatusDetailsCapitalization::CapitalizeFirst, @@ -411,9 +385,7 @@ mod tests { #[test] fn details_args_can_disable_capitalization_and_limit_lines() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true); + let mut w = StatusIndicatorWidget::new(crate::tui::FrameRequester::test_dummy(), true); w.update_details( Some("cargo test -p codex-core and then cargo test -p codex-tui".to_string()), StatusDetailsCapitalization::Preserve, diff --git a/codex-rs/tui_app_server/src/theme_picker.rs b/codex-rs/tui_app_server/src/theme_picker.rs index 54910ee10a8..59d0e2861f0 100644 --- a/codex-rs/tui_app_server/src/theme_picker.rs +++ b/codex-rs/tui_app_server/src/theme_picker.rs @@ -8,8 +8,8 @@ //! the preview panel and any visible code blocks. //! - **Cancel-restore:** on dismiss (Esc / Ctrl+C) the `on_cancel` callback //! restores the theme snapshot taken when the picker opened. -//! - **Persist on confirm:** the `AppEvent::SyntaxThemeSelected` action persists -//! `[tui] theme = "..."` to `config.toml` via `ConfigEditsBuilder`. +//! - **Persist on confirm:** the picker emits a canonical `/theme ` draft, +//! which then persists `[tui] theme = "..."` through normal slash-command handling. //! //! Two preview renderables adapt to terminal width: //! @@ -35,6 +35,8 @@ use crate::diff_render::push_wrapped_diff_line_with_style_context; use crate::diff_render::push_wrapped_diff_line_with_syntax_and_style_context; use crate::render::highlight; use crate::render::renderable::Renderable; +use crate::slash_command::SlashCommand; +use crate::slash_command_invocation::SlashCommandInvocation; use crate::status::format_directory_display; use ratatui::buffer::Buffer; use ratatui::layout::Rect; @@ -356,9 +358,13 @@ pub(crate) fn build_theme_picker_params( dismiss_on_select: true, search_value: Some(entry.name.clone()), actions: vec![Box::new(move |tx| { - tx.send(AppEvent::SyntaxThemeSelected { - name: name_for_action.clone(), - }); + tx.send(AppEvent::HandleSlashCommandDraft( + SlashCommandInvocation::with_args( + SlashCommand::Theme, + [name_for_action.clone()], + ) + .into_user_message(), + )); })], ..Default::default() } diff --git a/docs/slash_commands.md b/docs/slash_commands.md index 4db63f7f6e6..88f9640df6d 100644 --- a/docs/slash_commands.md +++ b/docs/slash_commands.md @@ -1,3 +1,13 @@ # Slash commands For an overview of Codex CLI slash commands, see [this documentation](https://developers.openai.com/codex/cli/slash-commands). + +## TUI + +In the TUI, type `/` to open the slash-command popup. The popup uses the same command order as the +in-app `/help` page, with `/help` pinned at the top for discovery. + +For commands that have both an interactive picker flow and a direct argument form, the bare +`/command` form opens the picker and `/command ...` runs the direct argument form instead. Use +`/help` inside the TUI for the current list of supported commands and argument syntax. Argument +parsing uses shell-style quoting, so quote values with spaces when needed. diff --git a/docs/tui-chat-composer.md b/docs/tui-chat-composer.md index 5630b84ecac..bade061deab 100644 --- a/docs/tui-chat-composer.md +++ b/docs/tui-chat-composer.md @@ -115,8 +115,8 @@ the input starts with `!` (shell command). 5. Clears pending pastes on success and suppresses submission if the final text is empty and there are no attachments. -The same preparation path is reused for slash commands with arguments (for example `/plan` and -`/review`) so pasted content and text elements are preserved when extracting args. +The same preparation path is reused for slash commands with arguments (for example `/model`, +`/plan`, and `/review`) so pasted content and text elements are preserved when extracting args. The composer also treats the textarea kill buffer as separate editing state from the visible draft. After submit or slash-command dispatch clears the textarea, the most recent `Ctrl+K` payload is