返回 CodeWhale
undo.rs
根目录 / crates / tui / src / commands / groups / debug / undo.rs
1 //! Portable edit/diff/undo/retry policy. Hosts own I/O and mutation; typed
2 //! outcomes preserve refusal versus conversation-fallback without parsing text.
3
4 use super::{CommandResult, DebugAction};
5 use codewhale_command_contract::facets::*;
6 use codewhale_command_contract::handler::{
7 CommandCapabilities as Caps, CommandContexts, CommandHandler,
8 };
9 use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand};
10
11 macro_rules! registration {
12 ($ty:ident, $name:literal, $aliases:expr, $key:literal, $caps:expr, $handler:ident) => {
13 pub(in crate::commands) struct $ty;
14 impl RegisterCommand<CommandResult> for $ty {
15 fn info() -> &'static CommandInfo {
16 &CommandInfo {
17 name: $name,
18 aliases: $aliases,
19 usage: concat!("/", $name),
20 description_key: $key,
21 }
22 }
23 fn handler() -> CommandHandler<CommandResult> {
24 CommandHandler::Contextual {
25 capabilities: $caps,
26 handler: $handler,
27 }
28 }
29 }
30 };
31 }
32 registration!(
33 EditCmd,
34 "edit",
35 &[],
36 "cmd_edit_description",
37 Caps::DEBUG_HISTORY,
38 edit
39 );
40 registration!(
41 DiffCmd,
42 "diff",
43 &[],
44 "cmd_diff_description",
45 Caps::DEBUG_DIFF,
46 diff
47 );
48 registration!(
49 UndoCmd,
50 "undo",
51 &[],
52 "cmd_undo_description",
53 Caps::DEBUG_UNDO.union(Caps::DEBUG_HISTORY),
54 undo
55 );
56 registration!(
57 RetryCmd,
58 "retry",
59 &["chongshi"],
60 "cmd_retry_description",
61 Caps::DEBUG_HISTORY,
62 retry
63 );
64
65 pub(in crate::commands) const SNAPSHOT_REPO_UNAVAILABLE_PREFIX: &str = "Snapshot repo unavailable";
66 pub(in crate::commands) const FILES_NOT_REVERTED_NOTE: &str =
67 "Workspace files were NOT reverted — only the conversation was rolled back.";
68
69 pub(super) fn edit(contexts: CommandContexts<'_>, _: Option<&str>) -> CommandResult {
70 let mut parts = contexts.into_parts();
71 let Some(history) = parts.debug_history.as_deref_mut() else {
72 return CommandResult::error("Command capability unavailable: debug_history");
73 };
74 match history.last_user_input() {
75 Some(input) => {
76 history.load_composer(input);
77 CommandResult::message(
78 "Last message loaded into composer — edit and press Enter to resubmit",
79 )
80 }
81 None => CommandResult::message("No previous message to edit"),
82 }
83 }
84
85 pub(super) fn retry(contexts: CommandContexts<'_>, _: Option<&str>) -> CommandResult {
86 let mut parts = contexts.into_parts();
87 let Some(history) = parts.debug_history.as_deref_mut() else {
88 return CommandResult::error("Command capability unavailable: debug_history");
89 };
90 match history.last_user_input() {
91 Some(input) => {
92 let undone = history.undo_conversation();
93 let display_input = if input.len() > 50 {
94 let truncate_at = input
95 .char_indices()
96 .take_while(|(i, _)| *i <= 50)
97 .last()
98 .map_or(0, |(i, _)| i);
99 format!("{}...", &input[..truncate_at])
100 } else {
101 input.clone()
102 };
103 CommandResult::with_message_and_action(
104 format!("Retrying: {display_input}"),
105 DebugAction::ConversationUndo {
106 sync: undone.sync,
107 retry_input: Some(input),
108 },
109 )
110 }
111 None => CommandResult::error("No previous request to retry"),
112 }
113 }
114
115 pub(super) fn diff(contexts: CommandContexts<'_>, _: Option<&str>) -> CommandResult {
116 let mut parts = contexts.into_parts();
117 let Some(diff) = parts.debug_diff.as_deref_mut() else {
118 return CommandResult::error("Command capability unavailable: debug_diff");
119 };
120 match diff.diff() {
121 DebugDiffObservation::GitUnavailable => CommandResult::error("git not found on PATH"),
122 DebugDiffObservation::Failed(error) => CommandResult::message(format!(
123 "Git diff failed — is this a git repository?\n{error}"
124 )),
125 DebugDiffObservation::Output { names, stat } => {
126 if names.trim().is_empty() {
127 return CommandResult::message("No changes since session start");
128 }
129
130 let files: Vec<&str> = names.lines().filter(|l| !l.is_empty()).collect();
131 let file_count = files.len();
132 let file_list = files.join("\n");
133
134 // Detect rename entries (e.g. "foo -> bar") and exclude them
135 // from the file-count header so the user sees only actual
136 // modifications.
137 let renamed_count = files.iter().filter(|f| f.contains(" -> ")).count();
138 let summary = if renamed_count > 0 {
139 format!("Changed files ({file_count}, {renamed_count} renamed):\n{file_list}")
140 } else {
141 format!("Changed files ({file_count}):\n{file_list}")
142 };
143
144 let stat_str = stat.trim();
145 let mut message = summary;
146 if !stat_str.is_empty() {
147 message.push_str("\n\n── Stat ──\n");
148 message.push_str(stat_str);
149 }
150 CommandResult::message(message)
151 }
152 }
153 }
154
155 /// A conversation undo that removed anything hands the truncated conversation
156 /// to the engine, which owns the model context (#6788).
157 pub(in crate::commands) fn conversation_result(undone: DebugConversationUndo) -> CommandResult {
158 if undone.removed > 0 {
159 CommandResult::with_message_and_action(
160 format!("Removed {} message(s)", undone.removed),
161 DebugAction::ConversationUndo {
162 sync: undone.sync,
163 retry_input: None,
164 },
165 )
166 } else {
167 CommandResult::message("Nothing to undo")
168 }
169 }
170
171 pub(super) fn undo(contexts: CommandContexts<'_>, _: Option<&str>) -> CommandResult {
172 let mut parts = contexts.into_parts();
173 let Some(undo) = parts.debug_undo.as_deref_mut() else {
174 return CommandResult::error("Command capability unavailable: debug_undo");
175 };
176 let Some(history) = parts.debug_history.as_deref_mut() else {
177 return CommandResult::error("Command capability unavailable: debug_history");
178 };
179 match undo.undo_files() {
180 DebugUndoOutcome::NoSnapshots
181 | DebugUndoOutcome::NoSession
182 | DebugUndoOutcome::NoOwnedSteps
183 | DebugUndoOutcome::NoDifference => conversation_result(history.undo_conversation()),
184 DebugUndoOutcome::RepoUnavailable { workspace, error } => {
185 let mut result = conversation_result(history.undo_conversation());
186 let note = format!(
187 "{FILES_NOT_REVERTED_NOTE}\n{SNAPSHOT_REPO_UNAVAILABLE_PREFIX} for {}: {error}",
188 workspace.display()
189 );
190 result.message = Some(match result.message.take() {
191 Some(message) => format!("{message}\n{note}"),
192 None => note,
193 });
194 result
195 }
196 outcome => patch_result(outcome),
197 }
198 }
199
200 /// Format snapshot outcomes without invoking host operations. Host tests also
201 /// use this for the file-only operation before the command's chat fallback.
202 pub(in crate::commands) fn patch_result(outcome: DebugUndoOutcome) -> CommandResult {
203 match outcome {
204 DebugUndoOutcome::RepoUnavailable { workspace, error } => CommandResult::error(format!(
205 "{SNAPSHOT_REPO_UNAVAILABLE_PREFIX} for {}: {error}",
206 workspace.display()
207 )),
208 DebugUndoOutcome::SnapshotPending => CommandResult::message(
209 "The last turn's workspace snapshot is still being written; nothing was changed. Run /undo again in a moment.",
210 ),
211 DebugUndoOutcome::NoSnapshots => {
212 CommandResult::message("No snapshots found to undo — nothing to revert.")
213 }
214 DebugUndoOutcome::NoSession => CommandResult::message(
215 "No undoable snapshot is tagged for the current session — nothing to revert.",
216 ),
217 DebugUndoOutcome::NoOwnedSteps => CommandResult::message(
218 "No undoable snapshots for the current session — nothing to revert.",
219 ),
220 DebugUndoOutcome::NoDifference => CommandResult::message(
221 "No undoable snapshot differs from the current workspace — nothing to revert.",
222 ),
223 DebugUndoOutcome::Untrusted => CommandResult::message(
224 "Refusing to undo workspace files outside trusted mode.\nRun `/trust on` or select Full Access with Shift+Tab, then re-run `/undo`.",
225 ),
226 DebugUndoOutcome::CompareFailed(error) => {
227 CommandResult::error(format!("Failed to compare snapshot: {error}"))
228 }
229 DebugUndoOutcome::SnapshotFailed(error) => CommandResult::error(format!(
230 "Failed to snapshot the workspace before undo: {error}"
231 )),
232 DebugUndoOutcome::ListFailed(error) => {
233 CommandResult::error(format!("Failed to list snapshots: {error}"))
234 }
235 DebugUndoOutcome::RestoreBlocked(error) => CommandResult::message(error),
236 DebugUndoOutcome::RestoreFailed(error) => {
237 CommandResult::error(format!("Restore failed: {error}"))
238 }
239 DebugUndoOutcome::ChangedSince { label, paths } => CommandResult::message(format!(
240 "Refusing to undo snapshot '{}': {} changed after it, and undoing would overwrite that change. Nothing was changed; revert those files yourself, or use /restore for a whole-workspace rollback.",
241 label,
242 paths.join(", ")
243 )),
244 DebugUndoOutcome::Restored(restored) => {
245 let short = &restored.snapshot_id[..restored.snapshot_id.len().min(8)];
246 let lines: Vec<String> = restored
247 .files
248 .iter()
249 .map(|file| {
250 let action = match file.action {
251 DebugRestoreAction::Modified => "modified",
252 DebugRestoreAction::Recreated => "recreated",
253 DebugRestoreAction::Removed => "removed",
254 };
255 format!("{action} {}", file.path.display())
256 })
257 .collect();
258 let mut summary = format!(
259 "Restored {} file(s) to snapshot '{}' ({}):\n{}",
260 restored.files.len(),
261 restored.label,
262 short,
263 lines.join("\n")
264 );
265 if !restored.skipped.is_empty() {
266 let skipped: Vec<String> = restored
267 .skipped
268 .iter()
269 .map(|path| path.display().to_string())
270 .collect();
271 summary.push_str(&format!("\nLeft in place (not a regular file, which /undo does not restore; use /restore for a whole-workspace rollback): {}", skipped.join(", ")));
272 }
273 CommandResult::with_message_and_action(summary, DebugAction::SyncSession(restored.sync))
274 }
275 }
276 }
277
277 lines RUST