diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index 853805c27d..35dd0e109a 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -736,6 +736,11 @@ pub struct Tui { #[serde(default = "default_true")] pub show_tooltips: bool, + /// Generate automatic conversation recaps when the terminal is unfocused. + /// Defaults to `true`. Disabling this leaves `/recap` available on demand. + #[serde(default = "default_true")] + pub auto_recap: bool, + /// When true, disables burst-paste detection for typed input entirely. /// All characters are inserted as they are received, and no buffering /// or placeholder replacement will occur for fast keypress bursts. diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 0be6b437c3..3063df2ed7 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -3810,6 +3810,11 @@ "description": "Enable animations (welcome screen, shimmer effects, spinners). Defaults to `true`.", "type": "boolean" }, + "auto_recap": { + "default": true, + "description": "Generate automatic conversation recaps when the terminal is unfocused. Defaults to `true`. Disabling this leaves `/recap` available on demand.", + "type": "boolean" + }, "disable_paste_burst": { "description": "When true, disables burst-paste detection for typed input entirely. All characters are inserted as they are received, and no buffering or placeholder replacement will occur for fast keypress bursts. Overrides the legacy top-level `disable_paste_burst` setting. Defaults to `false`.", "type": "boolean" diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 87938ad87f..81291d75c2 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -1210,6 +1210,7 @@ fn config_toml_deserializes_model_availability_nux() { notification_settings: TuiNotificationSettings::default(), animations: true, show_tooltips: true, + auto_recap: true, disable_paste_burst: None, vim_mode_default: false, raw_output_mode: false, @@ -1316,6 +1317,38 @@ async fn runtime_config_uses_tui_raw_output_mode() { assert!(cfg.tui_raw_output_mode); } +#[tokio::test] +async fn tui_auto_recap_defaults_and_cli_overrides() -> anyhow::Result<()> { + for (toml, override_value, expected) in [ + ("", None, true), + ("[tui]", None, true), + ("[tui]\nauto_recap = true", None, true), + ("[tui]\nauto_recap = false", None, false), + ("[tui]\nauto_recap = true", Some(false), false), + ("[tui]\nauto_recap = false", Some(true), true), + ] { + let codex_home = TempDir::new()?; + std::fs::write(codex_home.path().join(CONFIG_TOML_FILE), toml)?; + let config = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .cli_overrides( + override_value + .into_iter() + .map(|enabled| ("tui.auto_recap".to_string(), TomlValue::Boolean(enabled))) + .collect(), + ) + .build() + .await?; + + assert_eq!( + config.tui_auto_recap, expected, + "config: {toml}, override: {override_value:?}" + ); + } + Ok(()) +} + #[test] fn config_toml_deserializes_permission_profiles() { let toml = r#" @@ -4190,6 +4223,7 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() { notification_settings: TuiNotificationSettings::default(), animations: true, show_tooltips: true, + auto_recap: true, disable_paste_burst: None, vim_mode_default: false, raw_output_mode: false, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index b8ef3d319d..65e9620403 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -742,6 +742,9 @@ pub struct Config { /// Show startup tooltips in the TUI welcome screen. pub show_tooltips: bool, + /// Generate automatic TUI recaps. Manual `/recap` remains available when disabled. + pub tui_auto_recap: bool, + /// Persisted startup availability NUX state for model tooltips. pub model_availability_nux: ModelAvailabilityNuxConfig, @@ -4322,6 +4325,7 @@ impl Config { .unwrap_or_default(), animations: cfg.tui.as_ref().map(|t| t.animations).unwrap_or(true), show_tooltips: cfg.tui.as_ref().map(|t| t.show_tooltips).unwrap_or(true), + tui_auto_recap: cfg.tui.as_ref().map(|t| t.auto_recap).unwrap_or(/*default*/ true), model_availability_nux: cfg .tui .as_ref() diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index eb319b6e32..2781624fb0 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -224,6 +224,7 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R tui_notifications: TuiNotificationSettings::default(), animations: true, show_tooltips: true, + tui_auto_recap: true, model_availability_nux: ModelAvailabilityNuxConfig::default(), tui_alternate_screen: AltScreenMode::Auto, tui_status_line: None, diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 0162bbc101..93f94642b9 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -833,8 +833,7 @@ impl App { self.recap.note_focus_lost(now); if let Some(thread_id) = thread_id { - self.recap - .schedule_check(thread_id, self.app_event_tx.clone(), now); + self.schedule_recap_check(thread_id, now); } } TuiEvent::FocusGained => { diff --git a/codex-rs/tui/src/app/recap.rs b/codex-rs/tui/src/app/recap.rs index 9841b4b684..2fd05a83af 100644 --- a/codex-rs/tui/src/app/recap.rs +++ b/codex-rs/tui/src/app/recap.rs @@ -1,4 +1,5 @@ //! Determines when an unfocused conversation is ready for an automatic recap. +//! The TUI opt-out suppresses automatic scheduling and requests, but not manual `/recap`. use std::sync::Arc; use std::time::Duration; @@ -176,6 +177,33 @@ pub(super) struct RecapRequest { } impl App { + fn recap_trigger_enabled(&self, trigger: RecapTrigger) -> bool { + match trigger { + RecapTrigger::Automatic => self.config.tui_auto_recap, + RecapTrigger::Manual => true, + } + } + + pub(super) fn schedule_recap_check(&mut self, thread_id: ThreadId, now: Instant) { + if let Some(task) = self.recap.scheduled_check.take() { + task.abort(); + } + + if !self.config.tui_auto_recap { + return; + } + let Some(deadline) = self.recap.next_check_deadline() else { + return; + }; + let delay = deadline.saturating_duration_since(now); + let app_event_tx = self.app_event_tx.clone(); + + self.recap.scheduled_check = Some(tokio::spawn(async move { + tokio::time::sleep(delay).await; + app_event_tx.send(AppEvent::CheckRecap { thread_id }); + })); + } + pub(super) fn clear_recap_request(&mut self, trigger: RecapTrigger) { self.recap.clear_in_flight_request(); @@ -200,6 +228,9 @@ impl App { } fn retry_or_report_recap_failure(&mut self, request: RecapRequest) { + if !self.recap_trigger_enabled(request.trigger) { + return; + } match request.trigger { RecapTrigger::Automatic => self.recap.schedule_retry( request.thread_id, @@ -218,6 +249,9 @@ impl App { thread_id: ThreadId, trigger: RecapTrigger, ) { + if !self.recap_trigger_enabled(trigger) { + return; + } if self.recap.in_flight_request.is_some() { if matches!(trigger, RecapTrigger::Manual) { self.chat_widget @@ -332,6 +366,7 @@ impl App { && !self.chat_widget.is_user_turn_pending_or_running() && self.recap.completed_turns == completed_turn_count && self.recap.turn_revision == turn_revision + && self.recap_trigger_enabled(trigger) && trigger_is_eligible; let Ok(temporary_thread_id) = ThreadId::from_string(&temporary_thread_id_text) else { if is_current_request { @@ -408,7 +443,8 @@ impl App { self.clear_recap_request(trigger); - if self.current_displayed_thread_id() != Some(thread_id) + if !self.recap_trigger_enabled(trigger) + || self.current_displayed_thread_id() != Some(thread_id) || self.chat_widget.is_user_turn_pending_or_running() || self.recap.completed_turns != completed_turn_count || self.recap.turn_revision != turn_revision @@ -541,28 +577,6 @@ impl RecapState { self.last_turn_finished_at = Some(now); } - pub(super) fn schedule_check( - &mut self, - thread_id: ThreadId, - app_event_tx: AppEventSender, - now: Instant, - ) { - if let Some(task) = self.scheduled_check.take() { - task.abort(); - } - - let Some(deadline) = self.next_check_deadline() else { - return; - }; - - let delay = deadline.saturating_duration_since(now); - - self.scheduled_check = Some(tokio::spawn(async move { - tokio::time::sleep(delay).await; - app_event_tx.send(AppEvent::CheckRecap { thread_id }); - })); - } - pub(super) fn schedule_retry( &mut self, thread_id: ThreadId, diff --git a/codex-rs/tui/src/app/recap_tests.rs b/codex-rs/tui/src/app/recap_tests.rs index 0e738385e0..51615f4169 100644 --- a/codex-rs/tui/src/app/recap_tests.rs +++ b/codex-rs/tui/src/app/recap_tests.rs @@ -318,14 +318,15 @@ fn regaining_focus_preserves_only_manual_in_flight_request() { async fn scheduled_check_fires_at_recap_deadline() { let thread_id = ThreadId::new(); let now = Instant::now(); - let mut state = RecapState::default(); + let mut app = make_test_app().await; let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel(); + app.app_event_tx = AppEventSender::new(event_tx); - state.note_focus_lost(now); + app.recap.note_focus_lost(now); for _ in 0..3 { - state.note_turn_finished(&TurnStatus::Completed, now); + app.recap.note_turn_finished(&TurnStatus::Completed, now); } - state.schedule_check(thread_id, AppEventSender::new(event_tx), now); + app.schedule_recap_check(thread_id, now); tokio::task::yield_now().await; tokio::time::advance(RECAP_DELAY - Duration::from_secs(/*secs*/ 1)).await; @@ -346,22 +347,24 @@ async fn scheduled_check_fires_at_recap_deadline() { async fn rescheduling_check_cancels_the_earlier_timer() { let thread_id = ThreadId::new(); let first_turn = Instant::now(); - let mut state = RecapState::default(); + let mut app = make_test_app().await; let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel(); - let app_event_tx = AppEventSender::new(event_tx); + app.app_event_tx = AppEventSender::new(event_tx); - state.note_focus_lost(first_turn); + app.recap.note_focus_lost(first_turn); for _ in 0..3 { - state.note_turn_finished(&TurnStatus::Completed, first_turn); + app.recap + .note_turn_finished(&TurnStatus::Completed, first_turn); } - state.schedule_check(thread_id, app_event_tx.clone(), first_turn); + app.schedule_recap_check(thread_id, first_turn); tokio::task::yield_now().await; let elapsed = Duration::from_secs(/*secs*/ 1); tokio::time::advance(elapsed).await; let later_turn = first_turn + elapsed; - state.note_turn_finished(&TurnStatus::Completed, later_turn); - state.schedule_check(thread_id, app_event_tx, later_turn); + app.recap + .note_turn_finished(&TurnStatus::Completed, later_turn); + app.schedule_recap_check(thread_id, later_turn); tokio::task::yield_now().await; tokio::time::advance(RECAP_DELAY - elapsed).await; @@ -378,6 +381,32 @@ async fn rescheduling_check_cancels_the_earlier_timer() { )); } +#[tokio::test(flavor = "current_thread", start_paused = true)] +async fn auto_recap_opt_out_cancels_scheduling_after_restored_progress() { + let thread_id = ThreadId::new(); + let now = Instant::now(); + let mut app = make_test_app().await; + let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel(); + app.app_event_tx = AppEventSender::new(event_tx); + app.recap.note_focus_lost(now); + let progress = RecapProgress { + completed_turns: 3, + last_recapped_turn_count: None, + }; + app.recap.seed_from_progress(progress, now); + app.schedule_recap_check(thread_id, now); + tokio::task::yield_now().await; + + app.config.tui_auto_recap = false; + app.schedule_recap_check(thread_id, now); + tokio::time::advance(RECAP_DELAY).await; + tokio::task::yield_now().await; + + assert!(app.recap.scheduled_check.is_none()); + assert!(matches!(event_rx.try_recv(), Err(TryRecvError::Empty))); + assert_eq!(app.recap.progress(), progress); +} + #[tokio::test(flavor = "current_thread", start_paused = true)] async fn dropping_recap_state_cancels_timer() { let mut state = RecapState::default(); @@ -620,6 +649,27 @@ async fn generated_recap_is_returned_for_synchronous_insertion() { ); } +#[tokio::test] +async fn auto_recap_opt_out_discards_results_without_retrying() { + let thread_id = ThreadId::new(); + let mut app = app_with_visible_thread(thread_id).await; + for result in [ + Ok(serde_json::json!({ "recap": "obsolete" }).to_string()), + Err("temporary failure".to_string()), + ] { + let (request, temporary_thread_id) = track_in_flight_recap(&mut app, thread_id); + app.config.tui_auto_recap = false; + + assert!( + app.handle_generated_recap(request, temporary_thread_id, result) + .is_none() + ); + assert!(app.recap.in_flight_request.is_none()); + assert!(app.recap.scheduled_check.is_none()); + assert_eq!(app.recap.last_recapped_turn_count, None); + } +} + #[tokio::test] async fn obsolete_recap_result_does_not_clear_the_current_request() { let thread_id = ThreadId::new(); diff --git a/codex-rs/tui/src/app/reconnect.rs b/codex-rs/tui/src/app/reconnect.rs index 59b997e507..19b724e9a5 100644 --- a/codex-rs/tui/src/app/reconnect.rs +++ b/codex-rs/tui/src/app/reconnect.rs @@ -327,8 +327,7 @@ impl App { self.chat_widget.pause_unavailable_thread(); self.chat_widget.add_info_message("This conversation is unavailable. Its cached transcript and draft remain here; input is paused. Open the agent picker or return to the parent to continue.".into(), /*hint*/ None); } else { - self.recap - .schedule_check(id, self.app_event_tx.clone(), Instant::now()); + self.schedule_recap_check(id, Instant::now()); } } else { self.active_thread_id = None; diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index 6b670d63cf..41105a1b3b 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -620,8 +620,7 @@ impl App { } let now = Instant::now(); self.recap.seed_from_progress(recap_progress, now); - self.recap - .schedule_check(thread_id, self.app_event_tx.clone(), now); + self.schedule_recap_check(thread_id, now); self.render_thread_snapshot(tui, app_server, thread_id, snapshot, !is_replay_only)?; if is_replay_only { diff --git a/codex-rs/tui/src/app/tests/active_reconnect_tests.rs b/codex-rs/tui/src/app/tests/active_reconnect_tests.rs index 56efddf8c7..8ebf868eeb 100644 --- a/codex-rs/tui/src/app/tests/active_reconnect_tests.rs +++ b/codex-rs/tui/src/app/tests/active_reconnect_tests.rs @@ -180,8 +180,7 @@ async fn reconnect_restores_history_permissions_and_keeps_old_input_paused() -> app.recap .note_turn_finished(&TurnStatus::Completed, before_disconnect); } - app.recap - .schedule_check(id, app.app_event_tx.clone(), Instant::now()); + app.schedule_recap_check(id, Instant::now()); let old_sender = app.app_event_tx.clone(); let connected = reconnect( app.app_server_target.clone(), diff --git a/codex-rs/tui/src/app/tests/recap_generation_tests.rs b/codex-rs/tui/src/app/tests/recap_generation_tests.rs index 68eb334ae3..6c5f75cf90 100644 --- a/codex-rs/tui/src/app/tests/recap_generation_tests.rs +++ b/codex-rs/tui/src/app/tests/recap_generation_tests.rs @@ -191,8 +191,9 @@ stream_max_retries = 0 } #[tokio::test] -async fn manual_recap_bypasses_automatic_eligibility_and_reports_failure() -> Result<()> { +async fn manual_recap_works_when_auto_recap_disabled() -> Result<()> { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + app.config.tui_auto_recap = false; let thread_id = ThreadId::new(); app.active_thread_id = Some(thread_id); app.transcript_cells @@ -205,6 +206,8 @@ async fn manual_recap_bypasses_automatic_eligibility_and_reports_failure() -> Re let (mut app_server, _requests, proxy) = start_recording_remote_app_server(&app.config).await?; let mut tui = crate::tui::test_support::make_test_tui()?; + // An automatic request must not occupy the slot needed by a manual request. + app.request_recap(&app_server, thread_id, RecapTrigger::Automatic); app.handle_event( &mut tui, &mut app_server, @@ -295,6 +298,72 @@ async fn manual_recap_bypasses_automatic_eligibility_and_reports_failure() -> Re Ok(()) } +#[tokio::test] +async fn auto_recap_opt_out_blocks_requests_and_cleans_up_pending_start() -> Result<()> { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + prepare_eligible_recap(&mut app, thread_id); + let (mut app_server, requests, proxy) = start_recording_remote_app_server(&app.config).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + app.config.tui_auto_recap = false; + app.schedule_recap_check(thread_id, Instant::now()); + app.request_recap(&app_server, thread_id, RecapTrigger::Automatic); + app.handle_event( + &mut tui, + &mut app_server, + AppEvent::CheckRecap { thread_id }, + ) + .await?; + tokio::task::yield_now().await; + + assert!(app_event_rx.try_recv().is_err()); + assert_eq!( + recorded_params(&requests, "thread/start"), + Vec::::new() + ); + let rendered = app + .transcript_cells + .iter() + .flat_map(|cell| cell.display_lines(/*width*/ 80)) + .collect::>(); + + app.config.tui_auto_recap = true; + app.request_recap(&app_server, thread_id, RecapTrigger::Automatic); + let started_event = tokio::time::timeout(Duration::from_secs(/*secs*/ 5), app_event_rx.recv()) + .await? + .expect("recap start event"); + assert!(matches!(started_event, AppEvent::RecapStarted { .. })); + app.config.tui_auto_recap = false; + app.handle_event(&mut tui, &mut app_server, started_event) + .await?; + tokio::time::timeout(Duration::from_secs(/*secs*/ 5), async { + while recorded_params(&requests, "thread/unsubscribe").is_empty() { + tokio::task::yield_now().await; + } + }) + .await?; + + // Finish the unsubscribe round trip before shutting down the recording proxy. + app_server + .thread_loaded_list(codex_app_server_protocol::ThreadLoadedListParams { + cursor: None, + limit: None, + }) + .await?; + assert_eq!( + recorded_params(&requests, "turn/start"), + Vec::::new() + ); + assert!(app.temporary_structured_requests.is_empty()); + app_server.shutdown().await?; + proxy.await??; + insta::assert_snapshot!( + "auto_recap_disabled_transcript", + lines_to_single_string(&rendered) + ); + Ok(()) +} + #[tokio::test] async fn recap_generation_uses_remote_workspace_cwd() -> Result<()> { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__recap_generation__auto_recap_disabled_transcript.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__recap_generation__auto_recap_disabled_transcript.snap new file mode 100644 index 0000000000..ee2de56e51 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__recap_generation__auto_recap_disabled_transcript.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/app/tests/recap_generation_tests.rs +expression: lines_to_single_string(&rendered) +--- + +› Finish the recap implementation diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index 7a6fccadf1..dcce3111c3 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -973,11 +973,10 @@ impl App { && let ServerNotification::TurnCompleted(notification) = ¬ification { let now = Instant::now(); - let app_event_tx = self.app_event_tx.clone(); self.recap .note_turn_finished(¬ification.turn.status, now); - self.recap.schedule_check(thread_id, app_event_tx, now); + self.schedule_recap_check(thread_id, now); } let misalignment_policy_violation = match ¬ification { @@ -1340,10 +1339,9 @@ impl App { .send(AppEvent::BeginInitialHistoryReplayBuffer); } let now = Instant::now(); - let app_event_tx = self.app_event_tx.clone(); self.recap.seed_from_turns(&turns, now); - self.recap.schedule_check(thread_id, app_event_tx, now); + self.schedule_recap_check(thread_id, now); self.chat_widget .replay_thread_turns(turns, ReplayKind::ResumeInitialMessages);