Add a TUI setting to disable automatic recaps (#42101)

## What changed

- Add the `tui.auto_recap` configuration option, enabled by default.
- When disabled, cancel scheduled automatic recap checks, reject automatic
  requests, and discard pending automatic results without retrying.
- Keep manual `/recap` requests available regardless of the setting.

## Testing

- Cover configuration defaults and command-line overrides.
- Cover scheduling cancellation, pending request cleanup, discarded results,
  and manual recaps while automatic recaps are disabled.

GitOrigin-RevId: 7d5323f58a2c84e60683a25a8c77653097efe6e2
This commit is contained in:
Felipe Coury
2026-09-01 17:09:13 +00:00
committed by copyberry
parent b6ab99ed87
commit 8ea297ff60
14 changed files with 229 additions and 47 deletions
+5
View File
@@ -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.
+5
View File
@@ -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"
+34
View File
@@ -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,
+4
View File
@@ -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()
@@ -224,6 +224,7 @@ fn new_config(model: Option<String>, 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,
+1 -2
View File
@@ -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 => {
+37 -23
View File
@@ -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,
+61 -11
View File
@@ -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();
+1 -2
View File
@@ -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;
+1 -2
View File
@@ -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 {
@@ -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(),
@@ -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::<Value>::new()
);
let rendered = app
.transcript_cells
.iter()
.flat_map(|cell| cell.display_lines(/*width*/ 80))
.collect::<Vec<_>>();
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::<Value>::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;
@@ -0,0 +1,6 @@
---
source: tui/src/app/tests/recap_generation_tests.rs
expression: lines_to_single_string(&rendered)
---
› Finish the recap implementation
+2 -4
View File
@@ -973,11 +973,10 @@ impl App {
&& let ServerNotification::TurnCompleted(notification) = &notification
{
let now = Instant::now();
let app_event_tx = self.app_event_tx.clone();
self.recap
.note_turn_finished(&notification.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 &notification {
@@ -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);