fix(tui): keep /copy aligned with rollback (#18739)

## Why

Fixes #18718.

After rewinding a thread, `/copy` could still copy the latest assistant
response from before the rewind. The transcript cells were rolled back,
but the copy source was a single `last_agent_markdown` cache that was
not synchronized with backtracking, so the visible conversation and
copied content could diverge.

## What changed

`ChatWidget` now keeps a bounded copy history for the most recent 32
assistant responses, keyed by the visible user-turn count. When local
rollback trims transcript cells, the copy cache is trimmed to the same
surviving user-turn count so `/copy` uses the latest visible assistant
response.

If the user rewinds past the retained copy window, `/copy` now reports:

```text
Cannot copy that response after rewinding. Only the most recent 32 responses are available to /copy.
```

The change also adds coverage for copying the latest surviving response
after rollback and for the over-limit rewind message.

## Verification

- Manually resumed a synthetic 35-turn session, rewound within the
retained window, and verified `/copy` copied the surviving response.
- Manually rewound past the retained window and verified `/copy` showed
the 32-response limit message.
- `cargo test -p codex-tui slash_copy`
- `just fix -p codex-tui`
- `cargo insta pending-snapshots`

Note: `cargo test -p codex-tui` currently fails on unrelated model
catalog and snapshot drift around the default model changing to
`gpt-5.4`; the focused `/copy` tests pass after fixing the new test
setup.
This commit is contained in:
Felipe Coury authored and GitHub committed 2026-04-20 19:24:10 -03:00
1 parent 46e5814f77
commit cebe57b723
4 files changed
+162 -7

No files matched your search

+69 -7
View File
@@ -473,6 +473,13 @@ fn is_standard_tool_call(parsed_cmd: &[ParsedCommand]) -> bool {
const RATE_LIMIT_WARNING_THRESHOLDS: [f64; 3] = [75.0, 90.0, 95.0];
const NUDGE_MODEL_SLUG: &str = "gpt-5.4-mini";
const RATE_LIMIT_SWITCH_PROMPT_THRESHOLD: f64 = 90.0;
const MAX_AGENT_COPY_HISTORY: usize = 32;
#[derive(Debug)]
struct AgentTurnMarkdown {
user_turn_count: usize,
markdown: String,
}
#[derive(Default)]
struct RateLimitWarningState {
@@ -795,13 +802,17 @@ pub(crate) struct ChatWidget {
plan_stream_controller: Option<PlanStreamController>,
/// Holds the platform clipboard lease so copied text remains available while supported.
clipboard_lease: Option<crate::clipboard_copy::ClipboardLease>,
/// Raw markdown of the most recently completed agent response.
///
/// This cache is intentionally best-effort: if the user rolls back the
/// thread and then copies before a replacement response arrives, `/copy`
/// may still return the response from before the rollback. Keeping this as
/// a single cache avoids coupling copy state to the backtrack transcript.
/// Raw markdown of the most recently completed agent response that
/// survived any local thread rollback.
last_agent_markdown: Option<String>,
/// Copyable agent responses keyed by the number of visible user turns at
/// the time the response completed.
agent_turn_markdowns: Vec<AgentTurnMarkdown>,
/// Number of user turns currently reflected in the visible transcript.
visible_user_turn_count: usize,
/// True when rollback discarded the requested copy source because it was
/// older than the retained copy history.
copy_history_evicted_by_rollback: bool,
/// Raw markdown of the most recently completed proposed plan.
///
/// This is cached only for the approval popup. It is reset at the start of each new task so the
@@ -2045,10 +2056,30 @@ impl ChatWidget {
if message.is_empty() {
return;
}
self.last_agent_markdown = Some(message.to_string());
let markdown = message.to_string();
match self.agent_turn_markdowns.last_mut() {
Some(entry) if entry.user_turn_count == self.visible_user_turn_count => {
entry.markdown = markdown.clone();
}
_ => {
self.agent_turn_markdowns.push(AgentTurnMarkdown {
user_turn_count: self.visible_user_turn_count,
markdown: markdown.clone(),
});
if self.agent_turn_markdowns.len() > MAX_AGENT_COPY_HISTORY {
self.agent_turn_markdowns.remove(0);
}
}
}
self.last_agent_markdown = Some(markdown);
self.copy_history_evicted_by_rollback = false;
self.saw_copy_source_this_turn = true;
}
fn record_visible_user_turn_for_copy(&mut self) {
self.visible_user_turn_count = self.visible_user_turn_count.saturating_add(1);
}
// --- Small event handlers ---
fn on_session_configured(&mut self, event: codex_protocol::protocol::SessionConfiguredEvent) {
self.on_session_configured_with_display(event, SessionConfiguredDisplay::Normal);
@@ -2060,6 +2091,9 @@ impl ChatWidget {
display: SessionConfiguredDisplay,
) {
self.last_agent_markdown = None;
self.agent_turn_markdowns.clear();
self.visible_user_turn_count = 0;
self.copy_history_evicted_by_rollback = false;
self.saw_copy_source_this_turn = false;
self.bottom_pane
.set_history_metadata(event.history_log_id, event.history_entry_count);
@@ -5064,6 +5098,9 @@ impl ChatWidget {
agent_turn_running: false,
mcp_startup_status: None,
last_agent_markdown: None,
agent_turn_markdowns: Vec::new(),
visible_user_turn_count: 0,
copy_history_evicted_by_rollback: false,
latest_proposed_plan_markdown: None,
saw_copy_source_this_turn: false,
mcp_startup_expected_servers: None,
@@ -5432,6 +5469,23 @@ impl ChatWidget {
self.copy_last_agent_markdown_with(crate::clipboard_copy::copy_to_clipboard);
}
pub(crate) fn truncate_agent_copy_history_to_user_turn_count(
&mut self,
user_turn_count: usize,
) {
self.visible_user_turn_count = user_turn_count;
let had_copy_history = !self.agent_turn_markdowns.is_empty();
self.agent_turn_markdowns
.retain(|entry| entry.user_turn_count <= user_turn_count);
self.last_agent_markdown = self
.agent_turn_markdowns
.last()
.map(|entry| entry.markdown.clone());
self.copy_history_evicted_by_rollback =
had_copy_history && self.last_agent_markdown.is_none();
self.saw_copy_source_this_turn = false;
}
/// Inner implementation with an injectable clipboard backend for testing.
fn copy_last_agent_markdown_with(
&mut self,
@@ -5450,6 +5504,11 @@ impl ChatWidget {
"Copy failed: {error}"
))),
},
_ if self.copy_history_evicted_by_rollback => {
self.add_to_history(history_cell::new_error_event(format!(
"Cannot copy that response after rewinding. Only the most recent {MAX_AGENT_COPY_HISTORY} responses are available to /copy."
)));
}
_ => self.add_to_history(history_cell::new_error_event(
"No agent response to copy".into(),
)),
@@ -5865,6 +5924,7 @@ impl ChatWidget {
local_image_paths.clone(),
remote_image_urls.clone(),
));
self.record_visible_user_turn_for_copy();
self.add_to_history(history_cell::new_user_prompt(
text,
text_elements,
@@ -5879,6 +5939,7 @@ impl ChatWidget {
Vec::new(),
remote_image_urls.clone(),
));
self.record_visible_user_turn_for_copy();
self.add_to_history(history_cell::new_user_prompt(
String::new(),
Vec::new(),
@@ -7248,6 +7309,7 @@ impl ChatWidget {
|| !event.text_elements.is_empty()
|| !remote_image_urls.is_empty()
{
self.record_visible_user_turn_for_copy();
self.add_to_history(history_cell::new_user_prompt(
event.message,
event.text_elements,