use crate::session::Session;
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use comfy_table::presets::UTF8_BORDERS_ONLY;
use comfy_table::Table;
use serde::Serialize;
use serde_json::Value;
use std::fs;
use std::io::{BufRead, BufReader};
pub const SCHEMA_VERSION: u32 = 3;
#[derive(Serialize)]
struct SessionList<'a> {
schema_version: u32,
sessions: &'a [Session],
}
pub fn render_json(sessions: &[Session]) -> Result<String> {
Ok(serde_json::to_string_pretty(&SessionList {
schema_version: SCHEMA_VERSION,
sessions,
})?)
}
pub fn render_table(sessions: &[Session], now: DateTime<Utc>) -> String {
let mut table = Table::new();
table.load_preset(UTF8_BORDERS_ONLY);
table.set_header(vec![
"SHORT",
"NAME",
"LAST ACTIVE",
"MSGS",
"SIZE",
"BRANCH",
"PATH",
]);
for s in sessions {
let name = truncate(&s.name.replace('\n', " "), 50);
table.add_row(vec![
s.short.clone(),
name,
relative_time(s.last_active, now),
s.message_count.to_string(),
human_size(s.size_bytes),
s.git_branch.clone(),
truncate(&s.cwd, 45),
]);
}
format!("{table}\n{} sessions", sessions.len())
}
#[derive(Serialize)]
pub struct Message {
pub role: &'static str,
pub entry_type: String,
pub timestamp: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub uuid: Option<String>,
pub is_meta: bool,
pub is_sidechain: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_type: Option<String>,
pub content: Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_use_result: Option<Value>,
}
#[derive(Serialize)]
struct Transcript<'a> {
schema_version: u32,
session_id: &'a str,
name: &'a str,
cwd: &'a str,
created: Option<DateTime<Utc>>,
git_branch: &'a str,
version: &'a str,
message_count: usize,
messages: Vec<Message>,
}
#[derive(Clone, Copy, Default)]
pub struct MsgFilter<'a> {
pub role: Option<&'a str>,
pub grep: Option<&'a str>,
}
pub fn collect_filtered(path: &str, filter: MsgFilter) -> Result<Vec<Message>> {
collect_messages(path, None, None, filter)
}
fn collect_messages(
path: &str,
limit: Option<usize>,
before: Option<&str>,
filter: MsgFilter,
) -> Result<Vec<Message>> {
let file = fs::File::open(path).with_context(|| format!("opening {path}"))?;
let mut msgs = Vec::new();
for line in BufReader::new(file).lines() {
let line = match line {
Ok(l) => l,
Err(_) => continue,
};
let line = line.trim();
if line.is_empty() {
continue;
}
let v: Value = match serde_json::from_str(line) {
Ok(v) => v,
Err(_) => continue,
};
let role = match v.get("type").and_then(|t| t.as_str()) {
Some("user") => "user",
Some("assistant") => "assistant",
_ => continue,
};
if crate::session::is_synthetic_entry(&v) {
continue;
}
let content = match v.get("message").and_then(|m| m.get("content")) {
Some(c) => c.clone(),
None => continue,
};
let timestamp = v
.get("timestamp")
.and_then(|x| x.as_str())
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
.map(|d| d.with_timezone(&Utc));
let uuid = v
.get("uuid")
.and_then(|x| x.as_str())
.map(|s| s.to_string());
let is_meta = v.get("isMeta").and_then(|b| b.as_bool()).unwrap_or(false);
let is_sidechain = v
.get("isSidechain")
.and_then(|b| b.as_bool())
.unwrap_or(false);
let user_type = v
.get("userType")
.and_then(|x| x.as_str())
.map(|s| s.to_string());
msgs.push(Message {
role,
entry_type: role.to_string(),
timestamp,
uuid,
is_meta,
is_sidechain,
user_type,
content,
tool_use_result: v.get("toolUseResult").cloned(),
});
}
if let Some(r) = filter.role {
msgs.retain(|m| m.role == r);
}
if let Some(pat) = filter.grep {
let pat = pat.to_lowercase();
msgs.retain(|m| {
flatten_content(&m.content).is_some_and(|t| t.to_lowercase().contains(&pat))
});
}
if let Some(cur) = before {
if let Some(i) = msgs.iter().position(|m| m.uuid.as_deref() == Some(cur)) {
msgs.truncate(i);
}
}
if let Some(n) = limit {
let start = msgs.len().saturating_sub(n);
msgs.drain(..start);
}
Ok(msgs)
}
pub fn render_transcript(
s: &Session,
limit: Option<usize>,
before: Option<&str>,
filter: MsgFilter,
) -> Result<String> {
let mut out = format!("# {}\n", s.name.replace('\n', " "));
out.push_str(&format!("id: {}\n", s.session_id));
out.push_str(&format!("cwd: {}\n", s.cwd));
if let Some(c) = s.created {
out.push_str(&format!("created: {}\n", c.to_rfc3339()));
}
out.push_str(&format!("messages: {}\n", s.message_count));
for m in collect_messages(&s.file_path, limit, before, filter)? {
let text = match flatten_content(&m.content) {
Some(t) if !t.trim().is_empty() => t,
_ => continue,
};
match m.timestamp {
Some(ts) => out.push_str(&format!(
"\n## {} · {}\n{}\n",
m.role,
ts.to_rfc3339(),
text
)),
None => out.push_str(&format!("\n## {}\n{}\n", m.role, text)),
}
}
Ok(out)
}
pub fn render_transcript_json(
s: &Session,
limit: Option<usize>,
before: Option<&str>,
filter: MsgFilter,
) -> Result<String> {
let t = Transcript {
schema_version: SCHEMA_VERSION,
session_id: &s.session_id,
name: &s.name,
cwd: &s.cwd,
created: s.created,
git_branch: &s.git_branch,
version: &s.version,
message_count: s.message_count,
messages: collect_messages(&s.file_path, limit, before, filter)?,
};
Ok(serde_json::to_string_pretty(&t)?)
}
pub struct SessionHits<'a> {
pub session: &'a Session,
pub messages: Vec<Message>,
}
pub fn render_grep(hits: &[SessionHits], now: DateTime<Utc>) -> String {
let mut out = String::new();
let total: usize = hits.iter().map(|h| h.messages.len()).sum();
for h in hits {
out.push_str(&format!(
"{} {} ({}, {})\n",
h.session.short,
truncate(&h.session.name.replace('\n', " "), 50),
relative_time(h.session.last_active, now),
h.messages.len(),
));
for m in &h.messages {
let snippet = flatten_content(&m.content)
.map(|t| truncate(&t.replace('\n', " "), 120))
.unwrap_or_default();
out.push_str(&format!(" [{}] {}\n", m.role, snippet));
}
}
out.push_str(&format!("{total} matches in {} sessions", hits.len()));
out
}
#[derive(Serialize)]
struct GrepSession<'a> {
session_id: &'a str,
short: &'a str,
name: &'a str,
cwd: &'a str,
messages: &'a [Message],
}
#[derive(Serialize)]
struct GrepResult<'a> {
schema_version: u32,
matches: Vec<GrepSession<'a>>,
}
pub fn render_grep_json(hits: &[SessionHits]) -> Result<String> {
let matches = hits
.iter()
.map(|h| GrepSession {
session_id: &h.session.session_id,
short: &h.session.short,
name: &h.session.name,
cwd: &h.session.cwd,
messages: &h.messages,
})
.collect();
Ok(serde_json::to_string_pretty(&GrepResult {
schema_version: SCHEMA_VERSION,
matches,
})?)
}
fn flatten_content(content: &Value) -> Option<String> {
if let Some(s) = content.as_str() {
return Some(s.to_string());
}
let mut parts = Vec::new();
for block in content.as_array()? {
match block.get("type").and_then(|t| t.as_str()) {
Some("text") => {
if let Some(t) = block.get("text").and_then(|x| x.as_str()) {
parts.push(t.to_string());
}
}
Some("thinking") => {
let t = block.get("thinking").and_then(|x| x.as_str()).unwrap_or("");
parts.push(format!("[thinking]\n{t}"));
}
Some("tool_use") => {
let name = block.get("name").and_then(|x| x.as_str()).unwrap_or("tool");
let input = block
.get("input")
.map(|i| serde_json::to_string(i).unwrap_or_default())
.unwrap_or_default();
parts.push(format!("[tool_use: {name}] {input}"));
}
Some("tool_result") => {
let body = tool_result_text(block.get("content"));
parts.push(format!("[tool_result] {body}"));
}
_ => {}
}
}
if parts.is_empty() {
None
} else {
Some(parts.join("\n"))
}
}
fn tool_result_text(content: Option<&Value>) -> String {
match content {
Some(Value::String(s)) => s.clone(),
Some(Value::Array(arr)) => arr
.iter()
.filter_map(|b| b.get("text").and_then(|t| t.as_str()))
.collect::<Vec<_>>()
.join("\n"),
_ => String::new(),
}
}
fn human_size(bytes: u64) -> String {
const UNITS: [&str; 4] = ["B", "K", "M", "G"];
let mut size = bytes as f64;
let mut unit = 0;
while size >= 1024.0 && unit < UNITS.len() - 1 {
size /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{bytes}{}", UNITS[0])
} else {
format!("{size:.1}{}", UNITS[unit])
}
}
fn truncate(s: &str, max: usize) -> String {
let chars: Vec<char> = s.chars().collect();
if chars.len() <= max {
return s.to_string();
}
let cut = max.saturating_sub(1);
let head: String = chars[..cut].iter().collect();
format!("{head}…")
}
fn relative_time(then: DateTime<Utc>, now: DateTime<Utc>) -> String {
let d = now.signed_duration_since(then);
let secs = d.num_seconds();
if secs < 0 {
return "in future".to_string();
}
if secs < 60 {
return "just now".to_string();
}
if d.num_minutes() < 60 {
return format!("{}m ago", d.num_minutes());
}
if d.num_hours() < 24 {
return format!("{}h ago", d.num_hours());
}
let days = d.num_days();
if days < 30 {
return format!("{days}d ago");
}
if days < 365 {
return format!("{}mo ago", days / 30);
}
format!("{}y ago", days / 365)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn sample() -> Session {
Session {
session_id: "11111111-x".into(),
short: "11111111".into(),
name: "Build the thing".into(),
cwd: "/home/sibin/demo".into(),
last_active: Utc.with_ymd_and_hms(2026, 6, 16, 10, 0, 0).unwrap(),
created: None,
message_count: 4,
git_branch: "main".into(),
version: "1.0".into(),
size_bytes: 10,
file_path: "/x".into(),
}
}
#[test]
fn json_contains_name() {
let out = render_json(&[sample()]).unwrap();
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["schema_version"], 3);
assert_eq!(v["sessions"][0]["name"], "Build the thing");
}
#[test]
fn table_has_short_and_footer() {
let now = Utc.with_ymd_and_hms(2026, 6, 16, 12, 0, 0).unwrap();
let out = render_table(&[sample()], now);
assert!(out.contains("11111111"));
assert!(out.contains("Build the thing"));
assert!(out.contains("1 sessions"));
}
#[test]
fn human_size_scales() {
assert_eq!(human_size(512), "512B");
assert_eq!(human_size(1536), "1.5K");
assert_eq!(human_size(5 * 1024 * 1024), "5.0M");
}
#[test]
fn truncate_and_relative() {
assert_eq!(truncate("hello", 10), "hello");
assert_eq!(truncate("hello", 3), "he…");
let now = Utc.with_ymd_and_hms(2026, 6, 16, 12, 0, 0).unwrap();
let then = Utc.with_ymd_and_hms(2026, 6, 16, 10, 0, 0).unwrap();
assert_eq!(relative_time(then, now), "2h ago");
}
}