feat: TUI undo op (#5629)
This commit is contained in:
@@ -315,6 +315,11 @@ impl BottomPane {
|
||||
self.ctrl_c_quit_hint
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn status_indicator_visible(&self) -> bool {
|
||||
self.status.is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn show_esc_backtrack_hint(&mut self) {
|
||||
self.esc_backtrack_hint = true;
|
||||
self.composer.set_esc_backtrack_hint(true);
|
||||
@@ -359,6 +364,16 @@ impl BottomPane {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_status_indicator(&mut self) {
|
||||
if self.status.is_none() {
|
||||
self.status = Some(StatusIndicatorWidget::new(
|
||||
self.app_event_tx.clone(),
|
||||
self.frame_requester.clone(),
|
||||
));
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_context_window_percent(&mut self, percent: Option<i64>) {
|
||||
if self.context_window_percent == percent {
|
||||
return;
|
||||
|
||||
@@ -37,6 +37,8 @@ use codex_core::protocol::TokenUsage;
|
||||
use codex_core::protocol::TokenUsageInfo;
|
||||
use codex_core::protocol::TurnAbortReason;
|
||||
use codex_core::protocol::TurnDiffEvent;
|
||||
use codex_core::protocol::UndoCompletedEvent;
|
||||
use codex_core::protocol::UndoStartedEvent;
|
||||
use codex_core::protocol::UserMessageEvent;
|
||||
use codex_core::protocol::ViewImageToolCallEvent;
|
||||
use codex_core::protocol::WebSearchBeginEvent;
|
||||
@@ -113,16 +115,9 @@ use codex_core::protocol::AskForApproval;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig;
|
||||
use codex_file_search::FileMatch;
|
||||
use codex_git_tooling::CreateGhostCommitOptions;
|
||||
use codex_git_tooling::GhostCommit;
|
||||
use codex_git_tooling::GitToolingError;
|
||||
use codex_git_tooling::create_ghost_commit;
|
||||
use codex_git_tooling::restore_ghost_commit;
|
||||
use codex_protocol::plan_tool::UpdatePlanArgs;
|
||||
use strum::IntoEnumIterator;
|
||||
|
||||
const MAX_TRACKED_GHOST_COMMITS: usize = 20;
|
||||
|
||||
// Track information about an in-flight exec command.
|
||||
struct RunningCommand {
|
||||
command: Vec<String>,
|
||||
@@ -267,9 +262,6 @@ pub(crate) struct ChatWidget {
|
||||
pending_notification: Option<Notification>,
|
||||
// Simple review mode flag; used to adjust layout and banners.
|
||||
is_review_mode: bool,
|
||||
// List of ghost commits corresponding to each turn.
|
||||
ghost_snapshots: Vec<GhostCommit>,
|
||||
ghost_snapshots_disabled: bool,
|
||||
// Whether to add a final message separator after the last message
|
||||
needs_final_message_separator: bool,
|
||||
|
||||
@@ -672,6 +664,31 @@ impl ChatWidget {
|
||||
debug!("BackgroundEvent: {message}");
|
||||
}
|
||||
|
||||
fn on_undo_started(&mut self, event: UndoStartedEvent) {
|
||||
self.bottom_pane.ensure_status_indicator();
|
||||
let message = event
|
||||
.message
|
||||
.unwrap_or_else(|| "Undo in progress...".to_string());
|
||||
self.set_status_header(message);
|
||||
}
|
||||
|
||||
fn on_undo_completed(&mut self, event: UndoCompletedEvent) {
|
||||
let UndoCompletedEvent { success, message } = event;
|
||||
self.bottom_pane.hide_status_indicator();
|
||||
let message = message.unwrap_or_else(|| {
|
||||
if success {
|
||||
"Undo completed successfully.".to_string()
|
||||
} else {
|
||||
"Undo failed.".to_string()
|
||||
}
|
||||
});
|
||||
if success {
|
||||
self.add_info_message(message, None);
|
||||
} else {
|
||||
self.add_error_message(message);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_stream_error(&mut self, message: String) {
|
||||
if self.retry_status_header.is_none() {
|
||||
self.retry_status_header = Some(self.current_status_header.clone());
|
||||
@@ -989,8 +1006,6 @@ impl ChatWidget {
|
||||
suppress_session_configured_redraw: false,
|
||||
pending_notification: None,
|
||||
is_review_mode: false,
|
||||
ghost_snapshots: Vec::new(),
|
||||
ghost_snapshots_disabled: true,
|
||||
needs_final_message_separator: false,
|
||||
last_rendered_width: std::cell::Cell::new(None),
|
||||
feedback,
|
||||
@@ -1057,8 +1072,6 @@ impl ChatWidget {
|
||||
suppress_session_configured_redraw: true,
|
||||
pending_notification: None,
|
||||
is_review_mode: false,
|
||||
ghost_snapshots: Vec::new(),
|
||||
ghost_snapshots_disabled: true,
|
||||
needs_final_message_separator: false,
|
||||
last_rendered_width: std::cell::Cell::new(None),
|
||||
feedback,
|
||||
@@ -1211,7 +1224,7 @@ impl ChatWidget {
|
||||
self.app_event_tx.send(AppEvent::ExitRequest);
|
||||
}
|
||||
SlashCommand::Undo => {
|
||||
self.undo_last_snapshot();
|
||||
self.app_event_tx.send(AppEvent::CodexOp(Op::Undo));
|
||||
}
|
||||
SlashCommand::Diff => {
|
||||
self.add_diff_in_progress();
|
||||
@@ -1328,8 +1341,6 @@ impl ChatWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
self.capture_ghost_snapshot();
|
||||
|
||||
let mut items: Vec<UserInput> = Vec::new();
|
||||
|
||||
if !text.is_empty() {
|
||||
@@ -1362,57 +1373,6 @@ impl ChatWidget {
|
||||
self.needs_final_message_separator = false;
|
||||
}
|
||||
|
||||
fn capture_ghost_snapshot(&mut self) {
|
||||
if self.ghost_snapshots_disabled {
|
||||
return;
|
||||
}
|
||||
|
||||
let options = CreateGhostCommitOptions::new(&self.config.cwd);
|
||||
match create_ghost_commit(&options) {
|
||||
Ok(commit) => {
|
||||
self.ghost_snapshots.push(commit);
|
||||
if self.ghost_snapshots.len() > MAX_TRACKED_GHOST_COMMITS {
|
||||
self.ghost_snapshots.remove(0);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
self.ghost_snapshots_disabled = true;
|
||||
let (message, hint) = match &err {
|
||||
GitToolingError::NotAGitRepository { .. } => (
|
||||
"Snapshots disabled: current directory is not a Git repository."
|
||||
.to_string(),
|
||||
None,
|
||||
),
|
||||
_ => (
|
||||
format!("Snapshots disabled after error: {err}"),
|
||||
Some(
|
||||
"Restart Codex after resolving the issue to re-enable snapshots."
|
||||
.to_string(),
|
||||
),
|
||||
),
|
||||
};
|
||||
self.add_info_message(message, hint);
|
||||
tracing::warn!("failed to create ghost snapshot: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn undo_last_snapshot(&mut self) {
|
||||
let Some(commit) = self.ghost_snapshots.pop() else {
|
||||
self.add_info_message("No snapshot available to undo.".to_string(), None);
|
||||
return;
|
||||
};
|
||||
|
||||
if let Err(err) = restore_ghost_commit(&self.config.cwd, &commit) {
|
||||
self.add_error_message(format!("Failed to restore snapshot: {err}"));
|
||||
self.ghost_snapshots.push(commit);
|
||||
return;
|
||||
}
|
||||
|
||||
let short_id: String = commit.id().chars().take(8).collect();
|
||||
self.add_info_message(format!("Restored workspace to snapshot {short_id}"), None);
|
||||
}
|
||||
|
||||
/// Replay a subset of initial events into the UI to seed the transcript when
|
||||
/// resuming an existing session. This approximates the live event flow and
|
||||
/// is intentionally conservative: only safe-to-replay items are rendered to
|
||||
@@ -1510,6 +1470,8 @@ impl ChatWidget {
|
||||
EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => {
|
||||
self.on_background_event(message)
|
||||
}
|
||||
EventMsg::UndoStarted(ev) => self.on_undo_started(ev),
|
||||
EventMsg::UndoCompleted(ev) => self.on_undo_completed(ev),
|
||||
EventMsg::StreamError(StreamErrorEvent { message }) => self.on_stream_error(message),
|
||||
EventMsg::UserMessage(ev) => {
|
||||
if from_replay {
|
||||
|
||||
@@ -34,6 +34,8 @@ use codex_core::protocol::ReviewRequest;
|
||||
use codex_core::protocol::StreamErrorEvent;
|
||||
use codex_core::protocol::TaskCompleteEvent;
|
||||
use codex_core::protocol::TaskStartedEvent;
|
||||
use codex_core::protocol::UndoCompletedEvent;
|
||||
use codex_core::protocol::UndoStartedEvent;
|
||||
use codex_core::protocol::ViewImageToolCallEvent;
|
||||
use codex_protocol::ConversationId;
|
||||
use codex_protocol::plan_tool::PlanItemArg;
|
||||
@@ -294,8 +296,6 @@ fn make_chatwidget_manual() -> (
|
||||
suppress_session_configured_redraw: false,
|
||||
pending_notification: None,
|
||||
is_review_mode: false,
|
||||
ghost_snapshots: Vec::new(),
|
||||
ghost_snapshots_disabled: false,
|
||||
needs_final_message_separator: false,
|
||||
last_rendered_width: std::cell::Cell::new(None),
|
||||
feedback: codex_feedback::CodexFeedback::new(),
|
||||
@@ -849,6 +849,90 @@ fn slash_init_skips_when_project_doc_exists() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_undo_sends_op() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
chat.dispatch_command(SlashCommand::Undo);
|
||||
|
||||
match rx.try_recv() {
|
||||
Ok(AppEvent::CodexOp(Op::Undo)) => {}
|
||||
other => panic!("expected AppEvent::CodexOp(Op::Undo), got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_success_events_render_info_messages() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-1".to_string(),
|
||||
msg: EventMsg::UndoStarted(UndoStartedEvent {
|
||||
message: Some("Undo requested for the last turn...".to_string()),
|
||||
}),
|
||||
});
|
||||
assert!(
|
||||
chat.bottom_pane.status_indicator_visible(),
|
||||
"status indicator should be visible during undo"
|
||||
);
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-1".to_string(),
|
||||
msg: EventMsg::UndoCompleted(UndoCompletedEvent {
|
||||
success: true,
|
||||
message: None,
|
||||
}),
|
||||
});
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert_eq!(cells.len(), 1, "expected final status only");
|
||||
assert!(
|
||||
!chat.bottom_pane.status_indicator_visible(),
|
||||
"status indicator should be hidden after successful undo"
|
||||
);
|
||||
|
||||
let completed = lines_to_single_string(&cells[0]);
|
||||
assert!(
|
||||
completed.contains("Undo completed successfully."),
|
||||
"expected default success message, got {completed:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_failure_events_render_error_message() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-2".to_string(),
|
||||
msg: EventMsg::UndoStarted(UndoStartedEvent { message: None }),
|
||||
});
|
||||
assert!(
|
||||
chat.bottom_pane.status_indicator_visible(),
|
||||
"status indicator should be visible during undo"
|
||||
);
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-2".to_string(),
|
||||
msg: EventMsg::UndoCompleted(UndoCompletedEvent {
|
||||
success: false,
|
||||
message: Some("Failed to restore workspace state.".to_string()),
|
||||
}),
|
||||
});
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert_eq!(cells.len(), 1, "expected final status only");
|
||||
assert!(
|
||||
!chat.bottom_pane.status_indicator_visible(),
|
||||
"status indicator should be hidden after failed undo"
|
||||
);
|
||||
|
||||
let completed = lines_to_single_string(&cells[0]);
|
||||
assert!(
|
||||
completed.contains("Failed to restore workspace state."),
|
||||
"expected failure message, got {completed:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The commit picker shows only commit subjects (no timestamps).
|
||||
#[test]
|
||||
fn review_commit_picker_shows_subjects_without_timestamps() {
|
||||
|
||||
@@ -39,7 +39,7 @@ impl SlashCommand {
|
||||
SlashCommand::Init => "create an AGENTS.md file with instructions for Codex",
|
||||
SlashCommand::Compact => "summarize conversation to prevent hitting the context limit",
|
||||
SlashCommand::Review => "review my current changes and find issues",
|
||||
SlashCommand::Undo => "restore the workspace to the last Codex snapshot",
|
||||
SlashCommand::Undo => "ask Codex to undo a turn",
|
||||
SlashCommand::Quit => "exit Codex",
|
||||
SlashCommand::Diff => "show git diff (including untracked files)",
|
||||
SlashCommand::Mention => "mention a file",
|
||||
@@ -85,14 +85,5 @@ impl SlashCommand {
|
||||
|
||||
/// Return all built-in commands in a Vec paired with their command string.
|
||||
pub fn built_in_slash_commands() -> Vec<(&'static str, SlashCommand)> {
|
||||
let show_beta_features = beta_features_enabled();
|
||||
|
||||
SlashCommand::iter()
|
||||
.filter(|cmd| *cmd != SlashCommand::Undo || show_beta_features)
|
||||
.map(|c| (c.command(), c))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn beta_features_enabled() -> bool {
|
||||
std::env::var_os("BETA_FEATURE").is_some()
|
||||
SlashCommand::iter().map(|c| (c.command(), c)).collect()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user