use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use crate::error::{Result, TelePiError};
#[derive(Debug, Clone, Deserialize)]
pub struct SessionEntry {
#[serde(rename = "type")]
pub entry_type: String,
pub id: String,
#[serde(rename = "parentId")]
pub parent_id: Option<String>,
pub timestamp: Option<String>,
pub message: Option<EntryMessage>,
#[serde(rename = "modelId")]
pub model_id: Option<String>,
pub provider: Option<String>,
#[serde(rename = "thinkingLevel")]
pub thinking_level: Option<String>,
#[serde(skip)]
pub label: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct EntryMessage {
pub role: Option<String>,
pub content: Option<serde_json::Value>,
}
#[derive(Debug, Clone)]
pub struct TreeNode {
pub entry: SessionEntry,
pub children: Vec<TreeNode>,
}
impl TreeNode {
pub fn describe(&self) -> String {
match self.entry.entry_type.as_str() {
"session" => "π Session start".to_string(),
"model_change" => {
let model = self.entry.model_id.as_deref().unwrap_or("unknown");
let provider = self.entry.provider.as_deref().unwrap_or("");
if provider.is_empty() {
format!("π€ Model: {model}")
} else {
format!("π€ Model: {provider}/{model}")
}
}
"thinking_level_change" => {
let level = self.entry.thinking_level.as_deref().unwrap_or("unknown");
format!("π§ Thinking: {level}")
}
"message" => {
if let Some(ref msg) = self.entry.message {
let role = msg.role.as_deref().unwrap_or("unknown");
let preview = extract_text_preview(msg);
match role {
"user" => format!("π€ {preview}"),
"assistant" => format!("π€ {preview}"),
"toolResult" => format!("π§ {preview}"),
_ => format!("π¬ {role}: {preview}"),
}
} else {
"π¬ Message".to_string()
}
}
other => format!("β {other}"),
}
}
pub fn display_text(&self) -> String {
if let Some(ref label) = self.entry.label {
format!("π·οΈ {label}")
} else {
self.describe()
}
}
}
fn extract_text_preview(msg: &EntryMessage) -> String {
let content = match &msg.content {
Some(c) => c,
None => return "(empty)".to_string(),
};
if let Some(arr) = content.as_array() {
for block in arr {
if let Some(obj) = block.as_object() {
if let Some(t) = obj.get("type").and_then(|v| v.as_str()) {
match t {
"text" => {
if let Some(text) = obj.get("text").and_then(|v| v.as_str()) {
return truncate(text, 60);
}
}
"thinking" => {
if let Some(thinking) = obj.get("thinking").and_then(|v| v.as_str()) {
return format!("π {}", truncate(thinking, 50));
}
}
"tool_use" => {
if let Some(name) = obj.get("name").and_then(|v| v.as_str()) {
return format!("π§ {name}");
}
}
"toolResult" => {
if let Some(name) = obj.get("toolName").and_then(|v| v.as_str()) {
return format!("π {name} result");
}
}
_ => {}
}
}
}
}
}
if let Some(s) = content.as_str() {
return truncate(s, 60);
}
"(unknown)".to_string()
}
fn truncate(s: &str, max_len: usize) -> String {
let s = s.trim();
if s.len() <= max_len {
s.to_string()
} else {
format!("{}β¦", &s[..max_len])
}
}
pub fn parse_session_jsonl(path: &Path) -> Result<Vec<SessionEntry>> {
let content = std::fs::read_to_string(path)
.map_err(|e| TelePiError::PiSession(format!("failed to read session file: {e}")))?;
let mut entries = Vec::new();
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
match serde_json::from_str::<SessionEntry>(trimmed) {
Ok(entry) => entries.push(entry),
Err(_) => continue, }
}
Ok(entries)
}
pub fn build_tree(entries: Vec<SessionEntry>) -> Vec<TreeNode> {
let mut node_map: HashMap<String, TreeNode> = HashMap::new();
let mut root_ids: Vec<String> = Vec::new();
for entry in entries {
let id = entry.id.clone();
let parent_id = entry.parent_id.clone();
let node = TreeNode {
entry,
children: Vec::new(),
};
if parent_id.is_none() {
root_ids.push(id.clone());
}
node_map.insert(id, node);
}
let child_ids: Vec<(String, String)> = node_map
.iter()
.filter_map(|(id, node)| {
node.entry
.parent_id
.as_ref()
.map(|pid| (pid.clone(), id.clone()))
})
.collect();
for (parent_id, child_id) in child_ids {
if let Some(child) = node_map.remove(&child_id) {
if let Some(parent) = node_map.get_mut(&parent_id) {
parent.children.push(child);
}
}
}
root_ids
.into_iter()
.filter_map(|id| node_map.remove(&id))
.collect()
}
pub fn render_tree(nodes: &[TreeNode], max_depth: usize, max_entries: usize) -> String {
let mut output = String::new();
let mut count = 0;
render_tree_inner(nodes, &mut output, "", max_depth, &mut count, max_entries);
output
}
fn render_tree_inner(
nodes: &[TreeNode],
output: &mut String,
prefix: &str,
max_depth: usize,
count: &mut usize,
max_entries: usize,
) {
if *count >= max_entries || max_depth == 0 {
return;
}
for (i, node) in nodes.iter().enumerate() {
if *count >= max_entries {
output.push_str(&format!("{prefix}βββ β¦ (more entries)\n"));
return;
}
let is_last = i == nodes.len() - 1;
let connector = if is_last { "βββ " } else { "βββ " };
let child_prefix = if is_last { " " } else { "β " };
let label = node.display_text();
let entry_id = &node.entry.id[..8.min(node.entry.id.len())];
output.push_str(&format!("{prefix}{connector}{label} [{entry_id}]\n"));
*count += 1;
if !node.children.is_empty() {
render_tree_inner(
&node.children,
output,
&format!("{prefix}{child_prefix}"),
max_depth - 1,
count,
max_entries,
);
}
}
}
pub fn find_session_dirs(workspace: &Path) -> Vec<PathBuf> {
let encoded = encode_workspace_path(workspace);
let sessions_base = dirs::home_dir()
.unwrap_or_default()
.join(".pi")
.join("agent")
.join("sessions")
.join(&encoded);
let mut dirs = Vec::new();
if let Ok(entries) = std::fs::read_dir(&sessions_base) {
for entry in entries.flatten() {
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
dirs.push(entry.path());
}
}
}
dirs.sort();
dirs
}
pub fn find_latest_session_file(session_dir: &Path) -> Option<PathBuf> {
let mut latest: Option<(std::time::SystemTime, PathBuf)> = None;
if let Ok(entries) = std::fs::read_dir(session_dir) {
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
if let Ok(runs) = std::fs::read_dir(&path) {
for run in runs.flatten() {
let run_path = run.path();
if !run_path.is_dir() {
continue;
}
let jsonl = run_path.join("session.jsonl");
if jsonl.exists() {
if let Ok(meta) = std::fs::metadata(&jsonl) {
let modified = meta.modified().unwrap_or(std::time::UNIX_EPOCH);
if latest.is_none() || modified > latest.as_ref().unwrap().0 {
latest = Some((modified, jsonl));
}
}
}
}
}
}
}
latest.map(|(_, path)| path)
}
fn encode_workspace_path(workspace: &Path) -> String {
let s = workspace.to_string_lossy();
let mut encoded = s.replace('/', "-");
if !encoded.starts_with('-') {
encoded = format!("-{encoded}");
}
if !encoded.ends_with('-') {
encoded.push('-');
}
encoded
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_truncate() {
assert_eq!(truncate("hello", 10), "hello");
assert_eq!(truncate("hello world", 5), "helloβ¦");
}
#[test]
fn test_encode_workspace_path() {
let path = PathBuf::from("/Users/test/code");
let encoded = encode_workspace_path(&path);
assert!(encoded.starts_with('-'));
assert!(encoded.ends_with('-'));
}
}