返回 DeepSeek-TUI-2026
session.rs
根目录 / crates / tui / src / commands / session.rs
1 //! Session commands: save, load, compact, export
2
3 use std::fmt::Write;
4 use std::path::PathBuf;
5
6 use crate::session_manager::create_saved_session_with_mode;
7 use crate::tui::app::{App, AppAction};
8 use crate::tui::history::{HistoryCell, history_cells_from_message};
9 use crate::tui::session_picker::SessionPickerView;
10
11 use super::CommandResult;
12
13 /// Save session to file
14 pub fn save(app: &mut App, path: Option<&str>) -> CommandResult {
15 let save_path = if let Some(p) = path {
16 PathBuf::from(p)
17 } else {
18 let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
19 PathBuf::from(format!("session_{timestamp}.json"))
20 };
21
22 let messages = app.api_messages.clone();
23 let session = create_saved_session_with_mode(
24 &messages,
25 &app.model,
26 &app.workspace,
27 u64::from(app.session.total_tokens),
28 app.system_prompt.as_ref(),
29 Some(app.mode.label()),
30 );
31
32 let sessions_dir = save_path
33 .parent()
34 .filter(|p| !p.as_os_str().is_empty())
35 .map_or_else(|| app.workspace.clone(), std::path::Path::to_path_buf);
36
37 match std::fs::create_dir_all(&sessions_dir) {
38 Ok(()) => {
39 let json = match serde_json::to_string_pretty(&session) {
40 Ok(j) => j,
41 Err(e) => return CommandResult::error(format!("Failed to serialize session: {e}")),
42 };
43 match std::fs::write(&save_path, json) {
44 Ok(()) => {
45 app.current_session_id = Some(session.metadata.id.clone());
46 CommandResult::message(format!(
47 "Session saved to {} (ID: {})",
48 save_path.display(),
49 crate::session_manager::truncate_id(&session.metadata.id)
50 ))
51 }
52 Err(e) => CommandResult::error(format!("Failed to save session: {e}")),
53 }
54 }
55 Err(e) => CommandResult::error(format!("Failed to create directory: {e}")),
56 }
57 }
58
59 /// Load session from file
60 pub fn load(app: &mut App, path: Option<&str>) -> CommandResult {
61 let load_path = if let Some(p) = path {
62 if p.contains('/') || p.contains('\\') {
63 PathBuf::from(p)
64 } else {
65 app.workspace.join(p)
66 }
67 } else {
68 return CommandResult::error("Usage: /load <path>");
69 };
70
71 let content = match std::fs::read_to_string(&load_path) {
72 Ok(c) => c,
73 Err(e) => {
74 return CommandResult::error(format!("Failed to read session file: {e}"));
75 }
76 };
77
78 let session: crate::session_manager::SavedSession = match serde_json::from_str(&content) {
79 Ok(s) => s,
80 Err(e) => {
81 return CommandResult::error(format!("Failed to parse session file: {e}"));
82 }
83 };
84
85 app.api_messages.clone_from(&session.messages);
86 app.clear_history();
87 let cells_to_add: Vec<_> = app
88 .api_messages
89 .iter()
90 .flat_map(history_cells_from_message)
91 .collect();
92 app.extend_history(cells_to_add);
93 app.mark_history_updated();
94 app.viewport.transcript_selection.clear();
95 app.model.clone_from(&session.metadata.model);
96 app.update_model_compaction_budget();
97 app.workspace.clone_from(&session.metadata.workspace);
98 app.session.total_tokens = u32::try_from(session.metadata.total_tokens).unwrap_or(u32::MAX);
99 app.session.total_conversation_tokens = app.session.total_tokens;
100 app.session.last_prompt_tokens = None;
101 app.session.last_completion_tokens = None;
102 app.current_session_id = Some(session.metadata.id.clone());
103 if let Some(sp) = session.system_prompt {
104 app.system_prompt = Some(crate::models::SystemPrompt::Text(sp));
105 }
106 app.scroll_to_bottom();
107
108 CommandResult::with_message_and_action(
109 format!(
110 "Session loaded from {} (ID: {}, {} messages)",
111 load_path.display(),
112 crate::session_manager::truncate_id(&session.metadata.id),
113 session.metadata.message_count
114 ),
115 crate::tui::app::AppAction::SyncSession {
116 messages: app.api_messages.clone(),
117 system_prompt: app.system_prompt.clone(),
118 model: app.model.clone(),
119 workspace: app.workspace.clone(),
120 },
121 )
122 }
123
124 /// Trigger context compaction
125 pub fn compact(_app: &mut App) -> CommandResult {
126 // Trigger immediate compaction via engine
127 CommandResult::with_message_and_action(
128 "Context compaction triggered...".to_string(),
129 AppAction::CompactContext,
130 )
131 }
132
133 /// Export conversation to markdown
134 pub fn export(app: &mut App, path: Option<&str>) -> CommandResult {
135 let export_path = path.map_or_else(
136 || {
137 let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
138 PathBuf::from(format!("chat_export_{timestamp}.md"))
139 },
140 PathBuf::from,
141 );
142
143 let mut content = String::new();
144 content.push_str("# Chat Export\n\n");
145 let _ = write!(
146 content,
147 "**Model:** {}\n**Workspace:** {}\n**Date:** {}\n\n---\n\n",
148 app.model,
149 app.workspace.display(),
150 chrono::Local::now().format("%Y-%m-%d %H:%M:%S")
151 );
152
153 for cell in &app.history {
154 let (role, body) = match cell {
155 HistoryCell::User { content } => ("**You:**", content.clone()),
156 HistoryCell::Assistant { content, .. } => ("**Assistant:**", content.clone()),
157 HistoryCell::System { content } => ("*System:*", content.clone()),
158 HistoryCell::Error { message, severity } => match severity {
159 crate::error_taxonomy::ErrorSeverity::Warning => ("**Warning:**", message.clone()),
160 crate::error_taxonomy::ErrorSeverity::Info => ("*Info:*", message.clone()),
161 _ => ("**Error:**", message.clone()),
162 },
163 HistoryCell::Thinking { content, .. } => ("*Thinking:*", content.clone()),
164 HistoryCell::Tool(tool) => ("**Tool:**", render_tool_cell(tool, 80)),
165 HistoryCell::SubAgent(sub) => ("**Sub-agent:**", render_subagent_cell(sub, 80)),
166 HistoryCell::ArchivedContext {
167 level,
168 range,
169 summary,
170 ..
171 } => (
172 "**Archived Context:**",
173 format!("L{level} [{range}]: {summary}"),
174 ),
175 };
176
177 let _ = write!(content, "{}\n\n{}\n\n---\n\n", role, body.trim());
178 }
179
180 match std::fs::write(&export_path, content) {
181 Ok(()) => CommandResult::message(format!("Exported to {}", export_path.display())),
182 Err(e) => CommandResult::error(format!("Failed to export: {e}")),
183 }
184 }
185
186 /// Open the session picker UI, or run a sub-action like
187 /// `prune <days>` for housekeeping (#406 phase-1.5).
188 pub fn sessions(app: &mut App, arg: Option<&str>) -> CommandResult {
189 let trimmed = arg.unwrap_or("").trim();
190 if trimmed.is_empty() {
191 app.view_stack.push(SessionPickerView::new());
192 return CommandResult::ok();
193 }
194
195 let mut parts = trimmed.split_whitespace();
196 let action = parts.next().unwrap_or("").to_ascii_lowercase();
197 match action.as_str() {
198 "prune" => prune(app, parts.next()),
199 "show" | "list" | "picker" => {
200 app.view_stack.push(SessionPickerView::new());
201 CommandResult::ok()
202 }
203 _ => CommandResult::error(format!(
204 "unknown subcommand `{action}`. usage: /sessions [show|prune <days>]"
205 )),
206 }
207 }
208
209 /// Prune persisted sessions older than `<days>` from
210 /// `~/.deepseek/sessions/`. Wraps
211 /// [`crate::session_manager::SessionManager::prune_sessions_older_than`]
212 /// so users can run a safe cleanup without leaving the TUI. Skips
213 /// the checkpoint subdirectory (the helper guarantees that already).
214 fn prune(_app: &mut App, days_arg: Option<&str>) -> CommandResult {
215 let days_str = match days_arg {
216 Some(s) => s,
217 None => {
218 return CommandResult::error(
219 "usage: /sessions prune <days> (e.g. `/sessions prune 30` to drop sessions older than 30 days)",
220 );
221 }
222 };
223 let days: u64 = match days_str.parse() {
224 Ok(n) if n > 0 => n,
225 _ => {
226 return CommandResult::error(format!(
227 "expected a positive integer number of days, got `{days_str}`"
228 ));
229 }
230 };
231
232 let manager = match crate::session_manager::SessionManager::default_location() {
233 Ok(m) => m,
234 Err(err) => {
235 return CommandResult::error(format!("could not open sessions directory: {err}"));
236 }
237 };
238
239 let max_age = std::time::Duration::from_secs(days.saturating_mul(24 * 60 * 60));
240 match manager.prune_sessions_older_than(max_age) {
241 Ok(0) => CommandResult::message(format!("no sessions older than {days}d to prune")),
242 Ok(n) => CommandResult::message(format!(
243 "pruned {n} session{} older than {days}d",
244 if n == 1 { "" } else { "s" }
245 )),
246 Err(err) => CommandResult::error(format!("prune failed: {err}")),
247 }
248 }
249
250 fn render_tool_cell(tool: &crate::tui::history::ToolCell, width: u16) -> String {
251 tool.lines(width)
252 .into_iter()
253 .map(line_to_string)
254 .collect::<Vec<_>>()
255 .join("\n")
256 }
257
258 fn render_subagent_cell(cell: &crate::tui::history::SubAgentCell, width: u16) -> String {
259 cell.lines(width)
260 .into_iter()
261 .map(line_to_string)
262 .collect::<Vec<_>>()
263 .join("\n")
264 }
265
266 fn line_to_string(line: ratatui::text::Line<'static>) -> String {
267 line.spans
268 .into_iter()
269 .map(|span| span.content.to_string())
270 .collect::<String>()
271 }
272
273 #[cfg(test)]
274 mod tests {
275 use super::*;
276 use crate::config::Config;
277 use crate::tui::app::{App, TuiOptions};
278 use tempfile::TempDir;
279
280 fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App {
281 let options = TuiOptions {
282 model: "deepseek-v4-pro".to_string(),
283 workspace: tmpdir.path().to_path_buf(),
284 config_path: None,
285 config_profile: None,
286 allow_shell: false,
287 use_alt_screen: true,
288 use_mouse_capture: false,
289 use_bracketed_paste: true,
290 max_subagents: 1,
291 skills_dir: tmpdir.path().join("skills"),
292 memory_path: tmpdir.path().join("memory.md"),
293 notes_path: tmpdir.path().join("notes.txt"),
294 mcp_config_path: tmpdir.path().join("mcp.json"),
295 use_memory: false,
296 start_in_agent_mode: false,
297 skip_onboarding: true,
298 yolo: false,
299 resume_session_id: None,
300 initial_input: None,
301 };
302 App::new(options, &Config::default())
303 }
304
305 #[test]
306 fn test_save_creates_file_and_sets_session_id() {
307 let tmpdir = TempDir::new().unwrap();
308 let mut app = create_test_app_with_tmpdir(&tmpdir);
309 let save_path = tmpdir.path().join("test_session.json");
310
311 let result = save(&mut app, Some(save_path.to_str().unwrap()));
312 assert!(result.message.is_some());
313 let msg = result.message.unwrap();
314 assert!(msg.contains("Session saved to"));
315 assert!(msg.contains("ID:"));
316 assert!(app.current_session_id.is_some());
317 assert!(save_path.exists());
318 }
319
320 #[test]
321 fn test_save_with_default_path_uses_workspace() {
322 let tmpdir = TempDir::new().unwrap();
323 let mut app = create_test_app_with_tmpdir(&tmpdir);
324 let result = save(&mut app, None);
325 assert!(result.message.is_some());
326 let msg = result.message.unwrap();
327 // Should create file in workspace with timestamp name
328 // Give it a moment to ensure file is written
329 std::thread::sleep(std::time::Duration::from_millis(10));
330 let entries: Vec<_> = std::fs::read_dir(tmpdir.path())
331 .unwrap()
332 .filter_map(|e| e.ok())
333 .filter(|e| e.file_name().to_string_lossy().starts_with("session_"))
334 .collect();
335 // Test passes if file was created or if save returned success message
336 assert!(!entries.is_empty() || msg.contains("Session saved"));
337 }
338
339 #[test]
340 fn test_save_serialization_error() {
341 let tmpdir = TempDir::new().unwrap();
342 let mut app = create_test_app_with_tmpdir(&tmpdir);
343 // This should work normally since SavedSession is serializable
344 // Testing error path would require mocking, which is complex
345 let save_path = tmpdir.path().join("test.json");
346 let result = save(&mut app, Some(save_path.to_str().unwrap()));
347 assert!(result.message.is_some());
348 }
349
350 #[test]
351 fn test_load_without_path_returns_error() {
352 let tmpdir = TempDir::new().unwrap();
353 let mut app = create_test_app_with_tmpdir(&tmpdir);
354 let result = load(&mut app, None);
355 assert!(result.message.is_some());
356 assert!(result.message.unwrap().contains("Usage: /load"));
357 }
358
359 #[test]
360 fn test_load_nonexistent_file_returns_error() {
361 let tmpdir = TempDir::new().unwrap();
362 let mut app = create_test_app_with_tmpdir(&tmpdir);
363 let result = load(&mut app, Some("nonexistent.json"));
364 assert!(result.message.is_some());
365 assert!(result.message.unwrap().contains("Failed to read"));
366 }
367
368 #[test]
369 fn test_load_invalid_json_returns_error() {
370 let tmpdir = TempDir::new().unwrap();
371 let mut app = create_test_app_with_tmpdir(&tmpdir);
372 let bad_file = tmpdir.path().join("bad.json");
373 std::fs::write(&bad_file, "not valid json").unwrap();
374 let result = load(&mut app, Some(bad_file.to_str().unwrap()));
375 assert!(result.message.is_some());
376 assert!(result.message.unwrap().contains("Failed to parse"));
377 }
378
379 #[test]
380 fn test_load_valid_session_restores_state() {
381 let tmpdir = TempDir::new().unwrap();
382 let mut app1 = create_test_app_with_tmpdir(&tmpdir);
383 // Set up some state to save
384 app1.api_messages.push(crate::models::Message {
385 role: "user".to_string(),
386 content: vec![crate::models::ContentBlock::Text {
387 text: "Hello".to_string(),
388 cache_control: None,
389 }],
390 });
391 app1.session.total_tokens = 500;
392 let save_path = tmpdir.path().join("test.json");
393 save(&mut app1, Some(save_path.to_str().unwrap()));
394
395 // Create new app and load
396 let mut app2 = create_test_app_with_tmpdir(&tmpdir);
397 let result = load(&mut app2, Some(save_path.to_str().unwrap()));
398 assert!(result.message.is_some());
399 let msg = result.message.unwrap();
400 assert!(msg.contains("Session loaded from"));
401 assert!(msg.contains("ID:"));
402 assert!(msg.contains("messages"));
403 assert_eq!(app2.api_messages.len(), 1);
404 assert_eq!(app2.session.total_tokens, 500);
405 assert!(app2.current_session_id.is_some());
406 assert!(matches!(result.action, Some(AppAction::SyncSession { .. })));
407 }
408
409 #[test]
410 fn test_compact_toggles_state() {
411 let tmpdir = TempDir::new().unwrap();
412 let mut app = create_test_app_with_tmpdir(&tmpdir);
413
414 let result = compact(&mut app);
415 assert!(result.message.is_some());
416 let msg = result.message.unwrap();
417 assert!(msg.contains("compaction") || msg.contains("Compact"));
418 assert!(matches!(result.action, Some(AppAction::CompactContext)));
419 }
420
421 #[test]
422 fn test_export_crees_markdown_file() {
423 let tmpdir = TempDir::new().unwrap();
424 let mut app = create_test_app_with_tmpdir(&tmpdir);
425 app.history.push(HistoryCell::User {
426 content: "Hello".to_string(),
427 });
428 app.history.push(HistoryCell::Assistant {
429 content: "Hi there".to_string(),
430 streaming: false,
431 });
432
433 let export_path = tmpdir.path().join("export.md");
434 let result = export(&mut app, Some(export_path.to_str().unwrap()));
435 assert!(result.message.is_some());
436 let msg = result.message.unwrap();
437 assert!(msg.contains("Exported to"));
438 assert!(export_path.exists());
439
440 let content = std::fs::read_to_string(&export_path).unwrap();
441 assert!(content.contains("# Chat Export"));
442 assert!(content.contains("**Model:**"));
443 assert!(content.contains("**You:**"));
444 assert!(content.contains("**Assistant:**"));
445 }
446
447 #[test]
448 fn test_export_with_default_path() {
449 let tmpdir = TempDir::new().unwrap();
450 let mut app = create_test_app_with_tmpdir(&tmpdir);
451 let result = export(&mut app, None);
452 assert!(result.message.is_some());
453 // Should create file with timestamp name in current dir
454 let entries: Vec<_> = std::fs::read_dir(".")
455 .unwrap()
456 .filter_map(|e| e.ok())
457 .filter(|e| e.file_name().to_string_lossy().starts_with("chat_export_"))
458 .collect();
459 // Clean up
460 for entry in &entries {
461 let _ = std::fs::remove_file(entry.path());
462 }
463 assert!(!entries.is_empty() || result.message.unwrap().contains("Exported to"));
464 }
465
466 #[test]
467 fn test_sessions_pushes_picker_view() {
468 let tmpdir = TempDir::new().unwrap();
469 let mut app = create_test_app_with_tmpdir(&tmpdir);
470 let initial_kind = app.view_stack.top_kind();
471
472 let result = sessions(&mut app, None);
473 assert_eq!(result.message, None);
474 assert!(result.action.is_none());
475 // View should have changed (session picker should be on top)
476 assert_ne!(app.view_stack.top_kind(), initial_kind);
477 }
478
479 #[test]
480 fn test_sessions_show_subcommand_pushes_picker_view() {
481 // `/sessions show` and `/sessions list` are explicit aliases
482 // for the no-arg picker form. Verify they don't fall through
483 // to the prune branch.
484 let tmpdir = TempDir::new().unwrap();
485 let mut app = create_test_app_with_tmpdir(&tmpdir);
486 let initial_kind = app.view_stack.top_kind();
487 let result = sessions(&mut app, Some("show"));
488 assert_eq!(result.message, None);
489 assert_ne!(app.view_stack.top_kind(), initial_kind);
490 }
491
492 #[test]
493 fn test_sessions_prune_requires_days_argument() {
494 let tmpdir = TempDir::new().unwrap();
495 let mut app = create_test_app_with_tmpdir(&tmpdir);
496 let result = sessions(&mut app, Some("prune"));
497 assert!(result.is_error);
498 assert!(
499 result.message.as_deref().unwrap_or("").contains("usage"),
500 "expected usage hint: {:?}",
501 result.message
502 );
503 }
504
505 #[test]
506 fn test_sessions_prune_rejects_non_positive_days() {
507 let tmpdir = TempDir::new().unwrap();
508 let mut app = create_test_app_with_tmpdir(&tmpdir);
509 for bad in ["0", "-3", "abc", "3.14"] {
510 let result = sessions(&mut app, Some(&format!("prune {bad}")));
511 assert!(result.is_error, "expected error for `{bad}`");
512 }
513 }
514
515 #[test]
516 fn test_sessions_unknown_subcommand_errors() {
517 let tmpdir = TempDir::new().unwrap();
518 let mut app = create_test_app_with_tmpdir(&tmpdir);
519 let result = sessions(&mut app, Some("teleport"));
520 assert!(result.is_error);
521 assert!(
522 result
523 .message
524 .as_deref()
525 .unwrap_or("")
526 .contains("unknown subcommand"),
527 "expected unknown-subcommand error: {:?}",
528 result.message
529 );
530 }
531 }
532
532 lines RUST