mirror of
https://github.com/openai/codex.git
synced 2026-10-20 00:18:49 +00:00
refactor(tui): remove rollback interruption path
This commit is contained in:
@@ -123,7 +123,6 @@ use codex_app_server_protocol::SkillsListResponse;
|
||||
use codex_app_server_protocol::ThreadItem;
|
||||
use codex_app_server_protocol::ThreadLoadedListParams;
|
||||
use codex_app_server_protocol::ThreadMemoryMode;
|
||||
use codex_app_server_protocol::ThreadRollbackResponse;
|
||||
use codex_app_server_protocol::ThreadStartSource;
|
||||
use codex_app_server_protocol::Turn;
|
||||
use codex_app_server_protocol::TurnError as AppServerTurnError;
|
||||
@@ -542,11 +541,6 @@ pub(crate) struct App {
|
||||
|
||||
// Esc-backtracking state grouped
|
||||
pub(crate) backtrack: crate::app_backtrack::BacktrackState,
|
||||
/// When set, the next draw rebuilds terminal scrollback from the retained transcript cells.
|
||||
///
|
||||
/// This is used after a confirmed thread rollback to ensure scrollback reflects the trimmed
|
||||
/// transcript cells.
|
||||
pub(crate) backtrack_render_pending: bool,
|
||||
pub(crate) feedback: codex_feedback::CodexFeedback,
|
||||
feedback_audience: FeedbackAudience,
|
||||
environment_manager: Arc<EnvironmentManager>,
|
||||
@@ -1043,7 +1037,6 @@ See the Codex keymap documentation for supported actions and examples."
|
||||
terminal_title_invalid_items_warned: terminal_title_invalid_items_warned.clone(),
|
||||
skill_load_warnings: SkillLoadWarningState::default(),
|
||||
backtrack: BacktrackState::default(),
|
||||
backtrack_render_pending: false,
|
||||
feedback: feedback.clone(),
|
||||
feedback_audience,
|
||||
environment_manager,
|
||||
@@ -1289,10 +1282,6 @@ See the Codex keymap documentation for supported actions and examples."
|
||||
self.chat_widget.handle_paste(pasted);
|
||||
}
|
||||
TuiEvent::Draw | TuiEvent::Resize => {
|
||||
if self.backtrack_render_pending {
|
||||
self.rebuild_transcript_after_backtrack(tui)?;
|
||||
self.backtrack_render_pending = false;
|
||||
}
|
||||
self.chat_widget.maybe_post_pending_notification(tui);
|
||||
if self
|
||||
.chat_widget
|
||||
|
||||
@@ -344,11 +344,6 @@ impl App {
|
||||
self.chat_widget.note_stream_consolidation_completed();
|
||||
self.insert_pending_usage_output_after_stream_shutdown(tui);
|
||||
}
|
||||
AppEvent::ApplyThreadRollback { num_turns } => {
|
||||
if self.apply_non_pending_thread_rollback(num_turns) {
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
}
|
||||
AppEvent::StartCommitAnimation => {
|
||||
if self
|
||||
.commit_anim_running
|
||||
@@ -417,9 +412,6 @@ impl App {
|
||||
)
|
||||
.await;
|
||||
}
|
||||
AppEvent::RestoreCancelledTurn(prompt) => {
|
||||
self.apply_cancelled_turn_edit(prompt);
|
||||
}
|
||||
AppEvent::AppendMessageHistoryEntry { thread_id, text } => {
|
||||
self.append_message_history_entry(thread_id, text);
|
||||
}
|
||||
|
||||
@@ -172,7 +172,6 @@ impl App {
|
||||
self.chat_widget.clear_pending_rate_limit_reset_hint();
|
||||
self.initial_history_replay_buffer = None;
|
||||
self.backtrack = BacktrackState::default();
|
||||
self.backtrack_render_pending = false;
|
||||
self.skill_load_warnings.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -423,35 +423,6 @@ impl App {
|
||||
Ok(terminal_width)
|
||||
}
|
||||
|
||||
/// Rebuild scrollback after rollback removes transcript cells.
|
||||
///
|
||||
/// Unlike resize reflow, rollback must clear the terminal even when no cells remain. Otherwise
|
||||
/// the cancelled user prompt stays visible in scrollback despite being removed from the source
|
||||
/// transcript.
|
||||
pub(super) fn rebuild_transcript_after_backtrack(&mut self, tui: &mut tui::Tui) -> Result<()> {
|
||||
let terminal_width = tui.terminal.size()?.width;
|
||||
let width = self.chat_widget.history_wrap_width(terminal_width);
|
||||
let reflowed_lines = if self.transcript_cells.is_empty() {
|
||||
self.reset_history_emission_state();
|
||||
Vec::new()
|
||||
} else {
|
||||
self.render_transcript_lines_for_reflow(width).lines
|
||||
};
|
||||
|
||||
tui.clear_pending_history_lines();
|
||||
self.clear_terminal_for_resize_replay(tui)?;
|
||||
|
||||
self.deferred_history_lines.clear();
|
||||
if !reflowed_lines.is_empty() {
|
||||
tui.insert_history_hyperlink_lines_with_wrap_policy(
|
||||
reflowed_lines,
|
||||
self.history_line_wrap_policy(),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Render transcript cells for the current resize rebuild.
|
||||
///
|
||||
/// Rendering walks backward from the transcript tail so row-capped sessions avoid formatting the
|
||||
|
||||
@@ -441,7 +441,7 @@ impl App {
|
||||
};
|
||||
self.chat_widget.add_info_message(message, /*hint*/ None);
|
||||
}
|
||||
self.drain_active_thread_events(tui).await?;
|
||||
self.drain_active_thread_events().await?;
|
||||
self.refresh_pending_thread_approvals().await;
|
||||
|
||||
Ok(())
|
||||
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
---
|
||||
source: tui/src/app/tests.rs
|
||||
expression: app.chat_widget.composer_text_with_pending()
|
||||
---
|
||||
edit me
|
||||
@@ -44,7 +44,6 @@ pub(super) async fn make_test_app() -> App {
|
||||
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
|
||||
skill_load_warnings: SkillLoadWarningState::default(),
|
||||
backtrack: BacktrackState::default(),
|
||||
backtrack_render_pending: false,
|
||||
feedback: codex_feedback::CodexFeedback::new(),
|
||||
feedback_audience: FeedbackAudience::External,
|
||||
environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
|
||||
|
||||
@@ -8,7 +8,6 @@ mod startup;
|
||||
use super::*;
|
||||
use crate::app::session_lifecycle::ThreadAttachPresentation;
|
||||
use crate::app_backtrack::BacktrackState;
|
||||
use crate::app_backtrack::user_count;
|
||||
|
||||
use crate::chatwidget::ChatWidgetInit;
|
||||
use crate::chatwidget::create_initial_user_message;
|
||||
@@ -41,7 +40,6 @@ use codex_app_server_protocol::AdditionalPermissionProfile;
|
||||
use codex_app_server_protocol::AgentMessageDeltaNotification;
|
||||
use codex_app_server_protocol::AskForApproval;
|
||||
use codex_app_server_protocol::CommandExecutionRequestApprovalParams;
|
||||
use codex_app_server_protocol::ConfigWarningNotification;
|
||||
use codex_app_server_protocol::FileChangeRequestApprovalParams;
|
||||
use codex_app_server_protocol::FileUpdateChange;
|
||||
use codex_app_server_protocol::ItemStartedNotification;
|
||||
@@ -60,7 +58,6 @@ use codex_app_server_protocol::PermissionsRequestApprovalParams;
|
||||
use codex_app_server_protocol::RequestId as AppServerRequestId;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_app_server_protocol::ServerRequest;
|
||||
use codex_app_server_protocol::SessionSource;
|
||||
use codex_app_server_protocol::Thread;
|
||||
use codex_app_server_protocol::ThreadClosedNotification;
|
||||
use codex_app_server_protocol::ThreadItem;
|
||||
@@ -4167,7 +4164,6 @@ async fn make_test_app() -> App {
|
||||
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
|
||||
skill_load_warnings: SkillLoadWarningState::default(),
|
||||
backtrack: BacktrackState::default(),
|
||||
backtrack_render_pending: false,
|
||||
feedback: codex_feedback::CodexFeedback::new(),
|
||||
feedback_audience: FeedbackAudience::External,
|
||||
environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
|
||||
@@ -4232,7 +4228,6 @@ async fn make_test_app_with_channels() -> (
|
||||
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
|
||||
skill_load_warnings: SkillLoadWarningState::default(),
|
||||
backtrack: BacktrackState::default(),
|
||||
backtrack_render_pending: false,
|
||||
feedback: codex_feedback::CodexFeedback::new(),
|
||||
feedback_audience: FeedbackAudience::External,
|
||||
environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
|
||||
@@ -5208,8 +5203,6 @@ async fn backtrack_selection_targets_predecessor_and_preserves_selected_prompt()
|
||||
agent_cell("answer edited"),
|
||||
];
|
||||
|
||||
assert_eq!(user_count(&app.transcript_cells), 2);
|
||||
|
||||
let base_id = ThreadId::new();
|
||||
app.chat_widget
|
||||
.handle_thread_session(crate::session_state::ThreadSessionState {
|
||||
@@ -5237,7 +5230,7 @@ async fn backtrack_selection_targets_predecessor_and_preserves_selected_prompt()
|
||||
|
||||
app.backtrack.base_id = Some(base_id);
|
||||
app.backtrack.primed = true;
|
||||
app.backtrack.nth_user_message = user_count(&app.transcript_cells).saturating_sub(1);
|
||||
app.backtrack.nth_user_message = 1;
|
||||
|
||||
let selection = app
|
||||
.confirm_backtrack_from_main()
|
||||
@@ -5318,62 +5311,6 @@ async fn backtrack_branch_failure_restores_selected_prompt() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelled_turn_edit_restores_prompt_and_rolls_back_latest_turn() {
|
||||
let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await;
|
||||
app.transcript_cells = vec![Arc::new(UserHistoryCell {
|
||||
turn_id: None,
|
||||
message: "original".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
local_image_paths: Vec::new(),
|
||||
remote_image_urls: Vec::new(),
|
||||
}) as Arc<dyn HistoryCell>];
|
||||
let prompt = crate::chatwidget::UserMessage {
|
||||
text: "edit me".to_string(),
|
||||
local_images: Vec::new(),
|
||||
remote_image_urls: vec!["https://example.com/edit.png".to_string()],
|
||||
text_elements: Vec::new(),
|
||||
mention_bindings: Vec::new(),
|
||||
};
|
||||
|
||||
app.apply_cancelled_turn_edit(prompt);
|
||||
|
||||
assert_eq!(app.chat_widget.composer_text_with_pending(), "edit me");
|
||||
assert_snapshot!(
|
||||
"cancelled_turn_edit_restores_composer",
|
||||
app.chat_widget.composer_text_with_pending()
|
||||
);
|
||||
assert_eq!(
|
||||
app.chat_widget.remote_image_urls(),
|
||||
vec!["https://example.com/edit.png".to_string()]
|
||||
);
|
||||
assert_matches!(op_rx.try_recv(), Ok(Op::ThreadRollback { num_turns: 1 }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn first_cancelled_turn_edit_restores_prompt_without_local_history() {
|
||||
let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await;
|
||||
let prompt = crate::chatwidget::UserMessage {
|
||||
text: "edit first prompt".to_string(),
|
||||
local_images: Vec::new(),
|
||||
remote_image_urls: vec!["https://example.com/edit.png".to_string()],
|
||||
text_elements: Vec::new(),
|
||||
mention_bindings: Vec::new(),
|
||||
};
|
||||
|
||||
app.apply_cancelled_turn_edit(prompt);
|
||||
|
||||
assert_eq!(
|
||||
app.chat_widget.composer_text_with_pending(),
|
||||
"edit first prompt"
|
||||
);
|
||||
assert_eq!(
|
||||
app.chat_widget.remote_image_urls(),
|
||||
vec!["https://example.com/edit.png".to_string()]
|
||||
);
|
||||
assert_matches!(op_rx.try_recv(), Ok(Op::ThreadRollback { num_turns: 1 }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backtrack_selection_preserves_data_image_urls() {
|
||||
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||||
@@ -5652,86 +5589,6 @@ async fn refreshed_snapshot_session_persists_resumed_turns() {
|
||||
assert_eq!(store_snapshot.turns, snapshot.turns);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn queued_rollback_syncs_overlay_and_clears_deferred_history() {
|
||||
let mut app = make_test_app().await;
|
||||
app.transcript_cells = vec![
|
||||
Arc::new(UserHistoryCell {
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
message: "first".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
local_image_paths: Vec::new(),
|
||||
remote_image_urls: Vec::new(),
|
||||
}) as Arc<dyn HistoryCell>,
|
||||
Arc::new(AgentMessageCell::new(
|
||||
vec![Line::from("after first")],
|
||||
/*is_first_line*/ false,
|
||||
)) as Arc<dyn HistoryCell>,
|
||||
Arc::new(UserHistoryCell {
|
||||
turn_id: Some("turn-2".to_string()),
|
||||
message: "second".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
local_image_paths: Vec::new(),
|
||||
remote_image_urls: Vec::new(),
|
||||
}) as Arc<dyn HistoryCell>,
|
||||
Arc::new(AgentMessageCell::new(
|
||||
vec![Line::from("after second")],
|
||||
/*is_first_line*/ false,
|
||||
)) as Arc<dyn HistoryCell>,
|
||||
];
|
||||
app.overlay = Some(Overlay::new_transcript(
|
||||
app.transcript_cells.clone(),
|
||||
app.keymap.pager.clone(),
|
||||
));
|
||||
app.deferred_history_lines = vec![Line::from("stale buffered line").into()];
|
||||
app.backtrack.overlay_preview_active = true;
|
||||
app.backtrack.nth_user_message = 1;
|
||||
app.chat_widget.update_account_state(
|
||||
/*status_account_display*/ None, /*plan_type*/ None,
|
||||
/*has_chatgpt_account*/ false, /*has_codex_backend_auth*/ true,
|
||||
);
|
||||
app.chat_widget
|
||||
.set_composer_text("/usage daily".to_string(), Vec::new(), Vec::new());
|
||||
app.chat_widget
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
app.chat_widget
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
app.chat_widget
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
let pending_usage = app
|
||||
.chat_widget
|
||||
.active_cell_transcript_lines(/*width*/ 80)
|
||||
.expect("pending usage transcript");
|
||||
assert!(lines_to_single_string(&pending_usage).contains("Token activity\n Loading..."));
|
||||
|
||||
let changed = app.apply_non_pending_thread_rollback(/*num_turns*/ 1);
|
||||
|
||||
assert!(changed);
|
||||
assert!(app.backtrack_render_pending);
|
||||
assert!(app.deferred_history_lines.is_empty());
|
||||
assert!(
|
||||
app.chat_widget
|
||||
.active_cell_transcript_lines(/*width*/ 80)
|
||||
.is_none_or(|lines| !lines_to_single_string(&lines).contains("Token activity"))
|
||||
);
|
||||
assert_eq!(app.backtrack.nth_user_message, 0);
|
||||
let user_messages: Vec<String> = app
|
||||
.transcript_cells
|
||||
.iter()
|
||||
.filter_map(|cell| {
|
||||
cell.as_any()
|
||||
.downcast_ref::<UserHistoryCell>()
|
||||
.map(|cell| cell.message.clone())
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(user_messages, vec!["first".to_string()]);
|
||||
let overlay_cell_count = match app.overlay.as_ref() {
|
||||
Some(Overlay::Transcript(t)) => t.committed_cell_count(),
|
||||
_ => panic!("expected transcript overlay"),
|
||||
};
|
||||
assert_eq!(overlay_cell_count, app.transcript_cells.len());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn late_usage_result_can_follow_finalized_plan() {
|
||||
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||||
@@ -5764,64 +5621,6 @@ async fn late_usage_result_can_follow_finalized_plan() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_rollback_response_discards_queued_active_thread_events() {
|
||||
let mut app = make_test_app().await;
|
||||
let thread_id = ThreadId::new();
|
||||
let (tx, rx) = mpsc::channel(8);
|
||||
app.active_thread_id = Some(thread_id);
|
||||
app.active_thread_rx = Some(rx);
|
||||
tx.send(ThreadBufferedEvent::Notification(
|
||||
ServerNotification::ConfigWarning(ConfigWarningNotification {
|
||||
summary: "stale warning".to_string(),
|
||||
details: None,
|
||||
path: None,
|
||||
range: None,
|
||||
}),
|
||||
))
|
||||
.await
|
||||
.expect("event should queue");
|
||||
|
||||
app.handle_thread_rollback_response(
|
||||
thread_id,
|
||||
/*num_turns*/ 1,
|
||||
&ThreadRollbackResponse {
|
||||
thread: Thread {
|
||||
id: thread_id.to_string(),
|
||||
extra: None,
|
||||
session_id: thread_id.to_string(),
|
||||
forked_from_id: None,
|
||||
parent_thread_id: None,
|
||||
preview: String::new(),
|
||||
ephemeral: false,
|
||||
history_mode: Default::default(),
|
||||
model_provider: "openai".to_string(),
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
recency_at: Some(0),
|
||||
status: codex_app_server_protocol::ThreadStatus::Idle,
|
||||
path: None,
|
||||
cwd: test_path_buf("/tmp/project").abs(),
|
||||
cli_version: "0.0.0".to_string(),
|
||||
source: SessionSource::Cli,
|
||||
thread_source: None,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
git_info: None,
|
||||
name: None,
|
||||
turns: Vec::new(),
|
||||
},
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let rx = app
|
||||
.active_thread_rx
|
||||
.as_mut()
|
||||
.expect("active receiver should remain attached");
|
||||
assert!(matches!(rx.try_recv(), Err(TryRecvError::Empty)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn new_session_requests_shutdown_for_previous_conversation() {
|
||||
Box::pin(async {
|
||||
@@ -6282,7 +6081,6 @@ async fn clear_only_ui_reset_preserves_chat_session_state() {
|
||||
app.backtrack.primed = true;
|
||||
app.backtrack.overlay_preview_active = true;
|
||||
app.backtrack.nth_user_message = 0;
|
||||
app.backtrack_render_pending = true;
|
||||
|
||||
app.reset_app_ui_state_after_clear();
|
||||
|
||||
@@ -6292,8 +6090,6 @@ async fn clear_only_ui_reset_preserves_chat_session_state() {
|
||||
assert!(!app.has_emitted_history_lines);
|
||||
assert!(!app.backtrack.primed);
|
||||
assert!(!app.backtrack.overlay_preview_active);
|
||||
assert!(app.backtrack.pending_rollback.is_none());
|
||||
assert!(!app.backtrack_render_pending);
|
||||
assert_eq!(app.chat_widget.thread_id(), Some(thread_id));
|
||||
assert_eq!(app.chat_widget.composer_text_with_pending(), "draft prompt");
|
||||
}
|
||||
|
||||
@@ -198,13 +198,6 @@ impl ThreadEventStore {
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn apply_thread_rollback(&mut self, response: &ThreadRollbackResponse) {
|
||||
self.turns = response.thread.turns.clone();
|
||||
self.buffer.clear();
|
||||
self.pending_interactive_replay = PendingInteractiveReplayState::default();
|
||||
self.active_turn_id = None;
|
||||
}
|
||||
|
||||
pub(super) fn snapshot(&self) -> ThreadEventSnapshot {
|
||||
ThreadEventSnapshot {
|
||||
session: self.session.clone(),
|
||||
|
||||
@@ -11,8 +11,6 @@ use crate::session_resume::read_session_model;
|
||||
impl App {
|
||||
pub(super) async fn shutdown_current_thread(&mut self, app_server: &mut AppServerSession) {
|
||||
if let Some(thread_id) = self.chat_widget.thread_id() {
|
||||
// Clear any in-flight rollback guard when switching threads.
|
||||
self.backtrack.pending_rollback = None;
|
||||
if let Err(err) = app_server.thread_unsubscribe(thread_id).await {
|
||||
tracing::warn!("failed to unsubscribe thread {thread_id}: {err}");
|
||||
}
|
||||
@@ -516,7 +514,7 @@ impl App {
|
||||
op: &AppCommand,
|
||||
) -> Result<bool> {
|
||||
match op {
|
||||
AppCommand::Interrupt { .. } => {
|
||||
AppCommand::Interrupt => {
|
||||
if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await {
|
||||
let mut interrupt_turn_id = turn_id;
|
||||
for retried_after_turn_mismatch in [false, true] {
|
||||
@@ -693,18 +691,6 @@ impl App {
|
||||
.await?;
|
||||
Ok(true)
|
||||
}
|
||||
AppCommand::ThreadRollback { num_turns } => {
|
||||
let response = match app_server.thread_rollback(thread_id, *num_turns).await {
|
||||
Ok(response) => response,
|
||||
Err(err) => {
|
||||
self.handle_backtrack_rollback_failed();
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
self.handle_thread_rollback_response(thread_id, *num_turns, &response)
|
||||
.await;
|
||||
Ok(true)
|
||||
}
|
||||
AppCommand::Review { target } => {
|
||||
let response = app_server.review_start(thread_id, target.clone()).await?;
|
||||
let review_thread_id = ThreadId::from_string(&response.review_thread_id)
|
||||
@@ -1278,7 +1264,7 @@ impl App {
|
||||
/// refreshes from the backend. Refresh failures are treated as "thread is only inspectable by
|
||||
/// historical id now" and converted into closed picker entries instead of deleting them, so
|
||||
/// the stable traversal order remains intact for review and keyboard navigation.
|
||||
pub(super) async fn drain_active_thread_events(&mut self, tui: &mut tui::Tui) -> Result<()> {
|
||||
pub(super) async fn drain_active_thread_events(&mut self) -> Result<()> {
|
||||
let Some(mut rx) = self.active_thread_rx.take() else {
|
||||
return Ok(());
|
||||
};
|
||||
@@ -1301,9 +1287,6 @@ impl App {
|
||||
self.clear_active_thread().await;
|
||||
}
|
||||
|
||||
if self.backtrack_render_pending {
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1424,40 +1407,6 @@ impl App {
|
||||
self.chat_widget.handle_skills_list_response(response);
|
||||
}
|
||||
|
||||
pub(super) async fn handle_thread_rollback_response(
|
||||
&mut self,
|
||||
thread_id: ThreadId,
|
||||
num_turns: u32,
|
||||
response: &ThreadRollbackResponse,
|
||||
) {
|
||||
if let Some(channel) = self.thread_event_channels.get(&thread_id) {
|
||||
let mut store = channel.store.lock().await;
|
||||
store.apply_thread_rollback(response);
|
||||
}
|
||||
if self.active_thread_id == Some(thread_id)
|
||||
&& let Some(mut rx) = self.active_thread_rx.take()
|
||||
{
|
||||
let mut disconnected = false;
|
||||
loop {
|
||||
match rx.try_recv() {
|
||||
Ok(_) => {}
|
||||
Err(TryRecvError::Empty) => break,
|
||||
Err(TryRecvError::Disconnected) => {
|
||||
disconnected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !disconnected {
|
||||
self.active_thread_rx = Some(rx);
|
||||
} else {
|
||||
self.clear_active_thread().await;
|
||||
}
|
||||
}
|
||||
self.handle_backtrack_rollback_succeeded(num_turns);
|
||||
}
|
||||
|
||||
pub(super) fn handle_thread_event_now(&mut self, event: ThreadBufferedEvent) {
|
||||
let needs_refresh = matches!(
|
||||
&event,
|
||||
@@ -1570,9 +1519,6 @@ impl App {
|
||||
self.pending_shutdown_exit_thread_id = None;
|
||||
}
|
||||
self.handle_thread_event_now(event);
|
||||
if self.backtrack_render_pending {
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ use std::any::TypeId;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::app::App;
|
||||
use crate::app_command::AppCommand;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::bottom_pane::LocalImageAttachment;
|
||||
use crate::chatwidget::UserMessage;
|
||||
@@ -63,11 +62,6 @@ pub(crate) struct BacktrackState {
|
||||
pub(crate) nth_user_message: usize,
|
||||
/// True when the transcript overlay is showing a backtrack preview.
|
||||
pub(crate) overlay_preview_active: bool,
|
||||
/// Output-free cancellation rollback awaiting confirmation from app-server.
|
||||
///
|
||||
/// Transcript prompt editing no longer uses rollback, but the legacy early-interrupt path
|
||||
/// remains until the final PR in the stack.
|
||||
pub(crate) pending_rollback: Option<PendingCancelledTurnRollback>,
|
||||
}
|
||||
|
||||
/// A completed canonical turn selected for editing on a source-preserving branch.
|
||||
@@ -79,12 +73,6 @@ pub(crate) struct BacktrackSelection {
|
||||
pub(crate) prompt: UserMessage,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct PendingCancelledTurnRollback {
|
||||
nth_user_message: usize,
|
||||
thread_id: Option<ThreadId>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// Route overlay events while the transcript overlay is active.
|
||||
///
|
||||
@@ -166,24 +154,6 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// Preserve the existing output-free cancellation behavior while transcript editing migrates
|
||||
/// to source-preserving forks.
|
||||
pub(crate) fn apply_cancelled_turn_edit(&mut self, prompt: UserMessage) {
|
||||
if self.backtrack.pending_rollback.is_some() {
|
||||
self.chat_widget
|
||||
.add_error_message("Backtrack rollback already in progress.".to_string());
|
||||
return;
|
||||
}
|
||||
|
||||
self.backtrack.pending_rollback = Some(PendingCancelledTurnRollback {
|
||||
nth_user_message: user_count(&self.transcript_cells).saturating_sub(1),
|
||||
thread_id: self.chat_widget.thread_id(),
|
||||
});
|
||||
self.chat_widget
|
||||
.submit_op(AppCommand::thread_rollback(/*num_turns*/ 1));
|
||||
self.chat_widget.restore_user_message_to_composer(prompt);
|
||||
}
|
||||
|
||||
/// Open transcript overlay (enters alternate screen and shows full transcript).
|
||||
pub(crate) fn open_transcript_overlay(&mut self, tui: &mut tui::Tui) {
|
||||
let _ = tui.enter_alt_screen();
|
||||
@@ -495,67 +465,6 @@ impl App {
|
||||
));
|
||||
}
|
||||
|
||||
pub(crate) fn handle_backtrack_rollback_succeeded(&mut self, num_turns: u32) {
|
||||
if self.backtrack.pending_rollback.is_some() {
|
||||
self.finish_pending_cancelled_turn_rollback();
|
||||
} else {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::ApplyThreadRollback { num_turns });
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn handle_backtrack_rollback_failed(&mut self) {
|
||||
self.backtrack.pending_rollback = None;
|
||||
}
|
||||
|
||||
/// Apply rollback semantics for legacy TUI consumers that have not migrated to forks yet.
|
||||
pub(crate) fn apply_non_pending_thread_rollback(&mut self, num_turns: u32) -> bool {
|
||||
if !trim_transcript_cells_drop_last_n_user_turns(&mut self.transcript_cells, num_turns) {
|
||||
return false;
|
||||
}
|
||||
self.chat_widget.clear_pending_token_activity_refreshes();
|
||||
self.chat_widget.clear_pending_rate_limit_reset_hint();
|
||||
self.chat_widget
|
||||
.truncate_agent_copy_history_to_user_turn_count(user_count(&self.transcript_cells));
|
||||
self.sync_overlay_after_transcript_trim();
|
||||
self.backtrack_render_pending = true;
|
||||
true
|
||||
}
|
||||
|
||||
fn finish_pending_cancelled_turn_rollback(&mut self) {
|
||||
let Some(pending) = self.backtrack.pending_rollback.take() else {
|
||||
return;
|
||||
};
|
||||
if pending.thread_id != self.chat_widget.thread_id() {
|
||||
return;
|
||||
}
|
||||
if trim_transcript_cells_to_nth_user(&mut self.transcript_cells, pending.nth_user_message) {
|
||||
self.chat_widget.clear_pending_token_activity_refreshes();
|
||||
self.chat_widget.clear_pending_rate_limit_reset_hint();
|
||||
self.chat_widget
|
||||
.truncate_agent_copy_history_to_user_turn_count(user_count(&self.transcript_cells));
|
||||
self.sync_overlay_after_transcript_trim();
|
||||
self.backtrack_render_pending = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_overlay_after_transcript_trim(&mut self) {
|
||||
if let Some(Overlay::Transcript(overlay)) = &mut self.overlay {
|
||||
overlay.replace_cells(self.transcript_cells.clone());
|
||||
}
|
||||
if self.backtrack.overlay_preview_active {
|
||||
let count =
|
||||
forkable_turn_count(&self.transcript_cells, self.chat_widget.active_turn_id());
|
||||
let next_selection = if count == 0 {
|
||||
usize::MAX
|
||||
} else {
|
||||
self.backtrack.nth_user_message.min(count.saturating_sub(1))
|
||||
};
|
||||
self.apply_backtrack_selection_internal(next_selection);
|
||||
}
|
||||
self.deferred_history_lines.clear();
|
||||
}
|
||||
|
||||
fn backtrack_selection(&self, nth_user_message: usize) -> Option<BacktrackSelection> {
|
||||
let base_id = self.backtrack.base_id?;
|
||||
if self.chat_widget.thread_id() != Some(base_id) {
|
||||
@@ -603,78 +512,6 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn trim_transcript_cells_to_nth_user(
|
||||
transcript_cells: &mut Vec<Arc<dyn crate::history_cell::HistoryCell>>,
|
||||
nth_user_message: usize,
|
||||
) -> bool {
|
||||
if nth_user_message == usize::MAX {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(cut_idx) = nth_user_position(transcript_cells, nth_user_message) {
|
||||
let original_len = transcript_cells.len();
|
||||
transcript_cells.truncate(cut_idx);
|
||||
return transcript_cells.len() != original_len;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub(crate) fn trim_transcript_cells_drop_last_n_user_turns(
|
||||
transcript_cells: &mut Vec<Arc<dyn crate::history_cell::HistoryCell>>,
|
||||
num_turns: u32,
|
||||
) -> bool {
|
||||
if num_turns == 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let user_positions: Vec<usize> = user_positions_iter(transcript_cells).collect();
|
||||
let Some(&first_user_idx) = user_positions.first() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let turns_from_end = usize::try_from(num_turns).unwrap_or(usize::MAX);
|
||||
let cut_idx = if turns_from_end >= user_positions.len() {
|
||||
first_user_idx
|
||||
} else {
|
||||
user_positions[user_positions.len() - turns_from_end]
|
||||
};
|
||||
let original_len = transcript_cells.len();
|
||||
transcript_cells.truncate(cut_idx);
|
||||
transcript_cells.len() != original_len
|
||||
}
|
||||
|
||||
pub(crate) fn user_count(cells: &[Arc<dyn crate::history_cell::HistoryCell>]) -> usize {
|
||||
user_positions_iter(cells).count()
|
||||
}
|
||||
|
||||
fn nth_user_position(
|
||||
cells: &[Arc<dyn crate::history_cell::HistoryCell>],
|
||||
nth: usize,
|
||||
) -> Option<usize> {
|
||||
user_positions_iter(cells)
|
||||
.enumerate()
|
||||
.find_map(|(i, idx)| (i == nth).then_some(idx))
|
||||
}
|
||||
|
||||
fn user_positions_iter(
|
||||
cells: &[Arc<dyn crate::history_cell::HistoryCell>],
|
||||
) -> impl Iterator<Item = usize> + '_ {
|
||||
let session_start_type = TypeId::of::<SessionInfoCell>();
|
||||
let user_type = TypeId::of::<UserHistoryCell>();
|
||||
let type_of = |cell: &Arc<dyn crate::history_cell::HistoryCell>| cell.as_any().type_id();
|
||||
|
||||
let start = cells
|
||||
.iter()
|
||||
.rposition(|cell| type_of(cell) == session_start_type)
|
||||
.map_or(0, |idx| idx + 1);
|
||||
|
||||
cells
|
||||
.iter()
|
||||
.enumerate()
|
||||
.skip(start)
|
||||
.filter_map(move |(idx, cell)| (type_of(cell) == user_type).then_some(idx))
|
||||
}
|
||||
|
||||
fn has_backtrack_target(
|
||||
cells: &[Arc<dyn crate::history_cell::HistoryCell>],
|
||||
active_turn_id: Option<&str>,
|
||||
|
||||
@@ -24,9 +24,7 @@ use serde_json::Value;
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub(crate) enum AppCommand {
|
||||
Interrupt {
|
||||
behavior: InterruptBehavior,
|
||||
},
|
||||
Interrupt,
|
||||
CleanBackgroundTerminals,
|
||||
RunUserShellCommand {
|
||||
command: String,
|
||||
@@ -93,9 +91,6 @@ pub(crate) enum AppCommand {
|
||||
name: String,
|
||||
},
|
||||
Shutdown,
|
||||
ThreadRollback {
|
||||
num_turns: u32,
|
||||
},
|
||||
Review {
|
||||
target: ReviewTarget,
|
||||
},
|
||||
@@ -104,23 +99,9 @@ pub(crate) enum AppCommand {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
pub(crate) enum InterruptBehavior {
|
||||
Default,
|
||||
RestorePromptIfNoOutput,
|
||||
}
|
||||
|
||||
impl AppCommand {
|
||||
pub(crate) fn interrupt() -> Self {
|
||||
Self::Interrupt {
|
||||
behavior: InterruptBehavior::Default,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn interrupt_and_restore_prompt_if_no_output() -> Self {
|
||||
Self::Interrupt {
|
||||
behavior: InterruptBehavior::RestorePromptIfNoOutput,
|
||||
}
|
||||
Self::Interrupt
|
||||
}
|
||||
|
||||
pub(crate) fn clean_background_terminals() -> Self {
|
||||
@@ -256,10 +237,6 @@ impl AppCommand {
|
||||
Self::Shutdown
|
||||
}
|
||||
|
||||
pub(crate) fn thread_rollback(num_turns: u32) -> Self {
|
||||
Self::ThreadRollback { num_turns }
|
||||
}
|
||||
|
||||
pub(crate) fn review(target: ReviewTarget) -> Self {
|
||||
Self::Review { target }
|
||||
}
|
||||
|
||||
@@ -267,9 +267,6 @@ pub(crate) enum AppEvent {
|
||||
/// bubbling channels through layers of widgets.
|
||||
CodexOp(AppCommand),
|
||||
|
||||
/// Restore an output-free interrupted turn into the composer and roll it back.
|
||||
RestoreCancelledTurn(UserMessage),
|
||||
|
||||
/// Approve one retry of a recent auto-review denial selected in the TUI.
|
||||
ApproveRecentAutoReviewDenial {
|
||||
thread_id: ThreadId,
|
||||
@@ -706,15 +703,6 @@ pub(crate) enum AppEvent {
|
||||
/// finalization.
|
||||
ConsolidateProposedPlan(String),
|
||||
|
||||
/// Apply rollback semantics to local transcript cells.
|
||||
///
|
||||
/// This is emitted when rollback was not initiated by the current
|
||||
/// backtrack flow so trimming occurs in AppEvent queue order relative to
|
||||
/// inserted history cells.
|
||||
ApplyThreadRollback {
|
||||
num_turns: u32,
|
||||
},
|
||||
|
||||
StartCommitAnimation,
|
||||
StopCommitAnimation,
|
||||
CommitTick,
|
||||
|
||||
@@ -46,12 +46,6 @@ impl AppEventSender {
|
||||
self.send(AppEvent::CodexOp(AppCommand::interrupt()));
|
||||
}
|
||||
|
||||
pub(crate) fn interrupt_and_restore_prompt_if_no_output(&self) {
|
||||
self.send(AppEvent::CodexOp(
|
||||
AppCommand::interrupt_and_restore_prompt_if_no_output(),
|
||||
));
|
||||
}
|
||||
|
||||
pub(crate) fn compact(&self) {
|
||||
self.send(AppEvent::CodexOp(AppCommand::compact()));
|
||||
}
|
||||
|
||||
@@ -85,8 +85,6 @@ use codex_app_server_protocol::ThreadReadParams;
|
||||
use codex_app_server_protocol::ThreadReadResponse;
|
||||
use codex_app_server_protocol::ThreadResumeParams;
|
||||
use codex_app_server_protocol::ThreadResumeResponse;
|
||||
use codex_app_server_protocol::ThreadRollbackParams;
|
||||
use codex_app_server_protocol::ThreadRollbackResponse;
|
||||
use codex_app_server_protocol::ThreadSetNameParams;
|
||||
use codex_app_server_protocol::ThreadSetNameResponse;
|
||||
use codex_app_server_protocol::ThreadSettingsUpdateParams;
|
||||
@@ -1097,24 +1095,6 @@ impl AppServerSession {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn thread_rollback(
|
||||
&mut self,
|
||||
thread_id: ThreadId,
|
||||
num_turns: u32,
|
||||
) -> Result<ThreadRollbackResponse> {
|
||||
let request_id = self.next_request_id();
|
||||
self.client
|
||||
.request_typed(ClientRequest::ThreadRollback {
|
||||
request_id,
|
||||
params: ThreadRollbackParams {
|
||||
thread_id: thread_id.to_string(),
|
||||
num_turns,
|
||||
},
|
||||
})
|
||||
.await
|
||||
.wrap_err("thread/rollback failed in TUI")
|
||||
}
|
||||
|
||||
pub(crate) async fn review_start(
|
||||
&mut self,
|
||||
thread_id: ThreadId,
|
||||
|
||||
@@ -2655,7 +2655,7 @@ mod tests {
|
||||
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
assert!(
|
||||
!matches!(ev, AppEvent::CodexOp(Op::Interrupt { .. })),
|
||||
!matches!(ev, AppEvent::CodexOp(Op::Interrupt)),
|
||||
"expected Esc to not send Op::Interrupt when dismissing skill popup"
|
||||
);
|
||||
}
|
||||
@@ -2693,7 +2693,7 @@ mod tests {
|
||||
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
assert!(
|
||||
!matches!(ev, AppEvent::CodexOp(Op::Interrupt { .. })),
|
||||
!matches!(ev, AppEvent::CodexOp(Op::Interrupt)),
|
||||
"expected Esc to not send Op::Interrupt while command popup is active"
|
||||
);
|
||||
}
|
||||
@@ -2729,7 +2729,7 @@ mod tests {
|
||||
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
assert!(
|
||||
!matches!(ev, AppEvent::CodexOp(Op::Interrupt { .. })),
|
||||
!matches!(ev, AppEvent::CodexOp(Op::Interrupt)),
|
||||
"expected Esc to not send Op::Interrupt while typing `/agent`"
|
||||
);
|
||||
}
|
||||
@@ -2774,7 +2774,7 @@ mod tests {
|
||||
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
assert!(
|
||||
!matches!(ev, AppEvent::CodexOp(Op::Interrupt { .. })),
|
||||
!matches!(ev, AppEvent::CodexOp(Op::Interrupt)),
|
||||
"expected Esc release after dismissing agent picker to not interrupt"
|
||||
);
|
||||
}
|
||||
@@ -2804,7 +2804,7 @@ mod tests {
|
||||
pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
|
||||
assert!(
|
||||
matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt { .. }))),
|
||||
matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt))),
|
||||
"expected Esc to send Op::Interrupt while a task is running"
|
||||
);
|
||||
}
|
||||
@@ -2828,7 +2828,7 @@ mod tests {
|
||||
|
||||
pane.handle_key_event(KeyEvent::new(KeyCode::F(12), KeyModifiers::NONE));
|
||||
assert!(
|
||||
matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt { .. }))),
|
||||
matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt))),
|
||||
"expected configured key to interrupt while `/agent` is being edited"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -663,7 +663,6 @@ pub(crate) struct ChatWidget {
|
||||
// order.
|
||||
suppress_initial_user_message_submit: bool,
|
||||
input_queue: InputQueueState,
|
||||
cancel_edit: CancelEditState,
|
||||
/// Main chat-surface bindings resolved from `tui.keymap.chat`.
|
||||
chat_keymap: ChatKeymap,
|
||||
/// Keybinding to show for popping the most-recently queued message back
|
||||
@@ -786,13 +785,6 @@ pub(crate) enum InterruptedTurnNoticeMode {
|
||||
Suppress,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct CancelEditState {
|
||||
prompt: Option<UserMessage>,
|
||||
eligible: bool,
|
||||
armed: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum ReplayKind {
|
||||
ResumeInitialMessages,
|
||||
@@ -1218,9 +1210,6 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
fn add_boxed_history(&mut self, cell: Box<dyn HistoryCell>) {
|
||||
if self.turn_lifecycle.agent_turn_running && !cell.display_lines(u16::MAX).is_empty() {
|
||||
self.record_visible_turn_activity();
|
||||
}
|
||||
// Keep the placeholder session header as the active cell until real session info arrives,
|
||||
// so we can merge headers instead of committing a duplicate box to history.
|
||||
let keep_placeholder_header_active = !self.is_session_configured()
|
||||
@@ -1854,12 +1843,7 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
pub(crate) fn prepare_local_op_submission(&mut self, op: &AppCommand) {
|
||||
if let AppCommand::Interrupt { behavior } = op
|
||||
&& self.turn_lifecycle.agent_turn_running
|
||||
{
|
||||
if *behavior == crate::app_command::InterruptBehavior::RestorePromptIfNoOutput {
|
||||
self.arm_cancel_edit();
|
||||
}
|
||||
if matches!(op, AppCommand::Interrupt) && self.turn_lifecycle.agent_turn_running {
|
||||
if let Some(controller) = self.stream_controller.as_mut() {
|
||||
controller.clear_queue();
|
||||
}
|
||||
|
||||
@@ -240,7 +240,6 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
pub(crate) fn handle_command_execution_started_now(&mut self, item: ThreadItem) {
|
||||
self.record_visible_turn_activity();
|
||||
let ThreadItem::CommandExecution {
|
||||
id,
|
||||
command,
|
||||
|
||||
@@ -198,7 +198,6 @@ impl ChatWidget {
|
||||
forked_from: None,
|
||||
interrupted_turn_notice_mode: InterruptedTurnNoticeMode::Default,
|
||||
input_queue: InputQueueState::default(),
|
||||
cancel_edit: CancelEditState::default(),
|
||||
chat_keymap,
|
||||
queued_message_edit_hint_binding,
|
||||
show_welcome_banner: is_first_run,
|
||||
|
||||
@@ -15,7 +15,6 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
pub(super) fn on_hook_started(&mut self, run: codex_app_server_protocol::HookRunSummary) {
|
||||
self.record_visible_turn_activity();
|
||||
self.flush_answer_stream_with_separator();
|
||||
self.flush_completed_hook_output();
|
||||
match self.active_hook_cell.as_mut() {
|
||||
|
||||
@@ -6,38 +6,6 @@ use super::user_messages::remap_colliding_paste_placeholders;
|
||||
use super::*;
|
||||
|
||||
impl ChatWidget {
|
||||
pub(super) fn record_cancel_edit_candidate(&mut self, prompt: UserMessage) {
|
||||
self.cancel_edit.prompt = Some(prompt);
|
||||
self.cancel_edit.eligible = true;
|
||||
self.cancel_edit.armed = false;
|
||||
}
|
||||
|
||||
pub(super) fn record_visible_turn_activity(&mut self) {
|
||||
self.cancel_edit.eligible = false;
|
||||
self.cancel_edit.armed = false;
|
||||
}
|
||||
|
||||
pub(super) fn arm_cancel_edit(&mut self) {
|
||||
self.cancel_edit.armed = self.cancel_edit.eligible
|
||||
&& self.cancel_edit.prompt.is_some()
|
||||
&& self.bottom_pane.composer_is_empty()
|
||||
&& self.input_queue.pending_steers.is_empty()
|
||||
&& !self.has_queued_follow_up_messages()
|
||||
&& !self.active_side_conversation;
|
||||
}
|
||||
|
||||
fn take_armed_cancel_edit_prompt(&mut self, reason: TurnAbortReason) -> Option<UserMessage> {
|
||||
(reason == TurnAbortReason::Interrupted
|
||||
&& self.cancel_edit.armed
|
||||
&& self.cancel_edit.eligible)
|
||||
.then(|| self.cancel_edit.prompt.take())
|
||||
.flatten()
|
||||
}
|
||||
|
||||
pub(super) fn clear_cancel_edit(&mut self) {
|
||||
self.cancel_edit = CancelEditState::default();
|
||||
}
|
||||
|
||||
pub(crate) fn set_initial_user_message_submit_suppressed(&mut self, suppressed: bool) {
|
||||
self.suppress_initial_user_message_submit = suppressed;
|
||||
}
|
||||
@@ -147,15 +115,12 @@ impl ChatWidget {
|
||||
/// When there are queued user messages, restore them into the composer
|
||||
/// separated by newlines rather than auto-submitting the next one.
|
||||
pub(super) fn on_interrupted_turn(&mut self, reason: TurnAbortReason) {
|
||||
let cancelled_prompt = self.take_armed_cancel_edit_prompt(reason);
|
||||
// Finalize, log a gentle prompt, and clear running state.
|
||||
self.finalize_turn();
|
||||
let send_pending_steers_immediately =
|
||||
self.input_queue.submit_pending_steers_after_interrupt;
|
||||
self.input_queue.submit_pending_steers_after_interrupt = false;
|
||||
if cancelled_prompt.is_none()
|
||||
&& self.interrupted_turn_notice_mode != InterruptedTurnNoticeMode::Suppress
|
||||
{
|
||||
if self.interrupted_turn_notice_mode != InterruptedTurnNoticeMode::Suppress {
|
||||
if send_pending_steers_immediately {
|
||||
self.add_to_history(history_cell::new_info_event(
|
||||
"Model interrupted to submit steer instructions.".to_owned(),
|
||||
@@ -189,10 +154,6 @@ impl ChatWidget {
|
||||
self.restore_composer_state(combined);
|
||||
}
|
||||
self.refresh_pending_input_preview();
|
||||
if let Some(prompt) = cancelled_prompt {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::RestoreCancelledTurn(prompt));
|
||||
}
|
||||
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
@@ -400,7 +400,6 @@ impl ChatWidget {
|
||||
},
|
||||
&history_record,
|
||||
);
|
||||
self.record_cancel_edit_candidate(prompt.clone());
|
||||
self.record_safety_buffering_prompt(prompt);
|
||||
}
|
||||
|
||||
|
||||
@@ -255,14 +255,6 @@ 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.transcript
|
||||
.truncate_copy_history_to_user_turn_count(user_turn_count);
|
||||
}
|
||||
|
||||
/// Inner implementation with an injectable clipboard backend for testing.
|
||||
pub(super) fn copy_last_agent_markdown_with(
|
||||
&mut self,
|
||||
@@ -281,11 +273,6 @@ impl ChatWidget {
|
||||
"Copy failed: {error}"
|
||||
))),
|
||||
},
|
||||
_ if self.transcript.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(),
|
||||
)),
|
||||
@@ -403,7 +390,7 @@ impl ChatWidget {
|
||||
self.quit_shortcut_expires_at = None;
|
||||
self.quit_shortcut_key = None;
|
||||
self.bottom_pane.clear_quit_shortcut_hint();
|
||||
if self.submit_op(AppCommand::interrupt_and_restore_prompt_if_no_output()) {
|
||||
if self.submit_op(AppCommand::interrupt()) {
|
||||
self.pause_active_goal_for_interrupt();
|
||||
}
|
||||
} else {
|
||||
@@ -421,9 +408,7 @@ impl ChatWidget {
|
||||
|
||||
self.arm_quit_shortcut(key);
|
||||
|
||||
if self.is_cancellable_work_active()
|
||||
&& self.submit_op(AppCommand::interrupt_and_restore_prompt_if_no_output())
|
||||
{
|
||||
if self.is_cancellable_work_active() && self.submit_op(AppCommand::interrupt()) {
|
||||
self.pause_active_goal_for_interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,6 @@ impl ChatWidget {
|
||||
/// leave a ghost prompt in the transcript.
|
||||
pub(crate) fn prepare_safety_buffered_retry_submission(&mut self, prompt: UserMessage) {
|
||||
self.input_queue.user_turn_pending_start = true;
|
||||
self.record_cancel_edit_candidate(prompt.clone());
|
||||
self.record_safety_buffering_prompt(prompt);
|
||||
}
|
||||
|
||||
|
||||
@@ -116,9 +116,6 @@ impl ChatWidget {
|
||||
if self.active_mode_kind() != ModeKind::Plan {
|
||||
return;
|
||||
}
|
||||
if !delta.is_empty() {
|
||||
self.record_visible_turn_activity();
|
||||
}
|
||||
if !self.transcript.plan_item_active {
|
||||
self.transcript.plan_item_active = true;
|
||||
self.transcript.plan_delta_buffer.clear();
|
||||
@@ -389,7 +386,6 @@ impl ChatWidget {
|
||||
#[inline]
|
||||
pub(super) fn handle_streaming_delta(&mut self, delta: String) {
|
||||
if !delta.is_empty() {
|
||||
self.record_visible_turn_activity();
|
||||
self.mark_safety_buffering_agent_message_started();
|
||||
}
|
||||
if self.stream_controller.is_none() {
|
||||
|
||||
@@ -238,9 +238,6 @@ async fn safety_buffered_retry_preparation_renders_identical_prompt_and_preserve
|
||||
|
||||
chat.prepare_safety_buffered_retry_submission(prompt.clone());
|
||||
assert!(drain_insert_history(&mut rx).is_empty());
|
||||
assert_eq!(chat.cancel_edit.prompt, Some(prompt.clone()));
|
||||
assert!(chat.cancel_edit.eligible);
|
||||
assert!(!chat.cancel_edit.armed);
|
||||
|
||||
let retry_turn_id = "retry-turn";
|
||||
chat.record_safety_buffering_turn(retry_turn_id.to_string(), &turn);
|
||||
|
||||
@@ -7,7 +7,6 @@ use codex_protocol::permissions::FileSystemSandboxEntry;
|
||||
use codex_protocol::permissions::FileSystemSpecialPath;
|
||||
use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
#[tokio::test]
|
||||
@@ -929,66 +928,91 @@ async fn empty_enter_during_task_does_not_queue() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn output_free_interrupted_turn_requests_prompt_restore() {
|
||||
async fn output_free_esc_interrupt_keeps_prompt_and_opens_blank_composer() {
|
||||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
let prompt = UserMessage::from("revise this prompt");
|
||||
chat.record_cancel_edit_candidate(prompt.clone());
|
||||
let prompt = "revise this prompt";
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.submit_user_message(UserMessage::from(prompt));
|
||||
assert_matches!(next_submit_op(&mut op_rx), Op::UserTurn { .. });
|
||||
handle_turn_started(&mut chat, "turn-1");
|
||||
chat.bottom_pane.ensure_status_indicator();
|
||||
|
||||
chat.submit_op(AppCommand::interrupt_and_restore_prompt_if_no_output());
|
||||
assert_matches!(
|
||||
op_rx.try_recv(),
|
||||
Ok(Op::Interrupt {
|
||||
behavior: crate::app_command::InterruptBehavior::RestorePromptIfNoOutput,
|
||||
})
|
||||
let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
|
||||
assert!(chat.bottom_pane.is_task_running());
|
||||
assert!(chat.bottom_pane.status_indicator_visible());
|
||||
assert!(chat.bottom_pane.should_interrupt_running_task(esc));
|
||||
chat.handle_key_event(esc);
|
||||
|
||||
let mut saw_prompt = false;
|
||||
loop {
|
||||
match rx.try_recv() {
|
||||
Ok(AppEvent::InsertHistoryCell(cell)) => {
|
||||
if let Some(cell) = cell.as_any().downcast_ref::<UserHistoryCell>() {
|
||||
assert_eq!(cell.message, prompt);
|
||||
saw_prompt = true;
|
||||
}
|
||||
}
|
||||
Ok(AppEvent::CodexOp(Op::Interrupt)) => break,
|
||||
Ok(_) => {}
|
||||
Err(error) => panic!("expected Esc interrupt command, got {error:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
handle_turn_interrupted(&mut chat, "turn-1");
|
||||
|
||||
let mut interrupted_history = String::new();
|
||||
while let Ok(event) = rx.try_recv() {
|
||||
if let AppEvent::InsertHistoryCell(cell) = event {
|
||||
if let Some(cell) = cell.as_any().downcast_ref::<UserHistoryCell>() {
|
||||
assert_eq!(cell.message, prompt);
|
||||
saw_prompt = true;
|
||||
}
|
||||
interrupted_history
|
||||
.push_str(&lines_to_single_string(&cell.display_lines(/*width*/ 80)));
|
||||
}
|
||||
}
|
||||
assert!(saw_prompt, "expected the submitted prompt in history");
|
||||
assert!(
|
||||
interrupted_history
|
||||
.contains("Conversation interrupted - tell the model what to do differently."),
|
||||
"expected normal interruption notice, got {interrupted_history:?}"
|
||||
);
|
||||
handle_turn_interrupted(&mut chat, "turn-1");
|
||||
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::RestoreCancelledTurn(restored)) if restored == prompt);
|
||||
assert!(chat.bottom_pane.composer_text().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn visible_output_prevents_cancelled_turn_prompt_restore() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.record_cancel_edit_candidate(UserMessage::from("revise this prompt"));
|
||||
async fn output_free_ctrl_c_interrupt_keeps_prompt_and_opens_blank_composer() {
|
||||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
let prompt = "revise this prompt";
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.submit_user_message(UserMessage::from(prompt));
|
||||
assert_matches!(next_submit_op(&mut op_rx), Op::UserTurn { .. });
|
||||
handle_turn_started(&mut chat, "turn-1");
|
||||
chat.on_agent_message_delta("visible output".to_string());
|
||||
chat.submit_op(AppCommand::interrupt_and_restore_prompt_if_no_output());
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
|
||||
|
||||
next_interrupt_op(&mut op_rx);
|
||||
handle_turn_interrupted(&mut chat, "turn-1");
|
||||
|
||||
let mut saw_prompt = false;
|
||||
let mut interrupted_history = String::new();
|
||||
while let Ok(event) = rx.try_recv() {
|
||||
assert!(!matches!(event, AppEvent::RestoreCancelledTurn(_)));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thinking_status_keeps_cancelled_turn_prompt_restore_eligible() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
let prompt = UserMessage::from("revise this prompt");
|
||||
chat.record_cancel_edit_candidate(prompt.clone());
|
||||
handle_turn_started(&mut chat, "turn-1");
|
||||
chat.on_agent_reasoning_delta("**Thinking**".to_string());
|
||||
chat.submit_op(AppCommand::interrupt_and_restore_prompt_if_no_output());
|
||||
|
||||
handle_turn_interrupted(&mut chat, "turn-1");
|
||||
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::RestoreCancelledTurn(restored)) if restored == prompt);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn patch_activity_prevents_cancelled_turn_prompt_restore() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.record_cancel_edit_candidate(UserMessage::from("revise this prompt"));
|
||||
handle_turn_started(&mut chat, "turn-1");
|
||||
chat.on_patch_apply_begin(HashMap::new());
|
||||
chat.submit_op(AppCommand::interrupt_and_restore_prompt_if_no_output());
|
||||
|
||||
handle_turn_interrupted(&mut chat, "turn-1");
|
||||
|
||||
while let Ok(event) = rx.try_recv() {
|
||||
assert!(!matches!(event, AppEvent::RestoreCancelledTurn(_)));
|
||||
if let AppEvent::InsertHistoryCell(cell) = event {
|
||||
if let Some(cell) = cell.as_any().downcast_ref::<UserHistoryCell>() {
|
||||
assert_eq!(cell.message, prompt);
|
||||
saw_prompt = true;
|
||||
}
|
||||
interrupted_history
|
||||
.push_str(&lines_to_single_string(&cell.display_lines(/*width*/ 80)));
|
||||
}
|
||||
}
|
||||
assert!(saw_prompt, "expected the submitted prompt in history");
|
||||
assert!(
|
||||
interrupted_history
|
||||
.contains("Conversation interrupted - tell the model what to do differently."),
|
||||
"expected normal interruption notice, got {interrupted_history:?}"
|
||||
);
|
||||
assert!(chat.bottom_pane.composer_text().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1031,7 +1055,7 @@ async fn pending_steer_esc_does_not_steal_vim_insert_escape() {
|
||||
chat.handle_key_event(esc);
|
||||
|
||||
match op_rx.try_recv() {
|
||||
Ok(Op::Interrupt { .. }) => {}
|
||||
Ok(Op::Interrupt) => {}
|
||||
other => panic!("expected Op::Interrupt, got {other:?}"),
|
||||
}
|
||||
assert!(chat.input_queue.submit_pending_steers_after_interrupt);
|
||||
@@ -1057,7 +1081,7 @@ async fn pending_steer_interrupt_uses_remapped_binding() {
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::F(12), KeyModifiers::NONE));
|
||||
|
||||
match op_rx.try_recv() {
|
||||
Ok(Op::Interrupt { .. }) => {}
|
||||
Ok(Op::Interrupt) => {}
|
||||
other => panic!("expected Op::Interrupt, got {other:?}"),
|
||||
}
|
||||
assert!(chat.input_queue.submit_pending_steers_after_interrupt);
|
||||
|
||||
@@ -228,7 +228,7 @@ pub(super) fn next_submit_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Op
|
||||
pub(super) fn next_interrupt_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Op>) {
|
||||
loop {
|
||||
match op_rx.try_recv() {
|
||||
Ok(Op::Interrupt { .. }) => return,
|
||||
Ok(Op::Interrupt) => return,
|
||||
Ok(_) => continue,
|
||||
Err(TryRecvError::Empty) => panic!("expected interrupt op but queue was empty"),
|
||||
Err(TryRecvError::Disconnected) => panic!("expected interrupt op but channel closed"),
|
||||
|
||||
@@ -2056,62 +2056,6 @@ async fn queued_menu_slash_keeps_agent_turn_complete_notification() {
|
||||
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slash_copy_uses_latest_surviving_response_after_rollback() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
replay_user_message_text(&mut chat, "user-1", "foo", ReplayKind::ThreadSnapshot);
|
||||
replay_agent_message(
|
||||
&mut chat,
|
||||
"agent-1",
|
||||
"foo response",
|
||||
ReplayKind::ThreadSnapshot,
|
||||
);
|
||||
replay_user_message_text(&mut chat, "user-2", "bar", ReplayKind::ThreadSnapshot);
|
||||
replay_agent_message(
|
||||
&mut chat,
|
||||
"agent-2",
|
||||
"bar response",
|
||||
ReplayKind::ThreadSnapshot,
|
||||
);
|
||||
let _ = drain_insert_history(&mut rx);
|
||||
assert_eq!(chat.last_agent_markdown_text(), Some("bar response"));
|
||||
|
||||
chat.truncate_agent_copy_history_to_user_turn_count(/*user_turn_count*/ 1);
|
||||
|
||||
assert_eq!(chat.last_agent_markdown_text(), Some("foo response"));
|
||||
chat.copy_last_agent_markdown_with(|markdown| {
|
||||
assert_eq!(markdown, "foo response");
|
||||
Ok(None)
|
||||
});
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slash_copy_reports_when_rewind_exceeds_retained_copy_history() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
replay_user_message_text(&mut chat, "user-1", "foo", ReplayKind::ThreadSnapshot);
|
||||
replay_agent_message(
|
||||
&mut chat,
|
||||
"agent-1",
|
||||
"foo response",
|
||||
ReplayKind::ThreadSnapshot,
|
||||
);
|
||||
let _ = drain_insert_history(&mut rx);
|
||||
|
||||
chat.truncate_agent_copy_history_to_user_turn_count(/*user_turn_count*/ 0);
|
||||
chat.dispatch_command(SlashCommand::Copy);
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
let rendered = lines_to_single_string(&cells[0]);
|
||||
assert!(
|
||||
rendered.contains(
|
||||
"Cannot copy that response after rewinding. Only the most recent 32 responses are available to /copy."
|
||||
),
|
||||
"expected evicted-history message, got {rendered:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slash_exit_requests_exit() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
@@ -1519,7 +1519,7 @@ async fn streaming_final_answer_keeps_task_running_state() {
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
|
||||
match op_rx.try_recv() {
|
||||
Ok(Op::Interrupt { .. }) => {}
|
||||
Ok(Op::Interrupt) => {}
|
||||
other => panic!("expected Op::Interrupt, got {other:?}"),
|
||||
}
|
||||
assert!(!chat.bottom_pane.quit_shortcut_hint_visible());
|
||||
@@ -1544,7 +1544,7 @@ async fn esc_interrupt_pauses_active_goal_turn() {
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt { .. })));
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt)));
|
||||
assert_goal_paused_event(&mut rx, thread_id);
|
||||
|
||||
update_thread_goal(&mut chat, thread_id, AppThreadGoalStatus::Paused);
|
||||
@@ -1581,7 +1581,7 @@ async fn request_user_input_interrupt_pauses_active_goal_turn() {
|
||||
|
||||
chat.handle_key_event(key_event);
|
||||
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt { .. })));
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt)));
|
||||
assert_goal_paused_event(&mut rx, thread_id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,10 @@ use codex_utils_path_uri::LegacyAppPathString;
|
||||
|
||||
impl ChatWidget {
|
||||
pub(super) fn on_patch_apply_begin(&mut self, changes: HashMap<PathBuf, FileChange>) {
|
||||
self.record_visible_turn_activity();
|
||||
self.add_to_history(history_cell::new_patch_event(changes, &self.config.cwd));
|
||||
}
|
||||
|
||||
pub(super) fn on_view_image_tool_call(&mut self, path: LegacyAppPathString) {
|
||||
self.record_visible_turn_activity();
|
||||
self.flush_answer_stream_with_separator();
|
||||
self.add_to_history(history_cell::new_view_image_tool_call(
|
||||
path,
|
||||
@@ -23,7 +21,6 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
pub(super) fn on_image_generation_begin(&mut self) {
|
||||
self.record_visible_turn_activity();
|
||||
self.flush_answer_stream_with_separator();
|
||||
}
|
||||
|
||||
@@ -69,7 +66,6 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
pub(super) fn on_web_search_begin(&mut self, call_id: String) {
|
||||
self.record_visible_turn_activity();
|
||||
self.flush_answer_stream_with_separator();
|
||||
self.flush_active_cell();
|
||||
self.transcript.active_cell = Some(Box::new(history_cell::new_active_web_search_call(
|
||||
@@ -116,7 +112,6 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
pub(super) fn on_collab_agent_tool_call(&mut self, item: ThreadItem) {
|
||||
self.record_visible_turn_activity();
|
||||
let ThreadItem::CollabAgentToolCall {
|
||||
id, tool, status, ..
|
||||
} = &item
|
||||
@@ -148,7 +143,6 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
pub(super) fn on_sub_agent_activity(&mut self, item: ThreadItem) {
|
||||
self.record_visible_turn_activity();
|
||||
if let Some(cell) = multi_agents::sub_agent_activity_history_cell(&item) {
|
||||
self.on_collab_event(cell);
|
||||
}
|
||||
@@ -168,7 +162,6 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
pub(crate) fn handle_mcp_tool_call_started_now(&mut self, item: ThreadItem) {
|
||||
self.record_visible_turn_activity();
|
||||
let ThreadItem::McpToolCall {
|
||||
id,
|
||||
server,
|
||||
|
||||
@@ -7,7 +7,6 @@ use super::*;
|
||||
|
||||
impl ChatWidget {
|
||||
pub(super) fn on_exec_approval_request(&mut self, _id: String, ev: ExecApprovalRequestEvent) {
|
||||
self.record_visible_turn_activity();
|
||||
let ev2 = ev.clone();
|
||||
self.defer_or_handle(
|
||||
|q| q.push_exec_approval(ev),
|
||||
@@ -20,7 +19,6 @@ impl ChatWidget {
|
||||
_id: String,
|
||||
ev: ApplyPatchApprovalRequestEvent,
|
||||
) {
|
||||
self.record_visible_turn_activity();
|
||||
let ev2 = ev.clone();
|
||||
self.defer_or_handle(
|
||||
|q| q.push_apply_patch_approval(ev),
|
||||
@@ -258,7 +256,6 @@ impl ChatWidget {
|
||||
request_id: AppServerRequestId,
|
||||
params: McpServerElicitationRequestParams,
|
||||
) {
|
||||
self.record_visible_turn_activity();
|
||||
let request_id2 = request_id.clone();
|
||||
let params2 = params.clone();
|
||||
self.defer_or_handle(
|
||||
@@ -268,7 +265,6 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
pub(super) fn on_request_user_input(&mut self, ev: ToolRequestUserInputParams) {
|
||||
self.record_visible_turn_activity();
|
||||
let ev2 = ev.clone();
|
||||
self.defer_or_handle(
|
||||
|q| q.push_user_input(ev),
|
||||
@@ -277,7 +273,6 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
pub(super) fn on_request_permissions(&mut self, ev: RequestPermissionsEvent) {
|
||||
self.record_visible_turn_activity();
|
||||
let ev2 = ev.clone();
|
||||
self.defer_or_handle(
|
||||
|q| q.push_request_permissions(ev),
|
||||
|
||||
@@ -14,17 +14,13 @@ pub(super) struct TranscriptState {
|
||||
pub(super) active_cell: Option<Box<dyn HistoryCell>>,
|
||||
/// Monotonic-ish counter used to invalidate transcript overlay caching.
|
||||
pub(super) active_cell_revision: u64,
|
||||
/// Raw markdown of the most recently completed agent response that
|
||||
/// survived any local thread rollback.
|
||||
/// Raw markdown of the most recently completed agent response.
|
||||
pub(super) last_agent_markdown: Option<String>,
|
||||
/// Copyable agent responses keyed by the number of visible user turns at
|
||||
/// the time the response completed.
|
||||
pub(super) agent_turn_markdowns: Vec<AgentTurnMarkdown>,
|
||||
/// Number of user turns currently reflected in the visible transcript.
|
||||
pub(super) visible_user_turn_count: usize,
|
||||
/// True when rollback discarded the requested copy source because it was
|
||||
/// older than the retained copy history.
|
||||
pub(super) copy_history_evicted_by_rollback: bool,
|
||||
/// Raw markdown of the most recently completed proposed plan.
|
||||
pub(super) latest_proposed_plan_markdown: Option<String>,
|
||||
/// Whether this turn already produced a copyable response.
|
||||
@@ -76,7 +72,6 @@ impl TranscriptState {
|
||||
}
|
||||
}
|
||||
self.last_agent_markdown = Some(markdown);
|
||||
self.copy_history_evicted_by_rollback = false;
|
||||
self.saw_copy_source_this_turn = true;
|
||||
}
|
||||
|
||||
@@ -88,21 +83,6 @@ impl TranscriptState {
|
||||
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;
|
||||
}
|
||||
|
||||
pub(super) fn truncate_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;
|
||||
}
|
||||
|
||||
@@ -134,18 +114,4 @@ mod tests {
|
||||
|
||||
assert_eq!(state.active_cell_revision, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_history_tracks_latest_visible_turn() {
|
||||
let mut state = TranscriptState::default();
|
||||
state.record_visible_user_turn();
|
||||
state.record_agent_markdown("first".to_string());
|
||||
state.record_visible_user_turn();
|
||||
state.record_agent_markdown("second".to_string());
|
||||
|
||||
state.truncate_copy_history_to_user_turn_count(/*user_turn_count*/ 1);
|
||||
|
||||
assert_eq!(state.last_agent_markdown.as_deref(), Some("first"));
|
||||
assert!(!state.copy_history_evicted_by_rollback);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -332,7 +332,6 @@ impl ChatWidget {
|
||||
self.plan_stream_controller = None;
|
||||
self.request_pending_usage_output_insertion_after_stream_shutdown();
|
||||
self.status_state.pending_status_indicator_restore = false;
|
||||
self.clear_cancel_edit();
|
||||
self.request_status_line_branch_refresh();
|
||||
self.request_status_line_git_summary_refresh();
|
||||
self.maybe_show_pending_rate_limit_prompt();
|
||||
|
||||
@@ -803,11 +803,6 @@ impl TranscriptOverlay {
|
||||
pub(crate) fn is_done(&self) -> bool {
|
||||
self.is_done
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn committed_cell_count(&self) -> usize {
|
||||
self.cells.len()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct StaticOverlay {
|
||||
|
||||
@@ -101,8 +101,7 @@ impl StatusIndicatorWidget {
|
||||
}
|
||||
|
||||
pub(crate) fn interrupt(&self) {
|
||||
self.app_event_tx
|
||||
.interrupt_and_restore_prompt_if_no_output();
|
||||
self.app_event_tx.interrupt();
|
||||
}
|
||||
|
||||
/// Update the animated header label (left of the brackets).
|
||||
|
||||
Reference in New Issue
Block a user