From 7c1e36c23fef471dc6843e0cac2706d0901f1796 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Tue, 25 Aug 2026 20:43:08 +0000 Subject: [PATCH] Prepare the TUI for conversation recaps (#40696) ## What changed - Forward terminal focus changes through the TUI event stream and handle them across interactive screens. - Track completed turns and recap eligibility across active, resumed, and switched threads. - Add bounded recent-conversation prompt construction and a wrapping `Conversation recap` history cell. - Cover focus timing, repeat eligibility, restored progress, UTF-8 prompt limits, and recap rendering with unit and snapshot tests. GitOrigin-RevId: d7542c18b2bb1e72f5906f563898645bb526255f --- codex-rs/tui/src/app.rs | 20 +- codex-rs/tui/src/app/recap.rs | 225 +++++++++++ codex-rs/tui/src/app/recap_tests.rs | 376 ++++++++++++++++++ codex-rs/tui/src/app/session_lifecycle.rs | 16 + codex-rs/tui/src/app/startup.rs | 4 + codex-rs/tui/src/app/test_support.rs | 1 + codex-rs/tui/src/app/tests.rs | 9 + codex-rs/tui/src/app/thread_events.rs | 47 +++ codex-rs/tui/src/app/thread_routing.rs | 12 + codex-rs/tui/src/app_backtrack.rs | 2 +- codex-rs/tui/src/cwd_prompt.rs | 4 +- .../external_agent_config_migration/mod.rs | 4 +- .../external_agent_config_migration/source.rs | 4 +- codex-rs/tui/src/history_cell/notices.rs | 61 +++ codex-rs/tui/src/model_migration.rs | 4 +- .../tui/src/onboarding/onboarding_screen.rs | 6 +- codex-rs/tui/src/pager_overlay.rs | 4 +- codex-rs/tui/src/resume_picker.rs | 3 +- codex-rs/tui/src/startup_draft.rs | 9 +- codex-rs/tui/src/startup_hooks_review.rs | 6 +- codex-rs/tui/src/tui.rs | 10 +- codex-rs/tui/src/tui/event_stream.rs | 45 ++- codex-rs/tui/src/tui/screen_size.rs | 15 +- codex-rs/tui/src/unarchive_prompt.rs | 10 +- codex-rs/tui/src/update_prompt.rs | 4 +- 25 files changed, 867 insertions(+), 34 deletions(-) create mode 100644 codex-rs/tui/src/app/recap.rs create mode 100644 codex-rs/tui/src/app/recap_tests.rs diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index d38ddafe06..5c94998346 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -226,6 +226,7 @@ mod permission_shortcuts; mod pets; mod platform_actions; mod plugin_mentions; +mod recap; mod replay_filter; mod resize_reflow; mod safety_buffering; @@ -633,6 +634,7 @@ pub(crate) struct App { // Serialize hook enablement writes per hook so stale completions cannot // persist an older toggle after a newer one. pending_hook_enabled_writes: HashMap>, + recap: recap::RecapState, } #[derive(Debug, Clone, PartialEq)] @@ -754,7 +756,10 @@ impl App { event: TuiEvent, ) -> Result { let screen_size = tui.screen_size_for_event(&event)?; - if !matches!(&event, TuiEvent::Key(_) | TuiEvent::Paste(_)) { + if !matches!( + &event, + TuiEvent::Key(_) | TuiEvent::Paste(_) | TuiEvent::FocusLost + ) { self.expire_pending_key_chord(); self.handle_draw_pre_render(tui, screen_size)?; } @@ -768,6 +773,16 @@ impl App { event }; + match &event { + TuiEvent::FocusLost => { + self.recap.note_focus_lost(Instant::now()); + } + TuiEvent::FocusGained => { + self.recap.note_focus_gained(); + } + _ => {} + } + if self.overlay.is_some() { let _ = self .handle_backtrack_overlay_event(tui, app_server, event) @@ -786,7 +801,7 @@ impl App { let pasted = pasted.replace("\r\n", "\n").replace('\r', "\n"); self.chat_widget.handle_paste(pasted); } - TuiEvent::Draw | TuiEvent::Resume | TuiEvent::Resize(_) => { + TuiEvent::Draw | TuiEvent::Resume | TuiEvent::Resize(_) | TuiEvent::FocusGained => { if self.backtrack_render_pending { self.rebuild_transcript_after_backtrack(tui, screen_size.into())?; self.backtrack_render_pending = false; @@ -839,6 +854,7 @@ impl App { self.app_event_tx.send(AppEvent::LaunchExternalEditor); } } + TuiEvent::FocusLost => {} } } Ok(AppRunControl::Continue) diff --git a/codex-rs/tui/src/app/recap.rs b/codex-rs/tui/src/app/recap.rs new file mode 100644 index 0000000000..9f56c8fb56 --- /dev/null +++ b/codex-rs/tui/src/app/recap.rs @@ -0,0 +1,225 @@ +//! Tracks when an unfocused conversation becomes eligible for an automatic recap. + +#![cfg_attr(not(test), allow(dead_code))] + +use std::sync::Arc; +use std::time::Duration; +use std::time::Instant; + +use crate::history_cell::AgentMarkdownCell; +use crate::history_cell::AgentMessageCell; +use crate::history_cell::HistoryCell; +use crate::history_cell::UserHistoryCell; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnStatus; + +const MIN_COMPLETED_TURNS: usize = 3; +const MIN_TURNS_BETWEEN_RECAPS: usize = 2; +const RECAP_DELAY: Duration = Duration::from_secs(/*secs*/ 3 * 60); +const RECAP_HISTORY_MAX_TURNS: usize = 8; +const RECAP_PROMPT_PREFIX: &str = concat!( + "Write a brief catch-up for a user returning to this Codex task. ", + "In at most 40 words and one or two plain-text sentences, explain the ", + "objective, what was completed or learned, and the next step or blocker. ", + "Mention changed files, tests, approvals, or requested decisions only ", + "when relevant. Never claim changes were made or tests passed unless ", + "the conversation confirms it. If the task is complete, say so instead ", + "of inventing more work. Use the user's language; omit greetings, ", + "markdown, lists, and tool chatter.\n\nRecent conversation:\n", +); +const RECAP_PROMPT_MAX_BYTES: usize = 900; + +fn render_recap_message(role: &str, content: &str, max_bytes: usize) -> Option { + let prefix = format!("{role}: "); + let content_budget = max_bytes.checked_sub(prefix.len())?; + let end = content.floor_char_boundary(content_budget.min(content.len())); + Some(format!("{prefix}{}", &content[..end])) +} + +fn recap_history(cells: &[Arc]) -> String { + let mut messages = Vec::new(); + let mut user_turns = 0; + + for cell in cells.iter().rev() { + let is_user = cell.as_any().is::(); + + let role = if is_user { + "User" + } else if cell.as_any().is::() || cell.as_any().is::() + { + "Assistant" + } else { + continue; + }; + + let content = cell + .raw_lines() + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"); + + let content = content.trim(); + if content.is_empty() { + continue; + } + + messages.push((role, content.to_string())); + + if is_user { + user_turns += 1; + if user_turns == RECAP_HISTORY_MAX_TURNS { + break; + } + } + } + + messages.reverse(); + if messages.is_empty() { + return String::new(); + } + let byte_budget = RECAP_PROMPT_MAX_BYTES.saturating_sub(RECAP_PROMPT_PREFIX.len()); + let latest = messages + .iter() + .rposition(|(r, _)| *r == "User") + .unwrap_or(messages.len() - 1); + // Reserve half the budget for the latest request, then fill from newest to oldest. + let latest_user_budget = byte_budget / 2; + let (role, content) = &messages[latest]; + let latest_user = render_recap_message(role, content, latest_user_budget).unwrap_or_default(); + let mut selected = vec![(latest, latest_user)]; + let mut remaining = byte_budget.saturating_sub(selected[0].1.len()); + for (index, (role, content)) in messages.iter().enumerate().rev() { + if index == latest || remaining <= 2 { + continue; + } + + let Some(rendered) = render_recap_message(role, content, remaining - 2) else { + continue; + }; + remaining = remaining.saturating_sub(rendered.len() + 2); + selected.push((index, rendered)); + } + + selected.sort_unstable_by_key(|(index, _)| *index); + selected + .into_iter() + .map(|(_, message)| message) + .collect::>() + .join("\n\n") +} + +fn recap_prompt(history: &str) -> String { + format!("{RECAP_PROMPT_PREFIX}{}", history.trim()) +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(super) struct RecapProgress { + pub(super) completed_turns: usize, + pub(super) last_recapped_turn_count: Option, +} + +impl RecapProgress { + pub(super) fn from_turns(turns: &[Turn]) -> Self { + Self { + completed_turns: turns + .iter() + .filter(|turn| matches!(turn.status, TurnStatus::Completed)) + .count(), + last_recapped_turn_count: None, + } + } + + pub(super) fn merge(&mut self, other: Self) { + self.completed_turns = self.completed_turns.max(other.completed_turns); + self.last_recapped_turn_count = self + .last_recapped_turn_count + .max(other.last_recapped_turn_count); + } +} + +#[derive(Debug, Default)] +pub(super) struct RecapState { + unfocused_since: Option, + last_completed_turn_at: Option, + completed_turns: usize, + last_recapped_turn_count: Option, +} + +impl RecapState { + pub(super) fn seed_from_turns(&mut self, turns: &[Turn], now: Instant) { + self.seed_from_progress(RecapProgress::from_turns(turns), now); + } + + pub(super) fn seed_from_progress(&mut self, progress: RecapProgress, now: Instant) { + self.completed_turns = self.completed_turns.max(progress.completed_turns); + self.last_recapped_turn_count = self + .last_recapped_turn_count + .max(progress.last_recapped_turn_count); + + if progress.completed_turns > 0 { + self.last_completed_turn_at.get_or_insert(now); + } + } + + pub(super) fn progress(&self) -> RecapProgress { + RecapProgress { + completed_turns: self.completed_turns, + last_recapped_turn_count: self.last_recapped_turn_count, + } + } + + pub(super) fn reset_for_new_thread(&mut self, now: Instant) { + let unfocused_since = self.unfocused_since.map(|_| now); + *self = Self { + unfocused_since, + ..Self::default() + }; + } + + pub(super) fn note_focus_lost(&mut self, now: Instant) { + self.unfocused_since.get_or_insert(now); + } + + pub(super) fn note_focus_gained(&mut self) { + self.unfocused_since = None; + } + + pub(super) fn note_turn_completed(&mut self, now: Instant) { + self.completed_turns += 1; + self.last_completed_turn_at = Some(now); + } + + pub(super) fn should_generate(&self, now: Instant) -> bool { + self.next_check_deadline() + .is_some_and(|deadline| now >= deadline) + } + + pub(super) fn mark_recapped(&mut self, completed_turn_count: usize) { + self.last_recapped_turn_count = Some(completed_turn_count); + } + + fn next_check_deadline(&self) -> Option { + let unfocused_since = self.unfocused_since?; + + if self.completed_turns < MIN_COMPLETED_TURNS { + return None; + } + + if self.last_recapped_turn_count.is_some_and(|previous| { + self.completed_turns.saturating_sub(previous) < MIN_TURNS_BETWEEN_RECAPS + }) { + return None; + } + + let last_completed_turn_at = self.last_completed_turn_at?; + + unfocused_since + .max(last_completed_turn_at) + .checked_add(RECAP_DELAY) + } +} + +#[cfg(test)] +#[path = "recap_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/app/recap_tests.rs b/codex-rs/tui/src/app/recap_tests.rs new file mode 100644 index 0000000000..0fc953ddfa --- /dev/null +++ b/codex-rs/tui/src/app/recap_tests.rs @@ -0,0 +1,376 @@ +use super::RECAP_DELAY; +use super::RECAP_HISTORY_MAX_TURNS; +use super::RECAP_PROMPT_MAX_BYTES; +use super::RecapProgress; +use super::RecapState; +use super::recap_history; +use super::recap_prompt; +use crate::history_cell::AgentMarkdownCell; +use crate::history_cell::AgentMessageCell; +use crate::history_cell::HistoryCell; +use crate::history_cell::PlainHistoryCell; +use crate::history_cell::ThreadRecapHistoryCell; +use crate::history_cell::UserHistoryCell; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnStatus; +use pretty_assertions::assert_eq; +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; +use std::time::Instant; + +fn turn(status: TurnStatus) -> Turn { + Turn { + id: "turn".to_string(), + items: Vec::new(), + items_view: Default::default(), + status, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + } +} + +fn user_history_cell(message: &str) -> Arc { + Arc::new(UserHistoryCell { + message: message.to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), + remote_image_urls: Vec::new(), + }) +} + +fn assistant_history_cell(message: &str) -> Arc { + Arc::new(AgentMarkdownCell::new(message.to_string(), Path::new("."))) +} + +#[test] +fn recap_history_preserves_chronological_user_and_assistant_messages() { + let cells = vec![ + user_history_cell("First request"), + assistant_history_cell("First response"), + user_history_cell("Second request"), + Arc::new(AgentMessageCell::new( + vec!["Streaming response".into()], + /*is_first_line*/ true, + )), + ]; + + assert_eq!( + recap_history(&cells), + "User: First request\n\nAssistant: First response\n\nUser: Second request\n\nAssistant: Streaming response" + ); +} + +#[test] +fn recap_history_keeps_only_the_most_recent_eight_user_turns() { + let total_turns = RECAP_HISTORY_MAX_TURNS + 2; + let mut cells = Vec::new(); + + for index in 0..total_turns { + cells.push(user_history_cell(&format!("question-{index}"))); + cells.push(assistant_history_cell(&format!("answer-{index}"))); + } + + let expected = (2..total_turns) + .map(|index| format!("User: question-{index}\n\nAssistant: answer-{index}")) + .collect::>() + .join("\n\n"); + + assert_eq!(recap_history(&cells), expected); +} + +#[test] +fn recap_history_ignores_activity_previous_recaps_and_empty_messages() { + let cells = vec![ + user_history_cell(" "), + Arc::new(PlainHistoryCell::new(vec!["tool output".into()])), + user_history_cell("Implement recap"), + assistant_history_cell(" \n "), + Arc::new(ThreadRecapHistoryCell::new("Previous recap".to_string())), + assistant_history_cell("Done"), + ]; + + assert_eq!( + recap_history(&cells), + "User: Implement recap\n\nAssistant: Done" + ); +} + +#[test] +fn recap_history_preserves_latest_user_turn_when_latest_response_is_oversized() { + let cells = vec![ + user_history_cell("Keep this latest request"), + assistant_history_cell(&"🦀".repeat(RECAP_PROMPT_MAX_BYTES * 2)), + ]; + let prompt = recap_prompt(&recap_history(&cells)); + + assert!(prompt.len() <= RECAP_PROMPT_MAX_BYTES); + assert!(prompt.contains("User: Keep this latest request")); + assert!(prompt.contains("Assistant: 🦀")); +} + +#[test] +fn recap_history_caps_utf8_bytes_without_splitting_characters() { + let cells = vec![user_history_cell( + &"最新の進捗🦀".repeat(RECAP_PROMPT_MAX_BYTES), + )]; + let history = recap_history(&cells); + + assert!(recap_prompt(&history).len() <= RECAP_PROMPT_MAX_BYTES); + assert!(history.starts_with("User: 最新の進捗🦀")); +} + +#[test] +fn recap_requires_focus_loss_and_three_completed_turns() { + let now = Instant::now(); + let mut state = RecapState::default(); + + state.note_turn_completed(now); + state.note_turn_completed(now); + assert!(!state.should_generate(now + RECAP_DELAY)); + + state.note_focus_lost(now); + assert!(!state.should_generate(now + RECAP_DELAY)); + + state.note_turn_completed(now); + assert!(state.should_generate(now + RECAP_DELAY)); +} + +#[test] +fn recap_waits_after_focus_loss_even_if_turn_completed_earlier() { + let started = Instant::now(); + let mut state = RecapState::default(); + + for _ in 0..3 { + state.note_turn_completed(started); + } + + let focus_lost = started + RECAP_DELAY; + state.note_focus_lost(focus_lost); + + assert!(!state.should_generate(focus_lost)); + assert!(!state.should_generate(focus_lost + RECAP_DELAY - Duration::from_secs(/*secs*/ 1))); + assert!(state.should_generate(focus_lost + RECAP_DELAY)); +} + +#[test] +fn completed_turn_resets_recap_deadline_while_unfocused() { + let focus_lost = Instant::now(); + let mut state = RecapState::default(); + state.note_focus_lost(focus_lost); + + for _ in 0..3 { + state.note_turn_completed(focus_lost); + } + + let last_completed_turn_at = focus_lost + Duration::from_secs(/*secs*/ 30); + state.note_turn_completed(last_completed_turn_at); + + assert!(!state.should_generate(focus_lost + RECAP_DELAY)); + assert!(state.should_generate(last_completed_turn_at + RECAP_DELAY)); +} + +#[test] +fn repeated_focus_loss_does_not_restart_recap_delay() { + let focus_lost = Instant::now(); + let mut state = RecapState::default(); + state.note_focus_lost(focus_lost); + + for _ in 0..3 { + state.note_turn_completed(focus_lost); + } + + state.note_focus_lost(focus_lost + Duration::from_secs(/*secs*/ 30)); + + assert!(state.should_generate(focus_lost + RECAP_DELAY)); +} + +#[test] +fn regaining_focus_prevents_recap_generation() { + let now = Instant::now(); + let mut state = RecapState::default(); + state.note_focus_lost(now); + + for _ in 0..3 { + state.note_turn_completed(now); + } + + state.note_focus_gained(); + + assert!(!state.should_generate(now + RECAP_DELAY)); +} + +#[test] +fn another_recap_requires_two_additional_completed_turns() { + let now = Instant::now(); + let mut state = RecapState::default(); + state.note_focus_lost(now); + state.seed_from_progress( + RecapProgress { + completed_turns: 3, + last_recapped_turn_count: None, + }, + now, + ); + state.mark_recapped(/*completed_turn_count*/ 3); + assert!(!state.should_generate(now + RECAP_DELAY)); + + let fourth_turn = now + RECAP_DELAY; + state.note_turn_completed(fourth_turn); + assert!(!state.should_generate(fourth_turn + RECAP_DELAY)); + + let fifth_turn = fourth_turn + Duration::from_secs(/*secs*/ 1); + state.note_turn_completed(fifth_turn); + + assert!(state.should_generate(fifth_turn + RECAP_DELAY)); +} + +#[test] +fn replacing_the_primary_thread_resets_progress_but_preserves_focus() { + let now = Instant::now(); + let mut state = RecapState::default(); + state.note_focus_lost(now); + state.seed_from_progress( + RecapProgress { + completed_turns: 3, + last_recapped_turn_count: Some(3), + }, + now, + ); + + let replaced_at = now + Duration::from_secs(/*secs*/ 30); + state.reset_for_new_thread(replaced_at); + + assert_eq!(state.progress(), RecapProgress::default()); + assert_eq!(state.unfocused_since, Some(replaced_at)); + assert!(!state.should_generate(replaced_at + RECAP_DELAY)); +} + +#[test] +fn restored_history_counts_only_completed_turns() { + let now = Instant::now(); + let mut state = RecapState::default(); + state.note_focus_lost(now); + state.seed_from_turns( + &[ + turn(TurnStatus::Completed), + turn(TurnStatus::Failed), + turn(TurnStatus::Completed), + turn(TurnStatus::Interrupted), + turn(TurnStatus::InProgress), + turn(TurnStatus::Completed), + ], + now, + ); + + assert_eq!(state.completed_turns, 3); + assert!(state.should_generate(now + RECAP_DELAY)); +} + +#[test] +fn restored_history_never_reduces_observed_completed_turns() { + let now = Instant::now(); + let mut state = RecapState::default(); + + for _ in 0..4 { + state.note_turn_completed(now); + } + + state.seed_from_turns( + &[ + turn(TurnStatus::Completed), + turn(TurnStatus::Completed), + turn(TurnStatus::Completed), + ], + now, + ); + + assert_eq!(state.completed_turns, 4); +} + +#[test] +fn recap_history_cell_uses_labeled_checkpoint_layout() { + let cell = + ThreadRecapHistoryCell::new("Automatic recaps stay compact on wide terminals.".to_string()); + let rendered = cell + .display_lines(/*width*/ 64) + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"); + + insta::assert_snapshot!(rendered, @r" + ─ Conversation recap ─────────────────────────────────────────── + + Automatic recaps stay compact on wide terminals. + "); +} + +#[test] +fn recap_history_cell_wraps_in_narrow_terminals() { + let cell = ThreadRecapHistoryCell::new( + "Keep conversation recaps readable in narrow terminals.".to_string(), + ); + let rendered = cell + .display_lines(/*width*/ 32) + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"); + + insta::assert_snapshot!(rendered, @r" + ─ Conversation recap ─────────── + + Keep conversation recaps + readable in narrow terminals. + "); +} + +#[test] +fn recap_history_cell_preserves_heading_in_raw_history() { + let cell = ThreadRecapHistoryCell::new("Resume this task.".to_string()); + let rendered = cell + .raw_lines() + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"); + + insta::assert_snapshot!(rendered, @r" + Conversation recap + Resume this task. + "); +} + +#[test] +fn recap_history_cell_preserves_explicit_line_breaks() { + let cell = ThreadRecapHistoryCell::new( + "Finished the parser.\nNext: run the focused tests.".to_string(), + ); + let displayed = cell + .display_lines(/*width*/ 48) + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"); + let raw = cell + .raw_lines() + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"); + + insta::assert_snapshot!(displayed, @r" + ─ Conversation recap ─────────────────────────── + + Finished the parser. + Next: run the focused tests. + "); + insta::assert_snapshot!(raw, @r" + Conversation recap + Finished the parser. + Next: run the focused tests. + "); +} diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index 00ad08325b..ab19aa2f66 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -530,6 +530,14 @@ impl App { &mut snapshot, ) .await; + // Refreshing can merge restored turns into the store, so recap progress must be read only + // after the refresh while the activated thread channel is still retained. + let Some(channel) = self.thread_event_channels.get(&thread_id) else { + self.chat_widget + .add_error_message(format!("Agent thread {thread_id} is no longer available.")); + return Ok(()); + }; + let recap_progress = channel.store.lock().await.recap_progress(); if snapshot.input_state.is_none() { snapshot.input_state = self.agents_overview.input_states.remove(&thread_id); } @@ -538,6 +546,14 @@ impl App { self.active_thread_id = Some(thread_id); self.active_thread_rx = Some(receiver); + self.recap = recap::RecapState::default(); + + if !tui.is_terminal_focused() { + self.recap.note_focus_lost(Instant::now()); + } + self.recap + .seed_from_progress(recap_progress, Instant::now()); + let init = self.chatwidget_init_for_forked_or_resumed_thread( tui, self.config.clone(), diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs index 75fb508bec..62e8d838ab 100644 --- a/codex-rs/tui/src/app/startup.rs +++ b/codex-rs/tui/src/app/startup.rs @@ -539,7 +539,11 @@ See the Codex keymap documentation for supported actions and examples." rate_limit_hard_stop_generation: 0, pending_plugin_enabled_writes: HashMap::new(), pending_hook_enabled_writes: HashMap::new(), + recap: recap::RecapState::default(), }; + if !tui.is_terminal_focused() { + app.recap.note_focus_lost(Instant::now()); + } if start_in_agents_overview { app.open_agents_overview(&app_server); } diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index d53932f869..82b833b33e 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -81,6 +81,7 @@ pub(super) async fn make_test_app() -> App { rate_limit_hard_stop_generation: 0, pending_plugin_enabled_writes: HashMap::new(), pending_hook_enabled_writes: HashMap::new(), + recap: recap::RecapState::default(), } } diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 125ecfa7d8..f5c76a0e13 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -5470,6 +5470,7 @@ async fn make_test_app() -> App { rate_limit_hard_stop_generation: 0, pending_plugin_enabled_writes: HashMap::new(), pending_hook_enabled_writes: HashMap::new(), + recap: recap::RecapState::default(), } } @@ -5550,6 +5551,7 @@ async fn make_test_app_with_channels() -> ( rate_limit_hard_stop_generation: 0, pending_plugin_enabled_writes: HashMap::new(), pending_hook_enabled_writes: HashMap::new(), + recap: recap::RecapState::default(), }, rx, op_rx, @@ -7711,6 +7713,13 @@ async fn refreshed_snapshot_session_persists_resumed_turns() { let store_snapshot = store.snapshot(); assert_eq!(store_snapshot.session, Some(resumed_session)); assert_eq!(store_snapshot.turns, snapshot.turns); + assert_eq!( + store.recap_progress(), + recap::RecapProgress { + completed_turns: 1, + last_recapped_turn_count: None, + } + ); } #[tokio::test] diff --git a/codex-rs/tui/src/app/thread_events.rs b/codex-rs/tui/src/app/thread_events.rs index 48a74ea7f7..32e2d9f368 100644 --- a/codex-rs/tui/src/app/thread_events.rs +++ b/codex-rs/tui/src/app/thread_events.rs @@ -50,6 +50,7 @@ pub(super) struct ThreadEventStore { pub(super) capacity: usize, pub(super) active: bool, pub(super) buffered_agent_message_delta_bytes: usize, + recap_progress: recap::RecapProgress, } impl ThreadEventStore { @@ -78,6 +79,7 @@ impl ThreadEventStore { capacity, active: false, buffered_agent_message_delta_bytes: 0, + recap_progress: recap::RecapProgress::default(), } } @@ -104,6 +106,8 @@ impl ThreadEventStore { } pub(super) fn set_turns(&mut self, turns: Vec) { + self.recap_progress + .merge(recap::RecapProgress::from_turns(&turns)); self.active_turn_id = turns .iter() .rev() @@ -128,6 +132,9 @@ impl ThreadEventStore { self.active_turn_id = Some(turn.turn.id.clone()); } ServerNotification::TurnCompleted(turn) => { + if matches!(turn.turn.status, TurnStatus::Completed) { + self.recap_progress.completed_turns += 1; + } if self.active_turn_id.as_deref() == Some(turn.turn.id.as_str()) { self.active_turn_id = None; } @@ -222,6 +229,14 @@ impl ThreadEventStore { } } + pub(super) fn recap_progress(&self) -> recap::RecapProgress { + self.recap_progress + } + + pub(super) fn merge_recap_progress(&mut self, progress: recap::RecapProgress) { + self.recap_progress.merge(progress); + } + pub(super) fn note_outbound_op(&mut self, op: T) where T: Into, @@ -556,6 +571,38 @@ mod tests { assert_eq!(store.active_turn_id(), None); } + #[test] + fn thread_event_store_preserves_recap_progress_across_replay() { + let thread_id = ThreadId::new(); + let mut store = ThreadEventStore::new(/*capacity*/ 8); + store.set_turns(vec![ + test_turn("turn-1", TurnStatus::Completed, Vec::new()), + test_turn("turn-2", TurnStatus::Failed, Vec::new()), + ]); + store.push_notification(turn_completed_notification( + thread_id, + "turn-3", + TurnStatus::Completed, + )); + store.push_notification(turn_completed_notification( + thread_id, + "turn-4", + TurnStatus::Interrupted, + )); + store.merge_recap_progress(recap::RecapProgress { + completed_turns: 2, + last_recapped_turn_count: Some(2), + }); + + assert_eq!( + store.recap_progress(), + recap::RecapProgress { + completed_turns: 2, + last_recapped_turn_count: Some(2), + } + ); + } + #[test] fn thread_event_store_restores_active_turn_from_snapshot_turns() { let thread_id = ThreadId::new(); diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index b88983c336..f7893be3ae 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -77,11 +77,13 @@ impl App { return; }; let input_state = self.chat_widget.capture_thread_input_state(); + let recap_progress = self.recap.progress(); if let Some(channel) = self.thread_event_channels.get_mut(&active_id) { let receiver = self.active_thread_rx.take(); let mut store = channel.store.lock().await; store.active = false; store.input_state = input_state; + store.merge_recap_progress(recap_progress); if let Some(receiver) = receiver { channel.receiver = Some(receiver); } @@ -959,6 +961,12 @@ impl App { if self.abandoned_side_threads.contains(&thread_id) { return Ok(()); } + if self.current_displayed_thread_id() == Some(thread_id) + && let ServerNotification::TurnCompleted(notification) = ¬ification + && matches!(notification.turn.status, TurnStatus::Completed) + { + self.recap.note_turn_completed(Instant::now()); + } let misalignment_policy_violation = match ¬ification { ServerNotification::Error(notification) if !notification.will_retry => { @@ -1286,6 +1294,9 @@ impl App { self.config.approvals_reviewer = session.approvals_reviewer; let thread_id = session.thread_id; + if self.primary_thread_id != Some(thread_id) { + self.recap.reset_for_new_thread(Instant::now()); + } self.primary_thread_id = Some(thread_id); self.primary_session_configured = Some(session.clone()); self.upsert_agent_picker_thread( @@ -1316,6 +1327,7 @@ impl App { self.app_event_tx .send(AppEvent::BeginInitialHistoryReplayBuffer); } + self.recap.seed_from_turns(&turns, Instant::now()); self.chat_widget .replay_thread_turns(turns, ReplayKind::ResumeInitialMessages); if should_buffer_initial_replay { diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 855e7c51c6..6f36701033 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -376,7 +376,7 @@ impl App { fn overlay_forward_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> { if matches!( &event, - TuiEvent::Draw | TuiEvent::Resume | TuiEvent::Resize(_) + TuiEvent::Draw | TuiEvent::Resume | TuiEvent::Resize(_) | TuiEvent::FocusGained ) && let Some(Overlay::Transcript(t)) = &mut self.overlay { let active_key = self.chat_widget.active_cell_transcript_key(); diff --git a/codex-rs/tui/src/cwd_prompt.rs b/codex-rs/tui/src/cwd_prompt.rs index 9232e8d39d..9d54384479 100644 --- a/codex-rs/tui/src/cwd_prompt.rs +++ b/codex-rs/tui/src/cwd_prompt.rs @@ -133,8 +133,8 @@ pub(crate) async fn run_cwd_selection_prompt( tui.screen_size_for_event(&event)?; match event { TuiEvent::Key(key_event) => screen.handle_key(key_event), - TuiEvent::Paste(_) => {} - TuiEvent::Draw | TuiEvent::Resume | TuiEvent::Resize(_) => { + TuiEvent::Paste(_) | TuiEvent::FocusLost => {} + TuiEvent::Draw | TuiEvent::Resume | TuiEvent::Resize(_) | TuiEvent::FocusGained => { tui.draw(u16::MAX, |frame| { frame.render_widget_ref(&screen, frame.area()); })?; diff --git a/codex-rs/tui/src/external_agent_config_migration/mod.rs b/codex-rs/tui/src/external_agent_config_migration/mod.rs index 077ef33637..a80fe03af1 100644 --- a/codex-rs/tui/src/external_agent_config_migration/mod.rs +++ b/codex-rs/tui/src/external_agent_config_migration/mod.rs @@ -108,8 +108,8 @@ pub(crate) async fn run_external_agent_config_migration_prompt( let _ = tui.screen_size_for_event(&event); match event { TuiEvent::Key(key_event) => screen.handle_key(key_event), - TuiEvent::Paste(_) => {} - TuiEvent::Draw | TuiEvent::Resume | TuiEvent::Resize(_) => { + TuiEvent::Paste(_) | TuiEvent::FocusLost => {} + TuiEvent::Draw | TuiEvent::Resume | TuiEvent::Resize(_) | TuiEvent::FocusGained => { let _ = tui.draw(u16::MAX, |frame| { frame.render_widget_ref(&screen, frame.area()); }); diff --git a/codex-rs/tui/src/external_agent_config_migration/source.rs b/codex-rs/tui/src/external_agent_config_migration/source.rs index d98aa40a48..bc01ad78c3 100644 --- a/codex-rs/tui/src/external_agent_config_migration/source.rs +++ b/codex-rs/tui/src/external_agent_config_migration/source.rs @@ -65,8 +65,8 @@ pub(crate) async fn run_external_agent_config_source_prompt( let _ = tui.screen_size_for_event(&event); match event { TuiEvent::Key(key_event) => screen.handle_key(key_event), - TuiEvent::Paste(_) => {} - TuiEvent::Draw | TuiEvent::Resume | TuiEvent::Resize(_) => { + TuiEvent::Paste(_) | TuiEvent::FocusLost => {} + TuiEvent::Draw | TuiEvent::Resume | TuiEvent::Resize(_) | TuiEvent::FocusGained => { let _ = tui.draw(u16::MAX, |frame| { frame.render_widget_ref(&screen, frame.area()); }); diff --git a/codex-rs/tui/src/history_cell/notices.rs b/codex-rs/tui/src/history_cell/notices.rs index f1453d8288..ca360c9b5f 100644 --- a/codex-rs/tui/src/history_cell/notices.rs +++ b/codex-rs/tui/src/history_cell/notices.rs @@ -2,6 +2,9 @@ use super::*; +#[cfg_attr(not(test), allow(dead_code))] +const RECAP_HEADING: &str = "Conversation recap"; + #[cfg_attr(debug_assertions, allow(dead_code))] #[derive(Debug)] pub(crate) struct UpdateAvailableHistoryCell { @@ -226,3 +229,61 @@ pub(crate) fn new_error_event(message: String) -> PlainHistoryCell { let lines: Vec> = vec![vec![format!("■ {message}").red()].into()]; PlainHistoryCell { lines } } + +#[cfg_attr(not(test), allow(dead_code))] +#[derive(Debug)] +pub(crate) struct ThreadRecapHistoryCell { + recap: String, +} + +#[cfg_attr(not(test), allow(dead_code))] +impl ThreadRecapHistoryCell { + pub(crate) fn new(recap: String) -> Self { + Self { recap } + } +} + +impl HistoryCell for ThreadRecapHistoryCell { + fn display_lines(&self, width: u16) -> Vec> { + let width = usize::from(width); + let mut remaining_width = width; + let mut heading = Vec::new(); + + if remaining_width > 0 { + heading.push("─".dim()); + remaining_width -= 1; + } + if remaining_width > 0 { + heading.push(" ".dim()); + remaining_width -= 1; + } + + let (visible_heading, _suffix, heading_width) = + take_prefix_by_width(RECAP_HEADING, remaining_width); + if !visible_heading.is_empty() { + heading.push(visible_heading.bold()); + remaining_width -= heading_width; + } + if remaining_width > 0 { + heading.push(" ".dim()); + remaining_width -= 1; + } + if remaining_width > 0 { + heading.push("─".repeat(remaining_width).dim()); + } + + let wrap_width = width.saturating_sub(2).max(1); + let body = raw_lines_from_source(&self.recap); + let wrapped = adaptive_wrap_lines(body, RtOptions::new(wrap_width)); + let mut lines = vec![heading.into(), Line::default()]; + lines.extend(prefix_lines(wrapped, " ".into(), " ".into())); + + lines + } + + fn raw_lines(&self) -> Vec> { + let mut lines = vec![Line::from(RECAP_HEADING)]; + lines.extend(raw_lines_from_source(&self.recap)); + lines + } +} diff --git a/codex-rs/tui/src/model_migration.rs b/codex-rs/tui/src/model_migration.rs index c82f9717dd..094fb731b0 100644 --- a/codex-rs/tui/src/model_migration.rs +++ b/codex-rs/tui/src/model_migration.rs @@ -154,8 +154,8 @@ pub(crate) async fn run_model_migration_prompt( let _ = alt.tui.screen_size_for_event(&event); match event { TuiEvent::Key(key_event) => screen.handle_key(key_event), - TuiEvent::Paste(_) => {} - TuiEvent::Draw | TuiEvent::Resume | TuiEvent::Resize(_) => { + TuiEvent::Paste(_) | TuiEvent::FocusLost => {} + TuiEvent::Draw | TuiEvent::Resume | TuiEvent::Resize(_) | TuiEvent::FocusGained => { let _ = alt.tui.draw(u16::MAX, |frame| { frame.render_widget_ref(&screen, frame.area()); }); diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index 9ef2a74dca..39e06b8b1b 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -541,7 +541,10 @@ pub(crate) async fn run_onboarding_app( TuiEvent::Paste(text) => { onboarding_screen.handle_paste(text); } - TuiEvent::Draw | TuiEvent::Resume | TuiEvent::Resize(_) => { + TuiEvent::Draw + | TuiEvent::Resume + | TuiEvent::Resize(_) + | TuiEvent::FocusGained => { if !did_full_clear_after_success && onboarding_screen.steps.iter().any(|step| { if let Step::Auth(w) = step { @@ -576,6 +579,7 @@ pub(crate) async fn run_onboarding_app( frame.render_widget_ref(&onboarding_screen, frame.area()); }); } + TuiEvent::FocusLost => {} } } } diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index 6430b0839e..1dc90e63d8 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -922,7 +922,7 @@ impl TranscriptOverlay { } other => self.view.handle_key_event(tui, other), }, - TuiEvent::Draw | TuiEvent::Resume | TuiEvent::Resize(_) => { + TuiEvent::Draw | TuiEvent::Resume | TuiEvent::Resize(_) | TuiEvent::FocusGained => { tui.draw(u16::MAX, |frame| { self.render(frame.area(), frame.buffer); })?; @@ -996,7 +996,7 @@ impl StaticOverlay { } other => self.view.handle_key_event(tui, other), }, - TuiEvent::Draw | TuiEvent::Resume | TuiEvent::Resize(_) => { + TuiEvent::Draw | TuiEvent::Resume | TuiEvent::Resize(_) | TuiEvent::FocusGained => { tui.draw(u16::MAX, |frame| { self.render(frame.area(), frame.buffer); })?; diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 1f2f7d3e8e..353bfa65ed 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -576,7 +576,7 @@ async fn run_session_picker_with_loader( TuiEvent::Paste(pasted) => { state.handle_paste(pasted); } - TuiEvent::Draw | TuiEvent::Resume | TuiEvent::Resize(_) => { + TuiEvent::Draw | TuiEvent::Resume | TuiEvent::Resize(_) | TuiEvent::FocusGained => { let list_width = list_viewport_width(screen_size.width); let list_height = usize::from(screen_size.height.saturating_sub(PICKER_CHROME_HEIGHT)); @@ -587,6 +587,7 @@ async fn run_session_picker_with_loader( state.open_pending_transcript_if_ready(); } } + TuiEvent::FocusLost => {} } } Some(event) = background_events.next() => { diff --git a/codex-rs/tui/src/startup_draft.rs b/codex-rs/tui/src/startup_draft.rs index cd599b9eda..e4284a5812 100644 --- a/codex-rs/tui/src/startup_draft.rs +++ b/codex-rs/tui/src/startup_draft.rs @@ -304,13 +304,17 @@ impl StartupDraftPump { return Ok(()); } TuiEvent::Paste(text) => !text.is_empty(), - TuiEvent::Draw | TuiEvent::Resize(_) | TuiEvent::Resume => { + TuiEvent::Draw | TuiEvent::Resize(_) | TuiEvent::Resume | TuiEvent::FocusGained => { self.pending_paste_newline = Some((started_at, newlines)); if self.initial_screen == StartupDraftInitialScreen::Composer { self.draw(tui, screen_size)?; } return Ok(()); } + TuiEvent::FocusLost => { + self.pending_paste_newline = Some((started_at, newlines)); + return Ok(()); + } TuiEvent::Key(_) => false, }; if continues_paste && started_at.elapsed() <= STARTUP_PASTE_NEWLINE_TIMEOUT { @@ -358,7 +362,8 @@ impl StartupDraftPump { self.bottom_pane.handle_paste(text); } } - TuiEvent::Draw | TuiEvent::Resize(_) | TuiEvent::Resume => {} + TuiEvent::Draw | TuiEvent::Resize(_) | TuiEvent::Resume | TuiEvent::FocusGained => {} + TuiEvent::FocusLost => return Ok(()), } if self.initial_screen == StartupDraftInitialScreen::Composer { self.draw(tui, screen_size)?; diff --git a/codex-rs/tui/src/startup_hooks_review.rs b/codex-rs/tui/src/startup_hooks_review.rs index d6967527c2..a4b40a008b 100644 --- a/codex-rs/tui/src/startup_hooks_review.rs +++ b/codex-rs/tui/src/startup_hooks_review.rs @@ -181,8 +181,10 @@ async fn run_startup_hooks_review_app( } } } - TuiEvent::Paste(_) => {} - TuiEvent::Draw | TuiEvent::Resume | TuiEvent::Resize(_) => draw_view(tui, &view)?, + TuiEvent::Paste(_) | TuiEvent::FocusLost => {} + TuiEvent::Draw | TuiEvent::Resume | TuiEvent::Resize(_) | TuiEvent::FocusGained => { + draw_view(tui, &view)?; + } } } } diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 4ae3d7b93d..814e01d844 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -576,6 +576,10 @@ pub enum TuiEvent { /// The app refreshes terminal geometry for this draw because resize events are not delivered /// while the process is suspended. Resume, + /// A terminal focus notification indicating that the terminal or tab became active. + FocusGained, + /// A terminal focus notification indicating that the terminal or tab became inactive. + FocusLost, } pub struct Tui { @@ -672,6 +676,10 @@ impl Tui { self.notification_condition = condition; } + pub(crate) fn is_terminal_focused(&self) -> bool { + self.terminal_focused.load(Ordering::Relaxed) + } + pub fn frame_requester(&self) -> FrameRequester { self.frame_requester.clone() } @@ -775,7 +783,7 @@ impl Tui { /// Emit a desktop notification now if the terminal is unfocused. /// Returns true if a notification was posted. pub fn notify(&mut self, message: impl AsRef) -> bool { - let terminal_focused = self.terminal_focused.load(Ordering::Relaxed); + let terminal_focused = self.is_terminal_focused(); if !should_emit_notification(self.notification_condition, terminal_focused) { return false; } diff --git a/codex-rs/tui/src/tui/event_stream.rs b/codex-rs/tui/src/tui/event_stream.rs index da972c87e7..de4ceae632 100644 --- a/codex-rs/tui/src/tui/event_stream.rs +++ b/codex-rs/tui/src/tui/event_stream.rs @@ -190,7 +190,7 @@ impl TuiEventStream { /// a mapped event, hits `Pending`, or sees EOF/error. When the broker is paused, it drops /// the underlying stream and returns `Pending` to fully release stdin. pub fn poll_crossterm_event(&mut self, cx: &mut Context<'_>) -> Poll> { - // Some crossterm events map to None (e.g. FocusLost, mouse); loop so we keep polling + // Some crossterm events map to None (e.g. mouse); loop so we keep polling // until we return a mapped event, hit Pending, or see EOF/error. loop { let poll_result = { @@ -275,11 +275,11 @@ impl TuiEventStream { self.terminal_focused.store(true, Ordering::Relaxed); // Keep the startup-cached palette: querying terminal colors here blocks the // input loop, and a direct probe would discard keys typed during the refresh. - Some(TuiEvent::Draw) + Some(TuiEvent::FocusGained) } Event::FocusLost => { self.terminal_focused.store(false, Ordering::Relaxed); - None + Some(TuiEvent::FocusLost) } _ => None, } @@ -323,6 +323,8 @@ mod tests { use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; + use crossterm::event::MouseEvent; + use crossterm::event::MouseEventKind; use pretty_assertions::assert_eq; use std::task::Context; use std::task::Poll; @@ -419,7 +421,12 @@ mod tests { let (broker, handle, _draw_tx, draw_rx, terminal_focused) = setup(); let mut stream = make_stream(broker, draw_rx, terminal_focused); - handle.send(Ok(Event::FocusLost)); + handle.send(Ok(Event::Mouse(MouseEvent { + kind: MouseEventKind::Moved, + column: 0, + row: 0, + modifiers: KeyModifiers::NONE, + }))); handle.send(Ok(Event::Key(KeyEvent::new( KeyCode::Char('a'), KeyModifiers::NONE, @@ -434,6 +441,34 @@ mod tests { } } + #[tokio::test(flavor = "current_thread")] + async fn focus_lost_is_forwarded_and_updates_terminal_state() { + let (broker, handle, _draw_tx, draw_rx, terminal_focused) = setup(); + let mut stream = make_stream(broker, draw_rx, terminal_focused.clone()); + + handle.send(Ok(Event::FocusLost)); + + assert!(matches!(stream.next().await, Some(TuiEvent::FocusLost))); + assert!(!terminal_focused.load(Ordering::Relaxed)); + } + + #[tokio::test(flavor = "current_thread")] + async fn focus_lost_consumed_by_startup_remains_visible_to_the_tui() { + let (broker, handle, _draw_tx, draw_rx, terminal_focused) = setup(); + let mut tui = crate::tui::test_support::make_test_tui().expect("test tui"); + tui.terminal_focused = terminal_focused.clone(); + let mut startup_events = make_stream(broker, draw_rx, terminal_focused); + + assert!(tui.is_terminal_focused()); + handle.send(Ok(Event::FocusLost)); + + assert!(matches!( + startup_events.next().await, + Some(TuiEvent::FocusLost) + )); + assert!(!tui.is_terminal_focused()); + } + #[tokio::test(flavor = "current_thread")] async fn focus_gained_preserves_already_queued_key() { let (broker, handle, _draw_tx, draw_rx, terminal_focused) = setup(); @@ -444,7 +479,7 @@ mod tests { handle.send(Ok(Event::FocusGained)); handle.send(Ok(Event::Key(expected_key))); - assert!(matches!(stream.next().await, Some(TuiEvent::Draw))); + assert!(matches!(stream.next().await, Some(TuiEvent::FocusGained))); assert!(terminal_focused.load(Ordering::Relaxed)); assert!(matches!( &*broker diff --git a/codex-rs/tui/src/tui/screen_size.rs b/codex-rs/tui/src/tui/screen_size.rs index c08709cc77..c9f657886e 100644 --- a/codex-rs/tui/src/tui/screen_size.rs +++ b/codex-rs/tui/src/tui/screen_size.rs @@ -22,7 +22,7 @@ impl Tui { pub(crate) fn screen_size_for_event(&mut self, event: &TuiEvent) -> io::Result { if matches!(event, TuiEvent::Resize(_)) { self.schedule_screen_size_recheck(TRANSCRIPT_REFLOW_DEBOUNCE); - } else if matches!(event, TuiEvent::Draw) + } else if matches!(event, TuiEvent::Draw | TuiEvent::FocusGained) && let Some(deadline) = self.screen_size.pending_recheck_at { let now = Instant::now(); @@ -44,11 +44,16 @@ impl Tui { self.screen_size.deferred_size = None; self.terminal.size()? } - TuiEvent::Draw => self.screen_size.deferred_size.take().unwrap_or(cached), - TuiEvent::Key(_) | TuiEvent::Paste(_) => cached, + TuiEvent::Draw | TuiEvent::FocusGained => { + self.screen_size.deferred_size.take().unwrap_or(cached) + } + TuiEvent::Key(_) | TuiEvent::Paste(_) | TuiEvent::FocusLost => cached, }; - self.screen_size.pending_draw_size = - (!matches!(event, TuiEvent::Key(_) | TuiEvent::Paste(_))).then_some(size); + self.screen_size.pending_draw_size = (!matches!( + event, + TuiEvent::Key(_) | TuiEvent::Paste(_) | TuiEvent::FocusLost + )) + .then_some(size); Ok(size) } diff --git a/codex-rs/tui/src/unarchive_prompt.rs b/codex-rs/tui/src/unarchive_prompt.rs index b44d08d707..3f51822762 100644 --- a/codex-rs/tui/src/unarchive_prompt.rs +++ b/codex-rs/tui/src/unarchive_prompt.rs @@ -41,8 +41,8 @@ pub(crate) async fn run_unarchive_prompt( .discard_pending_input_before_interactive_screen()?; let events = guard.tui.event_stream(); tokio::pin!(events); + guard.draw(&screen)?; loop { - guard.draw(&screen)?; let Some(event) = events.next().await else { return Ok(UnarchiveChoice::Cancel); }; @@ -53,8 +53,14 @@ pub(crate) async fn run_unarchive_prompt( return Ok(choice); } } - TuiEvent::Paste(_) | TuiEvent::Draw | TuiEvent::Resize(_) | TuiEvent::Resume => {} + TuiEvent::Paste(_) + | TuiEvent::Draw + | TuiEvent::Resize(_) + | TuiEvent::Resume + | TuiEvent::FocusGained => {} + TuiEvent::FocusLost => continue, } + guard.draw(&screen)?; } } diff --git a/codex-rs/tui/src/update_prompt.rs b/codex-rs/tui/src/update_prompt.rs index ae8303124b..688d113487 100644 --- a/codex-rs/tui/src/update_prompt.rs +++ b/codex-rs/tui/src/update_prompt.rs @@ -59,8 +59,8 @@ pub(crate) async fn run_update_prompt_if_needed( tui.screen_size_for_event(&event)?; match event { TuiEvent::Key(key_event) => screen.handle_key(key_event), - TuiEvent::Paste(_) => {} - TuiEvent::Draw | TuiEvent::Resume | TuiEvent::Resize(_) => { + TuiEvent::Paste(_) | TuiEvent::FocusLost => {} + TuiEvent::Draw | TuiEvent::Resume | TuiEvent::Resize(_) | TuiEvent::FocusGained => { tui.draw(u16::MAX, |frame| { frame.render_widget_ref(&screen, frame.area()); })?;