Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion codex-rs/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions codex-rs/core/src/rollout/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool {
| EventMsg::TokenCount(_)
| EventMsg::EnteredReviewMode(_)
| EventMsg::ExitedReviewMode(_)
| EventMsg::UndoCompleted(_)
| EventMsg::TurnAborted(_) => true,
EventMsg::Error(_)
| EventMsg::TaskStarted(_)
Expand All @@ -67,6 +68,7 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool {
| EventMsg::PatchApplyEnd(_)
| EventMsg::TurnDiff(_)
| EventMsg::GetHistoryEntryResponse(_)
| EventMsg::UndoStarted(_)
| EventMsg::McpListToolsResponse(_)
| EventMsg::ListCustomPromptsResponse(_)
| EventMsg::PlanUpdate(_)
Expand Down
20 changes: 20 additions & 0 deletions codex-rs/protocol/src/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,9 @@ pub enum Op {
/// to generate a summary which will be returned as an AgentMessage event.
Compact,

/// Request Codex to undo a turn (turn are stacked so it is the same effect as CMD + Z).
Undo,

/// Request a code review from the agent.
Review { review_request: ReviewRequest },

Expand Down Expand Up @@ -484,6 +487,10 @@ pub enum EventMsg {

BackgroundEvent(BackgroundEventEvent),

UndoStarted(UndoStartedEvent),

UndoCompleted(UndoCompletedEvent),

/// Notification that a model stream experienced an error or disconnect
/// and the system is handling it (e.g., retrying with backoff).
StreamError(StreamErrorEvent),
Expand Down Expand Up @@ -1158,6 +1165,19 @@ pub struct BackgroundEventEvent {
pub message: String,
}

#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
pub struct UndoStartedEvent {
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}

#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
pub struct UndoCompletedEvent {
pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}

#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
pub struct StreamErrorEvent {
pub message: String,
Expand Down
1 change: 0 additions & 1 deletion codex-rs/tui/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ codex-common = { workspace = true, features = [
] }
codex-core = { workspace = true }
codex-file-search = { workspace = true }
codex-git-tooling = { workspace = true }
codex-login = { workspace = true }
codex-ollama = { workspace = true }
codex-protocol = { workspace = true }
Expand Down
15 changes: 15 additions & 0 deletions codex-rs/tui/src/bottom_pane/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,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);
Expand Down Expand Up @@ -357,6 +362,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;
Expand Down
98 changes: 30 additions & 68 deletions codex-rs/tui/src/chatwidget.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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>,
Expand Down Expand Up @@ -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,

Expand Down Expand Up @@ -636,6 +628,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());
Expand Down Expand Up @@ -952,8 +969,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,
Expand Down Expand Up @@ -1019,8 +1034,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,
Expand Down Expand Up @@ -1184,7 +1197,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();
Expand Down Expand Up @@ -1301,8 +1314,6 @@ impl ChatWidget {
return;
}

self.capture_ghost_snapshot();

let mut items: Vec<UserInput> = Vec::new();

if !text.is_empty() {
Expand Down Expand Up @@ -1335,57 +1346,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
Expand Down Expand Up @@ -1483,6 +1443,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 {
Expand Down
88 changes: 86 additions & 2 deletions codex-rs/tui/src/chatwidget/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -845,6 +845,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() {
Expand Down
2 changes: 1 addition & 1 deletion codex-rs/tui/src/slash_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading