mirror of
https://github.com/openai/codex.git
synced 2026-10-21 03:20:49 +00:00
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:
+18
-2
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
");
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
})?;
|
||||
|
||||
@@ -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() => {
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)?;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
})?;
|
||||
|
||||
Reference in New Issue
Block a user