ab2c708ca7
Major Features: - Runtime API for external integrations and turn management - Task manager with persistence and recovery - Shell output streaming and improved tool execution - Error taxonomy and audit logging - Command palette and UI enhancements Documentation: - Runtime API documentation - Operations runbook - Architecture updates Fixes: - Auto-compaction threshold and triggering logic - Doctor command API key validation - Clippy and formatting compliance
404 lines
14 KiB
Rust
404 lines
14 KiB
Rust
//! Session commands: save, load, compact, export
|
|
|
|
use std::fmt::Write;
|
|
use std::path::PathBuf;
|
|
|
|
use crate::session_manager::create_saved_session_with_mode;
|
|
use crate::tui::app::{App, AppAction};
|
|
use crate::tui::history::{HistoryCell, history_cells_from_message};
|
|
use crate::tui::session_picker::SessionPickerView;
|
|
|
|
use super::CommandResult;
|
|
|
|
/// Save session to file
|
|
pub fn save(app: &mut App, path: Option<&str>) -> CommandResult {
|
|
let save_path = if let Some(p) = path {
|
|
PathBuf::from(p)
|
|
} else {
|
|
let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
|
|
PathBuf::from(format!("session_{timestamp}.json"))
|
|
};
|
|
|
|
let messages = app.api_messages.clone();
|
|
let session = create_saved_session_with_mode(
|
|
&messages,
|
|
&app.model,
|
|
&app.workspace,
|
|
u64::from(app.total_tokens),
|
|
app.system_prompt.as_ref(),
|
|
Some(app.mode.label()),
|
|
);
|
|
|
|
let sessions_dir = save_path
|
|
.parent()
|
|
.filter(|p| !p.as_os_str().is_empty())
|
|
.map_or_else(|| app.workspace.clone(), std::path::Path::to_path_buf);
|
|
|
|
match std::fs::create_dir_all(&sessions_dir) {
|
|
Ok(()) => {
|
|
let json = match serde_json::to_string_pretty(&session) {
|
|
Ok(j) => j,
|
|
Err(e) => return CommandResult::error(format!("Failed to serialize session: {e}")),
|
|
};
|
|
match std::fs::write(&save_path, json) {
|
|
Ok(()) => {
|
|
app.current_session_id = Some(session.metadata.id.clone());
|
|
CommandResult::message(format!(
|
|
"Session saved to {} (ID: {})",
|
|
save_path.display(),
|
|
&session.metadata.id[..8]
|
|
))
|
|
}
|
|
Err(e) => CommandResult::error(format!("Failed to save session: {e}")),
|
|
}
|
|
}
|
|
Err(e) => CommandResult::error(format!("Failed to create directory: {e}")),
|
|
}
|
|
}
|
|
|
|
/// Load session from file
|
|
pub fn load(app: &mut App, path: Option<&str>) -> CommandResult {
|
|
let load_path = if let Some(p) = path {
|
|
if p.contains('/') || p.contains('\\') {
|
|
PathBuf::from(p)
|
|
} else {
|
|
app.workspace.join(p)
|
|
}
|
|
} else {
|
|
return CommandResult::error("Usage: /load <path>");
|
|
};
|
|
|
|
let content = match std::fs::read_to_string(&load_path) {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
return CommandResult::error(format!("Failed to read session file: {e}"));
|
|
}
|
|
};
|
|
|
|
let session: crate::session_manager::SavedSession = match serde_json::from_str(&content) {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
return CommandResult::error(format!("Failed to parse session file: {e}"));
|
|
}
|
|
};
|
|
|
|
app.api_messages.clone_from(&session.messages);
|
|
app.history.clear();
|
|
for msg in &app.api_messages {
|
|
app.history.extend(history_cells_from_message(msg));
|
|
}
|
|
app.mark_history_updated();
|
|
app.transcript_selection.clear();
|
|
app.model.clone_from(&session.metadata.model);
|
|
app.update_model_compaction_budget();
|
|
app.workspace.clone_from(&session.metadata.workspace);
|
|
app.total_tokens = u32::try_from(session.metadata.total_tokens).unwrap_or(u32::MAX);
|
|
app.total_conversation_tokens = app.total_tokens;
|
|
app.last_prompt_tokens = None;
|
|
app.last_completion_tokens = None;
|
|
app.current_session_id = Some(session.metadata.id.clone());
|
|
if let Some(sp) = session.system_prompt {
|
|
app.system_prompt = Some(crate::models::SystemPrompt::Text(sp));
|
|
}
|
|
app.scroll_to_bottom();
|
|
|
|
CommandResult::with_message_and_action(
|
|
format!(
|
|
"Session loaded from {} (ID: {}, {} messages)",
|
|
load_path.display(),
|
|
&session.metadata.id[..8],
|
|
session.metadata.message_count
|
|
),
|
|
crate::tui::app::AppAction::SyncSession {
|
|
messages: app.api_messages.clone(),
|
|
system_prompt: app.system_prompt.clone(),
|
|
model: app.model.clone(),
|
|
workspace: app.workspace.clone(),
|
|
},
|
|
)
|
|
}
|
|
|
|
/// Toggle auto-compaction
|
|
pub fn compact(app: &mut App) -> CommandResult {
|
|
app.auto_compact = !app.auto_compact;
|
|
|
|
CommandResult::with_message_and_action(
|
|
format!(
|
|
"Auto-compact: {}",
|
|
if app.auto_compact { "ON" } else { "OFF" }
|
|
),
|
|
AppAction::UpdateCompaction(app.compaction_config()),
|
|
)
|
|
}
|
|
|
|
/// Export conversation to markdown
|
|
pub fn export(app: &mut App, path: Option<&str>) -> CommandResult {
|
|
let export_path = path.map_or_else(
|
|
|| {
|
|
let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
|
|
PathBuf::from(format!("chat_export_{timestamp}.md"))
|
|
},
|
|
PathBuf::from,
|
|
);
|
|
|
|
let mut content = String::new();
|
|
content.push_str("# Chat Export\n\n");
|
|
let _ = write!(
|
|
content,
|
|
"**Model:** {}\n**Workspace:** {}\n**Date:** {}\n\n---\n\n",
|
|
app.model,
|
|
app.workspace.display(),
|
|
chrono::Local::now().format("%Y-%m-%d %H:%M:%S")
|
|
);
|
|
|
|
for cell in &app.history {
|
|
let (role, body) = match cell {
|
|
HistoryCell::User { content } => ("**You:**", content.clone()),
|
|
HistoryCell::Assistant { content, .. } => ("**Assistant:**", content.clone()),
|
|
HistoryCell::System { content } => ("*System:*", content.clone()),
|
|
HistoryCell::Thinking { content, .. } => ("*Thinking:*", content.clone()),
|
|
HistoryCell::Tool(tool) => ("**Tool:**", render_tool_cell(tool, 80)),
|
|
};
|
|
|
|
let _ = write!(content, "{}\n\n{}\n\n---\n\n", role, body.trim());
|
|
}
|
|
|
|
match std::fs::write(&export_path, content) {
|
|
Ok(()) => CommandResult::message(format!("Exported to {}", export_path.display())),
|
|
Err(e) => CommandResult::error(format!("Failed to export: {e}")),
|
|
}
|
|
}
|
|
|
|
/// Open the session picker UI
|
|
pub fn sessions(app: &mut App) -> CommandResult {
|
|
app.view_stack.push(SessionPickerView::new());
|
|
CommandResult::ok()
|
|
}
|
|
|
|
fn render_tool_cell(tool: &crate::tui::history::ToolCell, width: u16) -> String {
|
|
tool.lines(width)
|
|
.into_iter()
|
|
.map(line_to_string)
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
}
|
|
|
|
fn line_to_string(line: ratatui::text::Line<'static>) -> String {
|
|
line.spans
|
|
.into_iter()
|
|
.map(|span| span.content.to_string())
|
|
.collect::<String>()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::config::Config;
|
|
use crate::tui::app::{App, TuiOptions};
|
|
use tempfile::TempDir;
|
|
|
|
fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App {
|
|
let options = TuiOptions {
|
|
model: "deepseek-v3.2".to_string(),
|
|
workspace: tmpdir.path().to_path_buf(),
|
|
allow_shell: false,
|
|
use_alt_screen: true,
|
|
max_subagents: 1,
|
|
skills_dir: tmpdir.path().join("skills"),
|
|
memory_path: tmpdir.path().join("memory.md"),
|
|
notes_path: tmpdir.path().join("notes.txt"),
|
|
mcp_config_path: tmpdir.path().join("mcp.json"),
|
|
use_memory: false,
|
|
start_in_agent_mode: false,
|
|
skip_onboarding: true,
|
|
yolo: false,
|
|
resume_session_id: None,
|
|
};
|
|
App::new(options, &Config::default())
|
|
}
|
|
|
|
#[test]
|
|
fn test_save_creates_file_and_sets_session_id() {
|
|
let tmpdir = TempDir::new().unwrap();
|
|
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
|
let save_path = tmpdir.path().join("test_session.json");
|
|
|
|
let result = save(&mut app, Some(save_path.to_str().unwrap()));
|
|
assert!(result.message.is_some());
|
|
let msg = result.message.unwrap();
|
|
assert!(msg.contains("Session saved to"));
|
|
assert!(msg.contains("ID:"));
|
|
assert!(app.current_session_id.is_some());
|
|
assert!(save_path.exists());
|
|
}
|
|
|
|
#[test]
|
|
fn test_save_with_default_path_uses_workspace() {
|
|
let tmpdir = TempDir::new().unwrap();
|
|
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
|
let result = save(&mut app, None);
|
|
assert!(result.message.is_some());
|
|
let msg = result.message.unwrap();
|
|
// Should create file in workspace with timestamp name
|
|
// Give it a moment to ensure file is written
|
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
|
let entries: Vec<_> = std::fs::read_dir(tmpdir.path())
|
|
.unwrap()
|
|
.filter_map(|e| e.ok())
|
|
.filter(|e| e.file_name().to_string_lossy().starts_with("session_"))
|
|
.collect();
|
|
// Test passes if file was created or if save returned success message
|
|
assert!(!entries.is_empty() || msg.contains("Session saved"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_save_serialization_error() {
|
|
let tmpdir = TempDir::new().unwrap();
|
|
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
|
// This should work normally since SavedSession is serializable
|
|
// Testing error path would require mocking, which is complex
|
|
let save_path = tmpdir.path().join("test.json");
|
|
let result = save(&mut app, Some(save_path.to_str().unwrap()));
|
|
assert!(result.message.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_load_without_path_returns_error() {
|
|
let tmpdir = TempDir::new().unwrap();
|
|
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
|
let result = load(&mut app, None);
|
|
assert!(result.message.is_some());
|
|
assert!(result.message.unwrap().contains("Usage: /load"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_load_nonexistent_file_returns_error() {
|
|
let tmpdir = TempDir::new().unwrap();
|
|
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
|
let result = load(&mut app, Some("nonexistent.json"));
|
|
assert!(result.message.is_some());
|
|
assert!(result.message.unwrap().contains("Failed to read"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_load_invalid_json_returns_error() {
|
|
let tmpdir = TempDir::new().unwrap();
|
|
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
|
let bad_file = tmpdir.path().join("bad.json");
|
|
std::fs::write(&bad_file, "not valid json").unwrap();
|
|
let result = load(&mut app, Some(bad_file.to_str().unwrap()));
|
|
assert!(result.message.is_some());
|
|
assert!(result.message.unwrap().contains("Failed to parse"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_load_valid_session_restores_state() {
|
|
let tmpdir = TempDir::new().unwrap();
|
|
let mut app1 = create_test_app_with_tmpdir(&tmpdir);
|
|
// Set up some state to save
|
|
app1.api_messages.push(crate::models::Message {
|
|
role: "user".to_string(),
|
|
content: vec![crate::models::ContentBlock::Text {
|
|
text: "Hello".to_string(),
|
|
cache_control: None,
|
|
}],
|
|
});
|
|
app1.total_tokens = 500;
|
|
let save_path = tmpdir.path().join("test.json");
|
|
save(&mut app1, Some(save_path.to_str().unwrap()));
|
|
|
|
// Create new app and load
|
|
let mut app2 = create_test_app_with_tmpdir(&tmpdir);
|
|
let result = load(&mut app2, Some(save_path.to_str().unwrap()));
|
|
assert!(result.message.is_some());
|
|
let msg = result.message.unwrap();
|
|
assert!(msg.contains("Session loaded from"));
|
|
assert!(msg.contains("ID:"));
|
|
assert!(msg.contains("messages"));
|
|
assert_eq!(app2.api_messages.len(), 1);
|
|
assert_eq!(app2.total_tokens, 500);
|
|
assert!(app2.current_session_id.is_some());
|
|
assert!(matches!(result.action, Some(AppAction::SyncSession { .. })));
|
|
}
|
|
|
|
#[test]
|
|
fn test_compact_toggles_state() {
|
|
let tmpdir = TempDir::new().unwrap();
|
|
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
|
let initial = app.auto_compact;
|
|
|
|
let result = compact(&mut app);
|
|
assert!(result.message.is_some());
|
|
let msg = result.message.unwrap();
|
|
assert!(msg.contains("Auto-compact:"));
|
|
assert!(msg.contains(if initial { "OFF" } else { "ON" }));
|
|
assert_eq!(app.auto_compact, !initial);
|
|
assert!(matches!(
|
|
result.action,
|
|
Some(AppAction::UpdateCompaction(_))
|
|
));
|
|
|
|
// Toggle back
|
|
let _result2 = compact(&mut app);
|
|
assert_eq!(app.auto_compact, initial);
|
|
}
|
|
|
|
#[test]
|
|
fn test_export_crees_markdown_file() {
|
|
let tmpdir = TempDir::new().unwrap();
|
|
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
|
app.history.push(HistoryCell::User {
|
|
content: "Hello".to_string(),
|
|
});
|
|
app.history.push(HistoryCell::Assistant {
|
|
content: "Hi there".to_string(),
|
|
streaming: false,
|
|
});
|
|
|
|
let export_path = tmpdir.path().join("export.md");
|
|
let result = export(&mut app, Some(export_path.to_str().unwrap()));
|
|
assert!(result.message.is_some());
|
|
let msg = result.message.unwrap();
|
|
assert!(msg.contains("Exported to"));
|
|
assert!(export_path.exists());
|
|
|
|
let content = std::fs::read_to_string(&export_path).unwrap();
|
|
assert!(content.contains("# Chat Export"));
|
|
assert!(content.contains("**Model:**"));
|
|
assert!(content.contains("**You:**"));
|
|
assert!(content.contains("**Assistant:**"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_export_with_default_path() {
|
|
let tmpdir = TempDir::new().unwrap();
|
|
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
|
let result = export(&mut app, None);
|
|
assert!(result.message.is_some());
|
|
// Should create file with timestamp name in current dir
|
|
let entries: Vec<_> = std::fs::read_dir(".")
|
|
.unwrap()
|
|
.filter_map(|e| e.ok())
|
|
.filter(|e| e.file_name().to_string_lossy().starts_with("chat_export_"))
|
|
.collect();
|
|
// Clean up
|
|
for entry in &entries {
|
|
let _ = std::fs::remove_file(entry.path());
|
|
}
|
|
assert!(!entries.is_empty() || result.message.unwrap().contains("Exported to"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_sessions_pushes_picker_view() {
|
|
let tmpdir = TempDir::new().unwrap();
|
|
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
|
let initial_kind = app.view_stack.top_kind();
|
|
|
|
let result = sessions(&mut app);
|
|
assert_eq!(result.message, None);
|
|
assert!(result.action.is_none());
|
|
// View should have changed (session picker should be on top)
|
|
assert_ne!(app.view_stack.top_kind(), initial_kind);
|
|
}
|
|
}
|