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