返回 CodeWhale
undo.rs
根目录 / crates / tui / src / commands / groups / debug / undo.rs
1 //! Undo, retry, edit, and diff commands.
2
3 use crate::dependencies::{ExternalTool, Git};
4 use crate::tui::app::{App, AppAction};
5 use crate::tui::history::HistoryCell;
6 use codewhale_models::ContentBlock;
7
8 use super::CommandResult;
9
10 /// Opening of the [`patch_undo`] message that means the snapshot repo could
11 /// not be opened at all — as opposed to "there is nothing to revert". The
12 /// dispatcher must carry this into the conversation-only fallback: dropping it
13 /// left `/undo` reporting `Removed N message(s)` while the workspace files it
14 /// implied were reverted were never touched.
15 pub(in crate::commands) const SNAPSHOT_REPO_UNAVAILABLE_PREFIX: &str = "Snapshot repo unavailable";
16
17 /// Told to the user whenever conversation-only undo runs because the snapshot
18 /// repo was unavailable.
19 pub(in crate::commands) const FILES_NOT_REVERTED_NOTE: &str =
20 "Workspace files were NOT reverted — only the conversation was rolled back.";
21
22 /// Remove last message pair (user + assistant).
23 ///
24 /// This is the old `/undo` behaviour — it removes the most recent
25 /// user+assistant conversation pair from history and API messages.
26 /// The new `/undo` first tries to revert workspace files via
27 /// [`patch_undo`]; if no snapshots are available it falls back to
28 /// this function.
29 pub fn undo_conversation(app: &mut App) -> CommandResult {
30 // Remove from display history (up to the last user message)
31 let mut removed_count = 0;
32 while !app.history.is_empty() {
33 let last_is_user = matches!(app.history.last(), Some(HistoryCell::User { .. }));
34 app.pop_history();
35 removed_count += 1;
36 if last_is_user {
37 break;
38 }
39 }
40
41 // Remove from API messages
42 while let Some(last) = app.api_messages.last() {
43 if last.role == "user" {
44 app.pop_api_message();
45 break;
46 }
47 app.pop_api_message();
48 }
49
50 if removed_count > 0 {
51 // Keep tool/index mappings consistent after truncation.
52 app.tool_cells.clear();
53 app.tool_details_by_cell.clear();
54 app.exploring_entries.clear();
55 app.ignored_tool_calls.clear();
56 app.mark_history_updated();
57 CommandResult::message(format!("Removed {removed_count} message(s)"))
58 } else {
59 CommandResult::message("Nothing to undo")
60 }
61 }
62
63 pub(crate) fn prune_undone_tool_context(app: &mut App, tool_id: &str) {
64 if let Some(history_idx) = app.tool_cells.get(tool_id).copied() {
65 app.truncate_history_to(history_idx);
66 }
67
68 let Some((msg_idx, block_idx)) =
69 app.api_messages
70 .iter()
71 .enumerate()
72 .find_map(|(msg_idx, msg)| {
73 msg.content
74 .iter()
75 .position(
76 |block| matches!(block, ContentBlock::ToolUse { id, ..} if id == tool_id),
77 )
78 .map(|block_idx| (msg_idx, block_idx))
79 })
80 else {
81 return;
82 };
83
84 let kept_blocks = app.api_messages[msg_idx].content[..block_idx].to_vec();
85 let kept_tool_ids: std::collections::HashSet<String> = kept_blocks
86 .iter()
87 .filter_map(|block| match block {
88 ContentBlock::ToolUse { id, .. } => Some(id.clone()),
89 _ => None,
90 })
91 .collect();
92
93 if kept_blocks.is_empty() {
94 app.truncate_api_messages(msg_idx);
95 return;
96 }
97 // Re-inserted tool results keep the stamp they earned, so the journal's
98 // timeline survives the prune.
99 let preserved_tool_results: Vec<_> =
100 app.api_messages_stamped()
101 .skip(msg_idx + 1)
102 .take_while(|(msg, _)| {
103 msg.role == "user"
104 && !msg.content.is_empty()
105 && msg
106 .content
107 .iter()
108 .all(|block| tool_result_id(block).is_some())
109 })
110 .filter(|(msg, _)| {
111 msg.role == "user"
112 && !msg.content.is_empty()
113 && msg.content.iter().all(|block| {
114 tool_result_id(block).is_some_and(|id| kept_tool_ids.contains(id))
115 })
116 })
117 .map(|(msg, stamp)| (msg.clone(), stamp))
118 .collect();
119 app.truncate_api_messages(msg_idx + 1);
120 app.api_messages_mut()[msg_idx].content = kept_blocks;
121 for (message, stamp) in preserved_tool_results {
122 app.push_api_message_stamped(message, stamp);
123 }
124 }
125
126 fn prune_undone_turn_context(app: &mut App) {
127 if let Some(history_idx) = app
128 .history
129 .iter()
130 .rposition(|cell| matches!(cell, HistoryCell::User { .. }))
131 {
132 app.truncate_history_to(history_idx);
133 }
134
135 if let Some(api_idx) = app.api_messages.iter().rposition(|msg| msg.role == "user") {
136 app.truncate_api_messages(api_idx);
137 }
138 }
139
140 fn tool_result_id(block: &ContentBlock) -> Option<&String> {
141 match block {
142 ContentBlock::ToolResult { tool_use_id, .. }
143 | ContentBlock::ToolSearchToolResult { tool_use_id, .. }
144 | ContentBlock::CodeExecutionToolResult { tool_use_id, .. } => Some(tool_use_id),
145 _ => None,
146 }
147 }
148
149 /// Revert the most recent write tool (apply_patch/edit_file/write_file) or turn.
150 ///
151 /// Opens the side-git snapshot repo and finds the most recent snapshot,
152 /// preferring per-tool snapshots (`tool:*`) over pre-turn snapshots
153 /// (`pre-turn:*`). Restores files from that snapshot and shows a diff
154 /// summary. Falls back to conversation undo when no snapshots exist.
155 ///
156 /// Posts a `HistoryCell::System` entry so the user can see what was
157 /// reverted in the transcript.
158 pub fn patch_undo(app: &mut App) -> CommandResult {
159 let workspace = app.workspace.clone();
160
161 let repo = match crate::snapshot::SnapshotRepo::open_or_init(&workspace) {
162 Ok(r) => r,
163 Err(e) => {
164 return CommandResult::error(format!(
165 "{SNAPSHOT_REPO_UNAVAILABLE_PREFIX} for {}: {e}",
166 workspace.display(),
167 ));
168 }
169 };
170
171 let snapshots = match repo.list(100) {
172 Ok(s) => s,
173 Err(e) => {
174 return CommandResult::error(format!("Failed to list snapshots: {e}"));
175 }
176 };
177
178 if snapshots.is_empty() {
179 return CommandResult::message("No snapshots found to undo — nothing to revert.");
180 }
181
182 // Automatic file rollback is allowed only when ownership is provable.
183 // Untagged legacy snapshots and snapshots from another conversation may
184 // describe unrelated user work in this same workspace, so fail closed
185 // and let the command dispatcher fall back to conversation-only undo.
186 let Some(current_session) = app.current_session_id.as_deref() else {
187 return CommandResult::message(
188 "No undoable snapshot is tagged for the current session — nothing to revert.",
189 );
190 };
191 let candidates: Vec<crate::snapshot::Snapshot> = snapshots
192 .into_iter()
193 .filter(|s| s.label.starts_with("tool:") || s.label.starts_with("pre-turn:"))
194 .filter(|s| s.session_id.as_deref() == Some(current_session))
195 .collect();
196
197 if candidates.is_empty() {
198 return CommandResult::message(
199 "No undoable snapshots for the current session — nothing to revert.",
200 );
201 }
202
203 // Pick the newest current-session candidate whose tree differs from the
204 // workspace. Skipping identical snapshots makes repeated `/undo` walk
205 // backward only inside the proven session boundary.
206 let mut target = None;
207 for snapshot in &candidates {
208 match repo.work_tree_matches_snapshot(&snapshot.id) {
209 Ok(false) => {
210 target = Some(snapshot);
211 break;
212 }
213 Ok(true) => {}
214 Err(error) => {
215 return CommandResult::error(format!("Failed to compare snapshot: {error}"));
216 }
217 }
218 }
219
220 let Some(target) = target else {
221 return CommandResult::message(
222 "No undoable snapshot differs from the current workspace — nothing to revert.",
223 );
224 };
225
226 // Restoring workspace files is a mutation. Apply the trust gate only
227 // after finding a real, current-session target so chat-only `/undo` can
228 // still fall back to conversation history in ordinary mode.
229 if !(app.yolo || app.trust_mode) {
230 return CommandResult::message(
231 "Refusing to undo workspace files outside trusted mode.\n\
232 Run `/trust on` or select Full Access with Shift+Tab, then re-run `/undo`.",
233 );
234 }
235
236 // Capture what this restore is about to change *before* it runs: after the
237 // checkout the work tree matches the snapshot and the diff is empty by
238 // construction. Computed in the side repo, not the user's — the user's
239 // `git diff --stat` reports their own uncommitted work, which is not what
240 // the undo changed.
241 let diff_stat = match repo.snapshot_diff_stat(&target.id) {
242 Ok(stat) => stat,
243 Err(e) => {
244 tracing::warn!(target: "snapshot", "diff stat for the undo summary failed: {e}");
245 None
246 }
247 };
248
249 if let Err(e) = repo.restore(&target.id) {
250 return CommandResult::error(format!("Restore failed: {e}"));
251 }
252
253 if let Some(tool_id) = target.label.strip_prefix("tool:") {
254 prune_undone_tool_context(app, tool_id);
255 } else if target.label.starts_with("pre-turn:") {
256 prune_undone_turn_context(app);
257 }
258
259 let short = &target.id.as_str()[..target.id.as_str().len().min(8)];
260 let summary = match diff_stat {
261 Some(ref stat) => {
262 format!(
263 "Restored snapshot '{}' ({}). Files affected:\n{stat}",
264 target.label, short
265 )
266 }
267 None => {
268 format!(
269 "Restored snapshot '{}' ({}). No diff changes detected.",
270 target.label, short
271 )
272 }
273 };
274
275 // Post a system cell so the reverted state is visible in the transcript.
276 app.push_history_cell(HistoryCell::System {
277 content: format!(
278 "/undo reverted workspace to snapshot '{}' ({})",
279 target.label, short
280 ),
281 });
282
283 CommandResult::with_message_and_action(
284 summary,
285 AppAction::SyncSession {
286 session_id: app.current_session_id.clone(),
287 messages: app.api_messages.as_ref().clone(),
288 system_prompt: app.system_prompt.clone(),
289 model: app.model.clone(),
290 workspace: app.workspace.clone(),
291 mode: app.mode,
292 },
293 )
294 }
295
296 /// Load the last user message back into the composer for editing.
297 ///
298 /// Searches `app.history` for the most recent `HistoryCell::User`, copies its
299 /// content into `app.input`, and positions the cursor at the end so the user
300 /// can edit and press Enter to resubmit. The original exchange stays visible
301 /// in the transcript.
302 pub fn edit(app: &mut App) -> CommandResult {
303 let last_user = app.history.iter().rev().find_map(|cell| match cell {
304 HistoryCell::User { content } => Some(content.clone()),
305 _ => None,
306 });
307
308 match last_user {
309 Some(content) => {
310 app.input = content;
311 app.cursor_position = app.input.chars().count();
312 app.edit_in_progress = true;
313 CommandResult::message(
314 "Last message loaded into composer — edit and press Enter to resubmit",
315 )
316 }
317 None => CommandResult::message("No previous message to edit"),
318 }
319 }
320
321 /// Show git diff output since session start.
322 ///
323 /// Runs `git diff --stat` and `git diff --name-only` in the workspace
324 /// directory. Displays which files have changed and a stat summary. If no
325 /// changes exist or git fails, returns an appropriate message.
326 pub fn diff(app: &mut App) -> CommandResult {
327 let workspace = app.workspace.clone();
328
329 let Some(mut name_only_cmd) = Git::command() else {
330 return CommandResult::error("git not found on PATH");
331 };
332 let Some(mut stat_cmd) = Git::command() else {
333 return CommandResult::error("git not found on PATH");
334 };
335 let name_only_output = name_only_cmd
336 .args(["diff", "--name-only"])
337 .current_dir(&workspace)
338 .output();
339 let stat_output = stat_cmd
340 .args(["diff", "--stat"])
341 .current_dir(&workspace)
342 .output();
343
344 match (name_only_output, stat_output) {
345 (Ok(name_only), Ok(stat)) => {
346 let name_stdout = String::from_utf8_lossy(&name_only.stdout);
347 let stat_stdout = String::from_utf8_lossy(&stat.stdout);
348
349 if name_stdout.trim().is_empty() {
350 return CommandResult::message("No changes since session start");
351 }
352
353 let files: Vec<&str> = name_stdout.lines().filter(|l| !l.is_empty()).collect();
354 let file_count = files.len();
355 let file_list = files.join("\n");
356
357 // Detect rename entries (e.g. "foo -> bar") and exclude them
358 // from the file-count header so the user sees only actual
359 // modifications.
360 let renamed_count = files.iter().filter(|f| f.contains(" -> ")).count();
361 let summary = if renamed_count > 0 {
362 format!("Changed files ({file_count}, {renamed_count} renamed):\n{file_list}")
363 } else {
364 format!("Changed files ({file_count}):\n{file_list}")
365 };
366
367 let stat_str = stat_stdout.trim();
368 let mut message = summary;
369 if !stat_str.is_empty() {
370 message.push_str("\n\n── Stat ──\n");
371 message.push_str(stat_str);
372 }
373 CommandResult::message(message)
374 }
375 (Err(e), _) | (_, Err(e)) => {
376 CommandResult::message(format!("Git diff failed — is this a git repository?\n{e}"))
377 }
378 }
379 }
380
381 /// Retry last request - remove last exchange and re-send the user's message
382 pub fn retry(app: &mut App) -> CommandResult {
383 let last_user_input = app.history.iter().rev().find_map(|cell| match cell {
384 HistoryCell::User { content } => Some(content.clone()),
385 _ => None,
386 });
387
388 match last_user_input {
389 Some(input) => {
390 undo_conversation(app);
391 let display_input = if input.len() > 50 {
392 let truncate_at = input
393 .char_indices()
394 .take_while(|(i, _)| *i <= 50)
395 .last()
396 .map_or(0, |(i, _)| i);
397 format!("{}...", &input[..truncate_at])
398 } else {
399 input.clone()
400 };
401 CommandResult::with_message_and_action(
402 format!("Retrying: {display_input}"),
403 AppAction::SendMessage(input),
404 )
405 }
406 None => CommandResult::error("No previous request to retry"),
407 }
408 }
409
409 lines RUST