返回 DeepSeek-TUI-2026
debug.rs
根目录 / crates / tui / src / commands / debug.rs
1 #![allow(clippy::items_after_test_module)]
2
3 //! Debug commands: tokens, cost, system, context, undo, retry
4
5 use std::time::Instant;
6
7 use super::CommandResult;
8 use crate::compaction::estimate_input_tokens_conservative;
9 use crate::localization::{Locale, MessageId, tr};
10 use crate::models::{SystemPrompt, context_window_for_model};
11 use crate::tui::app::{App, AppAction, TurnCacheRecord};
12 use crate::tui::history::HistoryCell;
13
14 fn token_count(value: Option<u32>, locale: Locale) -> String {
15 value.map_or_else(
16 || tr(locale, MessageId::CmdTokensNotReported).to_string(),
17 |tokens| tokens.to_string(),
18 )
19 }
20
21 fn active_context_summary(app: &App, locale: Locale) -> String {
22 let estimated =
23 estimate_input_tokens_conservative(&app.api_messages, app.system_prompt.as_ref());
24 match context_window_for_model(&app.model) {
25 Some(window) => {
26 let used = estimated.min(window as usize);
27 let percent = (used as f64 / f64::from(window) * 100.0).clamp(0.0, 100.0);
28 tr(locale, MessageId::CmdTokensContextWithWindow)
29 .replace("{used}", &used.to_string())
30 .replace("{window}", &window.to_string())
31 .replace("{percent}", &format!("{percent:.1}"))
32 }
33 None => tr(locale, MessageId::CmdTokensContextUnknownWindow)
34 .replace("{estimated}", &estimated.to_string()),
35 }
36 }
37
38 fn cache_summary(app: &App, locale: Locale) -> String {
39 match (
40 app.session.last_prompt_cache_hit_tokens,
41 app.session.last_prompt_cache_miss_tokens,
42 ) {
43 (Some(hit), Some(miss)) => tr(locale, MessageId::CmdTokensCacheBoth)
44 .replace("{hit}", &hit.to_string())
45 .replace("{miss}", &miss.to_string()),
46 (Some(hit), None) => {
47 tr(locale, MessageId::CmdTokensCacheHitOnly).replace("{hit}", &hit.to_string())
48 }
49 (None, Some(miss)) => {
50 tr(locale, MessageId::CmdTokensCacheMissOnly).replace("{miss}", &miss.to_string())
51 }
52 (None, None) => tr(locale, MessageId::CmdTokensNotReported).to_string(),
53 }
54 }
55
56 /// Show token usage for session
57 pub fn tokens(app: &mut App) -> CommandResult {
58 let locale = app.ui_locale;
59 let message_count = app.api_messages.len();
60 let chat_count = app.history.len();
61
62 let report = tr(locale, MessageId::CmdTokensReport)
63 .replace("{active}", &active_context_summary(app, locale))
64 .replace(
65 "{input}",
66 &token_count(app.session.last_prompt_tokens, locale),
67 )
68 .replace(
69 "{output}",
70 &token_count(app.session.last_completion_tokens, locale),
71 )
72 .replace("{cache}", &cache_summary(app, locale))
73 .replace("{total}", &app.session.total_tokens.to_string())
74 .replace(
75 "{cost}",
76 &app.format_cost_amount_precise(app.session_cost_for_currency(app.cost_currency)),
77 )
78 .replace("{api_messages}", &message_count.to_string())
79 .replace("{chat_messages}", &chat_count.to_string())
80 .replace("{model}", &app.model);
81 CommandResult::message(report)
82 }
83
84 /// Show session cost breakdown
85 pub fn cost(app: &mut App) -> CommandResult {
86 let report = tr(app.ui_locale, MessageId::CmdCostReport).replace(
87 "{cost}",
88 &app.format_cost_amount_precise(app.session_cost_for_currency(app.cost_currency)),
89 );
90 CommandResult::message(report)
91 }
92
93 /// Show current system prompt
94 pub fn system_prompt(app: &mut App) -> CommandResult {
95 let prompt_text = match &app.system_prompt {
96 Some(SystemPrompt::Text(text)) => text.clone(),
97 Some(SystemPrompt::Blocks(blocks)) => blocks
98 .iter()
99 .map(|b| b.text.clone())
100 .collect::<Vec<_>>()
101 .join("\n\n---\n\n"),
102 None => "(no system prompt)".to_string(),
103 };
104
105 // Truncate if too long
106 let display = if prompt_text.len() > 500 {
107 // Find a valid UTF-8 char boundary at or before byte 500
108 let truncate_at = prompt_text
109 .char_indices()
110 .take_while(|(i, _)| *i <= 500)
111 .last()
112 .map_or(0, |(i, _)| i);
113 format!(
114 "{}...\n\n(truncated, {} chars total)",
115 &prompt_text[..truncate_at],
116 prompt_text.len()
117 )
118 } else {
119 prompt_text
120 };
121
122 CommandResult::message(format!(
123 "System Prompt ({} mode):\n─────────────────────────────\n{}",
124 app.mode.label(),
125 display
126 ))
127 }
128
129 /// Show context window usage
130 pub fn context(_app: &mut App) -> CommandResult {
131 CommandResult::action(AppAction::OpenContextInspector)
132 }
133
134 /// Show per-turn DeepSeek prefix-cache telemetry for the last N turns (#263).
135 ///
136 /// `arg` is parsed as a count override (default 10, capped at the ring size).
137 /// Renders a fixed-width table the user can paste into a bug report.
138 pub fn cache(app: &mut App, arg: Option<&str>) -> CommandResult {
139 let want = arg
140 .and_then(|s| s.trim().parse::<usize>().ok())
141 .unwrap_or(10);
142 let cap = app.session.turn_cache_history.len();
143 let count = want
144 .min(cap)
145 .min(crate::tui::app::App::TURN_CACHE_HISTORY_CAP);
146
147 if cap == 0 {
148 return CommandResult::message(tr(app.ui_locale, MessageId::CmdCacheNoData));
149 }
150
151 CommandResult::message(format_cache_history(app, count, app.ui_locale))
152 }
153
154 fn format_cache_history(app: &App, count: usize, locale: Locale) -> String {
155 let total = app.session.turn_cache_history.len();
156 let start = total.saturating_sub(count);
157 let rows: Vec<&TurnCacheRecord> = app.session.turn_cache_history.iter().skip(start).collect();
158
159 let mut totals_input: u64 = 0;
160 let mut totals_hit: u64 = 0;
161 let mut totals_miss: u64 = 0;
162 let mut header = tr(locale, MessageId::CmdCacheHeader)
163 .replace("{count}", &rows.len().to_string())
164 .replace("{total}", &total.to_string())
165 .replace("{model}", &app.model);
166 header.push_str(&"─".repeat(76));
167 header.push('\n');
168 header.push_str("turn in out hit miss replay ratio age\n");
169 header.push_str(&"─".repeat(76));
170 header.push('\n');
171
172 let now = Instant::now();
173 let mut body = String::new();
174 let absolute_start = total.saturating_sub(rows.len());
175 for (i, rec) in rows.iter().enumerate() {
176 let turn_index = absolute_start + i + 1;
177 totals_input += u64::from(rec.input_tokens);
178
179 let replay_cell = rec
180 .reasoning_replay_tokens
181 .map_or_else(|| "—".to_string(), |t| t.to_string());
182 let age = humanize_age(now.saturating_duration_since(rec.recorded_at));
183
184 // No cache telemetry → render `—` everywhere and don't pollute totals
185 // with inferred zeros. Some providers (and some routes inside DeepSeek)
186 // skip the cache fields; including a synthesized 0/N for those turns
187 // would make every aggregate ratio look broken.
188 let Some(hit) = rec.cache_hit_tokens else {
189 body.push_str(&format!(
190 "{turn:>4} {input:>5} {output:>5} {hit:>5} {miss:>5} {replay:>6} {ratio:>6} {age}\n",
191 turn = turn_index,
192 input = rec.input_tokens,
193 output = rec.output_tokens,
194 hit = "—",
195 miss = "—",
196 replay = replay_cell,
197 ratio = "—",
198 age = age,
199 ));
200 continue;
201 };
202
203 let miss_reported = rec.cache_miss_tokens;
204 let miss = miss_reported.unwrap_or_else(|| rec.input_tokens.saturating_sub(hit));
205 let accounted = u64::from(hit) + u64::from(miss);
206 let ratio = if accounted == 0 {
207 " —".to_string()
208 } else {
209 format!("{:>5.1}%", 100.0 * f64::from(hit) / accounted as f64)
210 };
211 totals_hit += u64::from(hit);
212 totals_miss += u64::from(miss);
213
214 let miss_cell = match miss_reported {
215 Some(_) => format!("{miss}"),
216 None => format!("{miss}*"),
217 };
218
219 body.push_str(&format!(
220 "{turn:>4} {input:>5} {output:>5} {hit:>5} {miss:>5} {replay:>6} {ratio} {age}\n",
221 turn = turn_index,
222 input = rec.input_tokens,
223 output = rec.output_tokens,
224 hit = hit,
225 miss = miss_cell,
226 replay = replay_cell,
227 ratio = ratio,
228 age = age,
229 ));
230 }
231
232 let totals_accounted = totals_hit + totals_miss;
233 let avg_ratio = if totals_accounted == 0 {
234 "—".to_string()
235 } else {
236 format!(
237 "{:.1}%",
238 100.0 * totals_hit as f64 / totals_accounted as f64
239 )
240 };
241
242 let mut footer = String::new();
243 footer.push_str(&"─".repeat(76));
244 footer.push('\n');
245 footer.push_str(
246 &tr(locale, MessageId::CmdCacheTotals)
247 .replace("{sum_in}", &totals_input.to_string())
248 .replace("{sum_hit}", &totals_hit.to_string())
249 .replace("{sum_miss}", &totals_miss.to_string())
250 .replace("{avg}", &avg_ratio),
251 );
252 footer.push_str(tr(locale, MessageId::CmdCacheFootnote));
253 footer.push_str(tr(locale, MessageId::CmdCacheAdvice));
254
255 format!("{header}{body}{footer}")
256 }
257
258 fn humanize_age(d: std::time::Duration) -> String {
259 let secs = d.as_secs();
260 if secs < 60 {
261 format!("{secs}s")
262 } else if secs < 3600 {
263 format!("{}m{:02}s", secs / 60, secs % 60)
264 } else {
265 format!("{}h{:02}m", secs / 3600, (secs % 3600) / 60)
266 }
267 }
268
269 #[cfg(test)]
270 mod tests {
271 use super::*;
272 use crate::config::Config;
273 use crate::models::{ContentBlock, Message, SystemBlock};
274 use crate::tui::app::{App, TuiOptions};
275 use std::path::PathBuf;
276
277 fn create_test_app() -> App {
278 let options = TuiOptions {
279 model: "deepseek-v4-pro".to_string(),
280 workspace: PathBuf::from("/tmp/test-workspace"),
281 config_path: None,
282 config_profile: None,
283 allow_shell: false,
284 use_alt_screen: true,
285 use_mouse_capture: false,
286 use_bracketed_paste: true,
287 max_subagents: 1,
288 skills_dir: PathBuf::from("/tmp/test-skills"),
289 memory_path: PathBuf::from("memory.md"),
290 notes_path: PathBuf::from("notes.txt"),
291 mcp_config_path: PathBuf::from("mcp.json"),
292 use_memory: false,
293 start_in_agent_mode: false,
294 skip_onboarding: true,
295 yolo: false,
296 resume_session_id: None,
297 initial_input: None,
298 };
299 let mut app = App::new(options, &Config::default());
300 app.ui_locale = crate::localization::Locale::En;
301 app.api_provider = crate::config::ApiProvider::Deepseek;
302 app
303 }
304
305 #[test]
306 fn test_tokens_shows_usage_info() {
307 let mut app = create_test_app();
308 app.session.total_tokens = 1234;
309 app.session.session_cost = 0.05;
310 app.session.last_prompt_tokens = Some(100);
311 app.session.last_completion_tokens = Some(25);
312 app.session.last_prompt_cache_hit_tokens = Some(70);
313 app.session.last_prompt_cache_miss_tokens = Some(30);
314 app.api_messages.push(Message {
315 role: "user".to_string(),
316 content: vec![ContentBlock::Text {
317 text: "test".to_string(),
318 cache_control: None,
319 }],
320 });
321 app.history.push(HistoryCell::User {
322 content: "test".to_string(),
323 });
324
325 let result = tokens(&mut app);
326 assert!(result.message.is_some());
327 let msg = result.message.unwrap();
328 assert!(msg.contains("Token Usage"));
329 assert!(msg.contains("Active context:"));
330 assert!(msg.contains("Last API input:"));
331 assert!(msg.contains("Last API output:"));
332 assert!(msg.contains("Cache hit/miss:"));
333 assert!(msg.contains("70 hit / 30 miss"));
334 assert!(msg.contains("Cumulative tokens:"));
335 assert!(msg.contains("Approx session cost:"));
336 assert!(msg.contains("API messages:"));
337 assert!(msg.contains("Chat messages:"));
338 assert!(msg.contains("Model:"));
339 }
340
341 #[test]
342 fn test_cost_shows_spending_info() {
343 let mut app = create_test_app();
344 app.session.session_cost = 0.1234;
345 let result = cost(&mut app);
346 assert!(result.message.is_some());
347 let msg = result.message.unwrap();
348 assert!(msg.contains("Session Cost"));
349 assert!(msg.contains("Approx total spent:"));
350 assert!(msg.contains("approximate"));
351 assert!(msg.contains("$0.1234"));
352 }
353
354 #[test]
355 fn test_system_prompt_displays_text() {
356 let mut app = create_test_app();
357 app.system_prompt = Some(SystemPrompt::Text("Test system prompt".to_string()));
358 let result = system_prompt(&mut app);
359 assert!(result.message.is_some());
360 let msg = result.message.unwrap();
361 assert!(msg.contains("System Prompt"));
362 assert!(msg.contains("Test system prompt"));
363 }
364
365 #[test]
366 fn test_system_prompt_displays_blocks() {
367 let mut app = create_test_app();
368 app.system_prompt = Some(SystemPrompt::Blocks(vec![
369 SystemBlock {
370 block_type: "text".to_string(),
371 text: "Block 1".to_string(),
372 cache_control: None,
373 },
374 SystemBlock {
375 block_type: "text".to_string(),
376 text: "Block 2".to_string(),
377 cache_control: None,
378 },
379 ]));
380 let result = system_prompt(&mut app);
381 assert!(result.message.is_some());
382 let msg = result.message.unwrap();
383 assert!(msg.contains("System Prompt"));
384 assert!(msg.contains("Block 1"));
385 assert!(msg.contains("Block 2"));
386 }
387
388 #[test]
389 fn test_system_prompt_none() {
390 let mut app = create_test_app();
391 app.system_prompt = None;
392 let result = system_prompt(&mut app);
393 assert!(result.message.is_some());
394 let msg = result.message.unwrap();
395 assert!(msg.contains("(no system prompt)"));
396 }
397
398 #[test]
399 fn test_system_prompt_truncates_long_text() {
400 let mut app = create_test_app();
401 let long_text = "x".repeat(600);
402 app.system_prompt = Some(SystemPrompt::Text(long_text));
403 let result = system_prompt(&mut app);
404 assert!(result.message.is_some());
405 let msg = result.message.unwrap();
406 assert!(msg.contains("..."));
407 assert!(msg.contains("chars total"));
408 }
409
410 #[test]
411 fn cache_command_reports_no_data_before_first_turn() {
412 let mut app = create_test_app();
413 let result = cache(&mut app, None);
414 let msg = result.message.expect("cache produces a message");
415 assert!(msg.contains("no turns recorded yet"), "got: {msg}");
416 }
417
418 #[test]
419 fn cache_command_renders_recorded_turns_with_ratio() {
420 let mut app = create_test_app();
421 let now = Instant::now();
422 // Three turns: 75% hit, 50% hit, miss-only (provider didn't report hit).
423 app.push_turn_cache_record(TurnCacheRecord {
424 input_tokens: 4_000,
425 output_tokens: 200,
426 cache_hit_tokens: Some(3_000),
427 cache_miss_tokens: Some(1_000),
428 reasoning_replay_tokens: None,
429 recorded_at: now,
430 });
431 app.push_turn_cache_record(TurnCacheRecord {
432 input_tokens: 6_000,
433 output_tokens: 250,
434 cache_hit_tokens: Some(3_000),
435 cache_miss_tokens: Some(3_000),
436 reasoning_replay_tokens: Some(150),
437 recorded_at: now,
438 });
439 // Turn 3: hit reported but provider didn't report miss separately —
440 // infer miss = input − hit and mark with `*`.
441 app.push_turn_cache_record(TurnCacheRecord {
442 input_tokens: 5_000,
443 output_tokens: 100,
444 cache_hit_tokens: Some(2_500),
445 cache_miss_tokens: None,
446 reasoning_replay_tokens: None,
447 recorded_at: now,
448 });
449 // Turn 4: no telemetry at all — must not pollute aggregate ratios.
450 app.push_turn_cache_record(TurnCacheRecord {
451 input_tokens: 1_000,
452 output_tokens: 50,
453 cache_hit_tokens: None,
454 cache_miss_tokens: None,
455 reasoning_replay_tokens: None,
456 recorded_at: now,
457 });
458
459 let result = cache(&mut app, None);
460 let msg = result.message.expect("cache produces a message");
461
462 // Header reflects total rows and model.
463 assert!(msg.contains("last 4 of 4 turn(s)"), "got: {msg}");
464 // Per-turn ratios are rendered.
465 assert!(msg.contains("75.0%"), "got: {msg}");
466 assert!(msg.contains("50.0%"), "got: {msg}");
467 // Turn 3: hit=2500, inferred miss=2500 → 50.0% with `*`-marked miss.
468 assert!(msg.contains("2500*"), "got: {msg}");
469 // Turn 4 (no telemetry) shows em-dashes and is excluded from totals.
470 // Aggregate over turns 1-3: hit=8500, miss=6500 → 56.7%.
471 assert!(msg.contains("avg hit ratio: 56.7%"), "got: {msg}");
472 // Footer guidance is present.
473 assert!(msg.contains("70%"), "got: {msg}");
474 }
475
476 #[test]
477 fn cache_command_count_argument_clamps_to_history() {
478 let mut app = create_test_app();
479 for _ in 0..3 {
480 app.push_turn_cache_record(TurnCacheRecord {
481 input_tokens: 1_000,
482 output_tokens: 100,
483 cache_hit_tokens: Some(500),
484 cache_miss_tokens: Some(500),
485 reasoning_replay_tokens: None,
486 recorded_at: Instant::now(),
487 });
488 }
489 let result = cache(&mut app, Some("100"));
490 let msg = result.message.expect("cache produces a message");
491 // Asked for 100 turns, only 3 exist — should report "last 3 of 3".
492 assert!(msg.contains("last 3 of 3 turn(s)"), "got: {msg}");
493 }
494
495 #[test]
496 fn turn_cache_history_is_capped_at_50() {
497 let mut app = create_test_app();
498 for i in 0..(crate::tui::app::App::TURN_CACHE_HISTORY_CAP + 12) {
499 app.push_turn_cache_record(TurnCacheRecord {
500 input_tokens: i as u32,
501 output_tokens: 1,
502 cache_hit_tokens: Some(i as u32),
503 cache_miss_tokens: Some(0),
504 reasoning_replay_tokens: None,
505 recorded_at: Instant::now(),
506 });
507 }
508 assert_eq!(
509 app.session.turn_cache_history.len(),
510 crate::tui::app::App::TURN_CACHE_HISTORY_CAP
511 );
512 // Oldest record was evicted; newest record is still at the back.
513 assert_eq!(
514 app.session.turn_cache_history.back().unwrap().input_tokens,
515 (crate::tui::app::App::TURN_CACHE_HISTORY_CAP + 11) as u32
516 );
517 }
518
519 #[test]
520 fn test_context_shows_usage_stats() {
521 let mut app = create_test_app();
522 app.api_messages.push(Message {
523 role: "user".to_string(),
524 content: vec![ContentBlock::Text {
525 text: "Hello".to_string(),
526 cache_control: None,
527 }],
528 });
529 app.history.push(HistoryCell::User {
530 content: "Hello".to_string(),
531 });
532
533 let result = context(&mut app);
534 assert!(matches!(
535 result.action,
536 Some(AppAction::OpenContextInspector)
537 ));
538 assert!(result.message.is_none());
539 }
540
541 #[test]
542 fn test_undo_conversation_removes_last_exchange() {
543 let mut app = create_test_app();
544 app.history.push(HistoryCell::User {
545 content: "Hello".to_string(),
546 });
547 app.history.push(HistoryCell::Assistant {
548 content: "Hi".to_string(),
549 streaming: false,
550 });
551 app.api_messages.push(Message {
552 role: "user".to_string(),
553 content: vec![],
554 });
555 app.api_messages.push(Message {
556 role: "assistant".to_string(),
557 content: vec![],
558 });
559
560 let initial_history_len = app.history.len();
561 let initial_api_len = app.api_messages.len();
562 let result = undo_conversation(&mut app);
563
564 assert!(result.message.is_some());
565 let msg = result.message.unwrap();
566 assert!(msg.contains("Removed"));
567 assert!(app.history.len() < initial_history_len);
568 assert!(app.api_messages.len() < initial_api_len);
569 }
570
571 #[test]
572 fn test_undo_conversation_nothing_to_undo() {
573 let mut app = create_test_app();
574 // Clear any default history
575 app.history.clear();
576 app.api_messages.clear();
577 let result = undo_conversation(&mut app);
578 assert!(result.message.is_some());
579 let msg = result.message.unwrap();
580 assert!(msg.contains("Nothing to undo") || msg.contains("Removed"));
581 }
582
583 #[test]
584 fn test_retry_with_previous_message() {
585 let mut app = create_test_app();
586 app.history.push(HistoryCell::User {
587 content: "Test message".to_string(),
588 });
589 app.history.push(HistoryCell::Assistant {
590 content: "Response".to_string(),
591 streaming: false,
592 });
593
594 let result = retry(&mut app);
595 assert!(result.message.is_some());
596 let msg = result.message.unwrap();
597 assert!(msg.contains("Retrying"));
598 assert!(msg.contains("Test message"));
599 assert!(matches!(result.action, Some(AppAction::SendMessage(_))));
600 }
601
602 #[test]
603 fn test_retry_no_previous_message() {
604 let mut app = create_test_app();
605 let result = retry(&mut app);
606 assert!(result.message.is_some());
607 let msg = result.message.unwrap();
608 assert!(msg.contains("No previous request to retry"));
609 assert!(result.action.is_none());
610 }
611
612 #[test]
613 fn test_retry_truncates_long_input() {
614 let mut app = create_test_app();
615 let long_input = "x".repeat(100);
616 app.history.push(HistoryCell::User {
617 content: long_input.clone(),
618 });
619 app.history.push(HistoryCell::Assistant {
620 content: "Response".to_string(),
621 streaming: false,
622 });
623
624 let result = retry(&mut app);
625 assert!(result.message.is_some());
626 let msg = result.message.unwrap();
627 assert!(msg.contains("Retrying"));
628 assert!(msg.contains("..."));
629 }
630 }
631
632 /// Remove last message pair (user + assistant).
633 ///
634 /// This is the old `/undo` behaviour — it removes the most recent
635 /// user+assistant conversation pair from history and API messages.
636 /// The new `/undo` first tries to revert workspace files via
637 /// [`patch_undo`]; if no snapshots are available it falls back to
638 /// this function.
639 pub fn undo_conversation(app: &mut App) -> CommandResult {
640 // Remove from display history (up to the last user message)
641 let mut removed_count = 0;
642 while !app.history.is_empty() {
643 let last_is_user = matches!(app.history.last(), Some(HistoryCell::User { .. }));
644 app.pop_history();
645 removed_count += 1;
646 if last_is_user {
647 break;
648 }
649 }
650
651 // Remove from API messages
652 while let Some(last) = app.api_messages.last() {
653 if last.role == "user" {
654 app.api_messages.pop();
655 break;
656 }
657 app.api_messages.pop();
658 }
659
660 if removed_count > 0 {
661 // Keep tool/index mappings consistent after truncation.
662 app.tool_cells.clear();
663 app.tool_details_by_cell.clear();
664 app.exploring_entries.clear();
665 app.ignored_tool_calls.clear();
666 app.mark_history_updated();
667 CommandResult::message(format!("Removed {removed_count} message(s)"))
668 } else {
669 CommandResult::message("Nothing to undo")
670 }
671 }
672
673 /// Revert the most recent write tool (apply_patch/edit_file/write_file) or turn.
674 ///
675 /// Opens the side-git snapshot repo and finds the most recent snapshot,
676 /// preferring per-tool snapshots (`tool:*`) over pre-turn snapshots
677 /// (`pre-turn:*`). Restores files from that snapshot and shows a diff
678 /// summary. Falls back to conversation undo when no snapshots exist.
679 ///
680 /// Posts a `HistoryCell::System` entry so the user can see what was
681 /// reverted in the transcript.
682 pub fn patch_undo(app: &mut App) -> CommandResult {
683 let workspace = app.workspace.clone();
684
685 let repo = match crate::snapshot::SnapshotRepo::open_or_init(&workspace) {
686 Ok(r) => r,
687 Err(e) => {
688 return CommandResult::error(format!(
689 "Snapshot repo unavailable for {}: {e}",
690 workspace.display(),
691 ));
692 }
693 };
694
695 let snapshots = match repo.list(20) {
696 Ok(s) => s,
697 Err(e) => {
698 return CommandResult::error(format!("Failed to list snapshots: {e}"));
699 }
700 };
701
702 if snapshots.is_empty() {
703 return CommandResult::message("No snapshots found to undo — nothing to revert.");
704 }
705
706 // Prefer the most recent `tool:` snapshot; fall back to `pre-turn:`.
707 let target = snapshots
708 .iter()
709 .find(|s| s.label.starts_with("tool:"))
710 .or_else(|| snapshots.iter().find(|s| s.label.starts_with("pre-turn:")));
711
712 let Some(target) = target else {
713 return CommandResult::message("No tool or pre-turn snapshots found — nothing to revert.");
714 };
715
716 if let Err(e) = repo.restore(&target.id) {
717 return CommandResult::error(format!("Restore failed: {e}"));
718 }
719
720 // Show diff stat so the user knows what changed.
721 let diff_stat = std::process::Command::new("git")
722 .args(["diff", "--stat"])
723 .current_dir(&workspace)
724 .output()
725 .ok()
726 .and_then(|o| {
727 let s = String::from_utf8_lossy(&o.stdout).trim().to_string();
728 if s.is_empty() { None } else { Some(s) }
729 });
730
731 let short = &target.id.as_str()[..target.id.as_str().len().min(8)];
732 let summary = match diff_stat {
733 Some(ref stat) => {
734 format!(
735 "Restored snapshot '{}' ({}). Files affected:\n{stat}",
736 target.label, short
737 )
738 }
739 None => {
740 format!(
741 "Restored snapshot '{}' ({}). No diff changes detected.",
742 target.label, short
743 )
744 }
745 };
746
747 // Post a system cell so the reverted state is visible in the transcript.
748 app.push_history_cell(HistoryCell::System {
749 content: format!(
750 "/undo reverted workspace to snapshot '{}' ({})",
751 target.label, short
752 ),
753 });
754
755 CommandResult::message(summary)
756 }
757
758 /// Load the last user message back into the composer for editing.
759 ///
760 /// Searches `app.history` for the most recent `HistoryCell::User`, copies its
761 /// content into `app.input`, and positions the cursor at the end so the user
762 /// can edit and press Enter to resubmit. The original exchange stays visible
763 /// in the transcript.
764 pub fn edit(app: &mut App) -> CommandResult {
765 let last_user = app.history.iter().rev().find_map(|cell| match cell {
766 HistoryCell::User { content } => Some(content.clone()),
767 _ => None,
768 });
769
770 match last_user {
771 Some(content) => {
772 app.input = content;
773 app.cursor_position = app.input.chars().count();
774 app.edit_in_progress = true;
775 CommandResult::message(
776 "Last message loaded into composer — edit and press Enter to resubmit",
777 )
778 }
779 None => CommandResult::message("No previous message to edit"),
780 }
781 }
782
783 /// Show git diff output since session start.
784 ///
785 /// Runs `git diff --stat` and `git diff --name-only` in the workspace
786 /// directory. Displays which files have changed and a stat summary. If no
787 /// changes exist or git fails, returns an appropriate message.
788 pub fn diff(app: &mut App) -> CommandResult {
789 let workspace = app.workspace.clone();
790
791 let name_only_output = std::process::Command::new("git")
792 .args(["diff", "--name-only"])
793 .current_dir(&workspace)
794 .output();
795 let stat_output = std::process::Command::new("git")
796 .args(["diff", "--stat"])
797 .current_dir(&workspace)
798 .output();
799
800 match (name_only_output, stat_output) {
801 (Ok(name_only), Ok(stat)) => {
802 let name_stdout = String::from_utf8_lossy(&name_only.stdout);
803 let stat_stdout = String::from_utf8_lossy(&stat.stdout);
804
805 if name_stdout.trim().is_empty() {
806 return CommandResult::message("No changes since session start");
807 }
808
809 let files: Vec<&str> = name_stdout.lines().filter(|l| !l.is_empty()).collect();
810 let file_count = files.len();
811 let file_list = files.join("\n");
812
813 // Detect rename entries (e.g. "foo -> bar") and exclude them
814 // from the file-count header so the user sees only actual
815 // modifications.
816 let renamed_count = files.iter().filter(|f| f.contains(" -> ")).count();
817 let summary = if renamed_count > 0 {
818 format!("Changed files ({file_count}, {renamed_count} renamed):\n{file_list}")
819 } else {
820 format!("Changed files ({file_count}):\n{file_list}")
821 };
822
823 let stat_str = stat_stdout.trim();
824 let mut message = summary;
825 if !stat_str.is_empty() {
826 message.push_str("\n\n── Stat ──\n");
827 message.push_str(stat_str);
828 }
829 CommandResult::message(message)
830 }
831 (Err(e), _) | (_, Err(e)) => {
832 CommandResult::message(format!("Git diff failed — is this a git repository?\n{e}"))
833 }
834 }
835 }
836
837 /// Retry last request - remove last exchange and re-send the user's message
838 pub fn retry(app: &mut App) -> CommandResult {
839 let last_user_input = app.history.iter().rev().find_map(|cell| match cell {
840 HistoryCell::User { content } => Some(content.clone()),
841 _ => None,
842 });
843
844 match last_user_input {
845 Some(input) => {
846 undo_conversation(app);
847 let display_input = if input.len() > 50 {
848 let truncate_at = input
849 .char_indices()
850 .take_while(|(i, _)| *i <= 50)
851 .last()
852 .map_or(0, |(i, _)| i);
853 format!("{}...", &input[..truncate_at])
854 } else {
855 input.clone()
856 };
857 CommandResult::with_message_and_action(
858 format!("Retrying: {display_input}"),
859 AppAction::SendMessage(input),
860 )
861 }
862 None => CommandResult::error("No previous request to retry"),
863 }
864 }
865
865 lines RUST