use std::path::{Path, PathBuf};
mod parse;
mod render;
mod rows;
mod spine;
pub(crate) mod wire;
use parse::{parse_model, parse_tool_result};
pub use render::{Reading, render};
pub(crate) use rows::key;
pub use rows::{AutoExpand, Fold, Row, RowClass, Tone, rows};
const AGENTS_DIR: &str = "agents";
const MESSAGES_DIR: &str = "messages";
const TOOL_ORIGIN: &str = "tool";
const MD_EXT: &str = "md";
const JSON_EXT: &str = "json";
const STREAMING_NAME: &str = "«live»";
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Transcript {
pub entries: Vec<Entry>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Entry {
pub name: String,
pub raw: Vec<u8>,
pub kind: EntryKind,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EntryKind {
Delivered {
sender: String,
epitaph: Option<crate::inboxview::Epitaph>,
body: String,
},
Model {
model_id: String,
blocks: Vec<Block>,
usage: Usage,
},
ToolResult {
tool_use_id: String,
content: String,
is_error: bool,
},
Streaming { thinking: String, text: String },
Raw,
}
pub type Usage = std::collections::BTreeMap<String, u64>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Block {
Text(String),
Thinking(String),
ToolUse {
id: String,
name: String,
input_summary: String,
},
}
impl Transcript {
pub fn tool_in_progress(&self, tool_use_id: &str) -> bool {
!self.entries.iter().any(|e| {
matches!(&e.kind, EntryKind::ToolResult { tool_use_id: id, .. } if id == tool_use_id)
})
}
#[must_use]
pub fn with_live(&self, stream: &crate::git_tree::Stream) -> Transcript {
let (thinking, text) = (
stream.thinking.clone().unwrap_or_default(),
stream.text.clone().unwrap_or_default(),
);
let mut entries = self.entries.clone();
if !thinking.is_empty() || !text.is_empty() {
entries.push(Entry {
name: STREAMING_NAME.to_string(),
raw: format!("{thinking}{text}").into_bytes(),
kind: EntryKind::Streaming { thinking, text },
});
}
Transcript { entries }
}
}
pub fn build(workspace: &Path, agent_id: &str) -> Transcript {
let dir = workspace.join(AGENTS_DIR).join(agent_id).join(MESSAGES_DIR);
Transcript {
entries: read_messages(&dir),
}
}
fn read_messages(dir: &Path) -> Vec<Entry> {
let Ok(read) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut files: Vec<(String, PathBuf)> = read
.flatten()
.filter_map(|e| {
let path = e.path();
path.is_file()
.then(|| (e.file_name().to_string_lossy().into_owned(), path))
})
.collect();
files.sort_by(|a, b| a.0.cmp(&b.0));
files
.into_iter()
.map(|(name, path)| {
let raw = std::fs::read(&path).unwrap_or_default();
let kind = classify(&name, &raw);
Entry { name, raw, kind }
})
.collect()
}
fn classify(name: &str, raw: &[u8]) -> EntryKind {
let Some((origin, ext)) = parse_name(name) else {
return EntryKind::Raw;
};
match ext {
MD_EXT => {
let deposit = crate::inboxview::parse_deposit(raw);
EntryKind::Delivered {
sender: origin.to_string(),
epitaph: deposit.epitaph,
body: deposit.body,
}
}
JSON_EXT if origin == TOOL_ORIGIN => parse_tool_result(raw).unwrap_or(EntryKind::Raw),
JSON_EXT => parse_model(origin, raw),
_ => EntryKind::Raw,
}
}
fn parse_name(name: &str) -> Option<(&str, &str)> {
let (stem, ext) = name.rsplit_once('.')?;
let (num, origin) = stem.split_once('-')?;
if num.is_empty() || !num.bytes().all(|b| b.is_ascii_digit()) || origin.is_empty() {
return None;
}
Some((origin, ext))
}
#[cfg(test)]
mod tests;