Skip to main content

oxicode/cli/commands/
sessions.rs

1//! Session listing, tree, fork, and delete handlers.
2
3use crate::store::session::{AgentMessage, SessionManager};
4use anyhow::Result;
5use uuid::Uuid;
6
7/// `oxicode sessions` — list sessions for the current project.
8pub async fn handle_sessions() -> Result<()> {
9    let cwd = std::env::current_dir()
10        .unwrap_or_else(|_| std::path::PathBuf::from("."))
11        .to_string_lossy()
12        .to_string();
13    let sessions = crate::store::session::SessionManager::list(&cwd, None).await?;
14    if sessions.is_empty() {
15        println!("No sessions found.");
16    } else {
17        println!("Sessions:");
18        println!(
19            "{:<20} {:>6} {:<30} {:>12}",
20            "NAME", "MSG", "PREVIEW", "TIME"
21        );
22        println!("{:-<20} {:-<6} {:-<30} {:-<12}", "", "", "", "");
23        for session in &sessions {
24            let name = session.name.as_deref().unwrap_or("-");
25            let preview = if session.first_message.len() > 28 {
26                format!("{}...", &session.first_message[..28])
27            } else {
28                session.first_message.clone()
29            };
30            let time = chrono::DateTime::<chrono::Local>::from(session.modified)
31                .format("%m-%d %H:%M")
32                .to_string();
33            println!(
34                "{:<20} {:>6} {:<30} {:>12}",
35                &name[..name.len().min(20)],
36                session.message_count,
37                preview,
38                time
39            );
40        }
41    }
42    Ok(())
43}
44
45/// `oxicode tree <session_id>` — show the conversation tree for a session.
46pub async fn handle_tree(session_id: &str) -> Result<()> {
47    let manager = SessionManager::new().await?;
48    show_tree(&manager, session_id).await
49}
50
51/// `oxicode fork <parent_id> <entry_id>` — branch a new session from an entry.
52pub async fn handle_fork(parent_id: &str, entry_id: &str) -> Result<()> {
53    let manager = SessionManager::new().await?;
54    fork_session(&manager, parent_id, entry_id).await
55}
56
57/// `oxicode delete <session_id>` — delete a session file.
58pub async fn handle_delete(session_id: &str) -> Result<()> {
59    let manager = SessionManager::new().await?;
60    delete_session(&manager, session_id).await
61}
62
63/// List all sessions via the manager API (legacy fallback).
64#[allow(dead_code)]
65pub async fn list_sessions(manager: &SessionManager) -> Result<()> {
66    let sessions = manager.list_sessions().await?;
67
68    if sessions.is_empty() {
69        println!("No sessions found.");
70        return Ok(());
71    }
72
73    println!("Sessions:");
74    println!("{:<36} {:<20} UPDATED", "ID", "BRANCH");
75    println!("{:-<36} {:-<20} {:-<20}", "", "", "");
76
77    for meta in sessions {
78        let branch_str = if let Some(ref pid) = meta.parent_id {
79            format!("forked from {}", &pid.to_string()[..8])
80        } else {
81            "root".to_string()
82        };
83        let updated = chrono::DateTime::from_timestamp_millis(meta.updated_at)
84            .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
85            .unwrap_or_else(|| "unknown".to_string());
86        println!("{:<36} {:<20} {}", meta.id, branch_str, updated);
87    }
88
89    Ok(())
90}
91
92/// Render the tree representation of a session.
93pub async fn show_tree(manager: &SessionManager, session_id: &str) -> Result<()> {
94    let id = if session_id.is_empty() {
95        // Get most recent session
96        let sessions = manager.list_sessions().await?;
97        match sessions.first() {
98            Some(s) => s.id,
99            None => {
100                println!("No sessions found.");
101                return Ok(());
102            }
103        }
104    } else {
105        Uuid::parse_str(session_id)?
106    };
107
108    let tree = manager.get_tree(id)?;
109    let branch_info = manager.get_branch_info(id).await?;
110
111    if let Some(info) = branch_info
112        && let Some(ref pid) = info.parent_session_id
113    {
114        println!("Session: {} (branched from {})", id, pid);
115    } else {
116        println!("Session: {} (root)", id);
117    }
118    println!();
119
120    // Show tree structure
121    for node in &tree {
122        let role_marker = match &node.entry.message {
123            AgentMessage::User { .. } => "U",
124            AgentMessage::Assistant { .. } => "A",
125            AgentMessage::System { .. } => "S",
126            _ => "-",
127        };
128
129        let content_preview = truncate(&node.entry.content(), 60);
130        let prefix = if node.entry.parent_id.is_some() {
131            "├─"
132        } else {
133            "└─"
134        };
135
136        println!(
137            "  {}{} [{:.8}] {}",
138            prefix, role_marker, node.entry.id, content_preview
139        );
140    }
141
142    Ok(())
143}
144
145/// Fork a new session from a specific entry point.
146pub async fn fork_session(
147    manager: &SessionManager,
148    parent_id_str: &str,
149    entry_id_str: &str,
150) -> Result<()> {
151    let sessions = manager.list_sessions().await?;
152    let info = sessions
153        .iter()
154        .find(|s| s.id.to_string().starts_with(parent_id_str))
155        .ok_or_else(|| anyhow::anyhow!("Session not found: {}", parent_id_str))?;
156    let entry_id = Uuid::parse_str(entry_id_str)
157        .map_err(|_| anyhow::anyhow!("Invalid entry ID: {}", entry_id_str))?;
158    let (new_session_id, _) = manager.branch_from(info.id, entry_id).await?;
159    println!("Created forked session: {}", new_session_id);
160    println!("File: {}", manager.session_path(&new_session_id).display());
161    Ok(())
162}
163
164/// Delete a session by id prefix.
165pub async fn delete_session(manager: &SessionManager, session_id: &str) -> Result<()> {
166    let sessions = manager.list_sessions().await?;
167    let info = sessions
168        .iter()
169        .find(|s| s.id.to_string().starts_with(session_id))
170        .ok_or_else(|| anyhow::anyhow!("Session not found: {}", session_id))?;
171    let path = manager.session_path(&info.id);
172    manager.delete(info.id).await?;
173    println!("Deleted session: {}", path.display());
174    Ok(())
175}
176
177/// Truncate a string to a maximum number of bytes, preserving a UTF-8 boundary.
178fn truncate(s: &str, max_len: usize) -> String {
179    if s.len() <= max_len {
180        return s.to_string();
181    }
182    let boundary = s
183        .char_indices()
184        .take_while(|(i, _)| *i <= max_len.saturating_sub(3))
185        .last()
186        .map(|(i, c)| i + c.len_utf8())
187        .unwrap_or(0);
188    format!("{}...", &s[..boundary])
189}