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
This commit is contained in:
Felipe Coury
2026-08-25 20:51:34 +00:00
committed by copyberry
parent 725b3a44f8
commit 7c1e36c23f
25 changed files with 867 additions and 34 deletions
+18 -2
View File
@@ -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<String, Option<bool>>,
recap: recap::RecapState,
}
#[derive(Debug, Clone, PartialEq)]
@@ -754,7 +756,10 @@ impl App {
event: TuiEvent,
) -> Result<AppRunControl> {
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)
+225
View File
@@ -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<String> {
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<dyn HistoryCell>]) -> String {
let mut messages = Vec::new();
let mut user_turns = 0;
for cell in cells.iter().rev() {
let is_user = cell.as_any().is::<UserHistoryCell>();
let role = if is_user {
"User"
} else if cell.as_any().is::<AgentMarkdownCell>() || cell.as_any().is::<AgentMessageCell>()
{
"Assistant"
} else {
continue;
};
let content = cell
.raw_lines()
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.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::<Vec<_>>()
.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<usize>,
}
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<Instant>,
last_completed_turn_at: Option<Instant>,
completed_turns: usize,
last_recapped_turn_count: Option<usize>,
}
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<Instant> {
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;
+376
View File
@@ -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<dyn HistoryCell> {
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<dyn HistoryCell> {
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::<Vec<_>>()
.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::<Vec<_>>()
.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::<Vec<_>>()
.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::<Vec<_>>()
.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::<Vec<_>>()
.join("\n");
let raw = cell
.raw_lines()
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.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.
");
}
+16
View File
@@ -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(),
+4
View File
@@ -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);
}
+1
View File
@@ -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(),
}
}
+9
View File
@@ -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]
+47
View File
@@ -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<Turn>) {
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<T>(&mut self, op: T)
where
T: Into<AppCommand>,
@@ -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();
+12
View File
@@ -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) = &notification
&& matches!(notification.turn.status, TurnStatus::Completed)
{
self.recap.note_turn_completed(Instant::now());
}
let misalignment_policy_violation =
match &notification {
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 {
+1 -1
View File
@@ -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();
+2 -2
View File
@@ -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());
})?;
@@ -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());
});
@@ -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());
});
+61
View File
@@ -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<Line<'static>> = 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<Line<'static>> {
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<Line<'static>> {
let mut lines = vec![Line::from(RECAP_HEADING)];
lines.extend(raw_lines_from_source(&self.recap));
lines
}
}
+2 -2
View File
@@ -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());
});
@@ -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 => {}
}
}
}
+2 -2
View File
@@ -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);
})?;
+2 -1
View File
@@ -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() => {
+7 -2
View File
@@ -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)?;
+4 -2
View File
@@ -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)?;
}
}
}
}
+9 -1
View File
@@ -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<str>) -> 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;
}
+40 -5
View File
@@ -190,7 +190,7 @@ impl<S: EventSource + Default + Unpin> TuiEventStream<S> {
/// 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<Option<TuiEvent>> {
// 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<S: EventSource + Default + Unpin> TuiEventStream<S> {
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
+10 -5
View File
@@ -22,7 +22,7 @@ impl Tui {
pub(crate) fn screen_size_for_event(&mut self, event: &TuiEvent) -> io::Result<Size> {
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)
}
+8 -2
View File
@@ -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)?;
}
}
+2 -2
View File
@@ -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());
})?;