1use std::{
4 fs,
5 path::{Path, PathBuf},
6};
7
8use anyhow::{Context, Result};
9use jiff::Timestamp;
10use serde::Deserialize;
11use serde_json::Value;
12
13#[derive(Debug)]
15pub struct Folio {
16 pub source: PathBuf,
17 pub turns: Vec<Turn>,
18}
19
20impl Folio {
21 pub fn read(source: &Path) -> Result<Self> {
24 let text = fs::read_to_string(source)
25 .with_context(|| format!("reading session {}", source.display()))?;
26
27 let mut turns = Vec::new();
28 for (index, line) in text.lines().enumerate() {
29 if line.trim().is_empty() {
30 continue;
31 }
32 let entry: Entry = serde_json::from_str(line)
33 .with_context(|| format!("{}:{}", source.display(), index + 1))?;
34 match entry {
35 Entry::User(turn) => turns.push(turn.into_turn(Role::User)),
36 Entry::Assistant(turn) => turns.push(turn.into_turn(Role::Assistant)),
37 Entry::Attachment(attachment) => turns.extend(attachment.into_turn()),
38 Entry::Bookkeeping => {}
39 }
40 }
41
42 Ok(Self {
43 source: source.to_path_buf(),
44 turns,
45 })
46 }
47
48 pub fn session_id(&self) -> &str {
49 self.source
50 .file_stem()
51 .and_then(|stem| stem.to_str())
52 .unwrap_or("session")
53 }
54
55 pub fn peek(source: &Path) -> SessionPeek {
61 let Ok(text) = fs::read_to_string(source) else {
62 return SessionPeek::default();
63 };
64
65 let mut cwd = None;
66 let mut ai_title = None;
67 let mut first_prompt = None;
68 for line in text.lines() {
69 let line = line.trim();
70 if line.is_empty() {
71 continue;
72 }
73 let Ok(value) = serde_json::from_str::<Value>(line) else {
74 continue;
75 };
76 if cwd.is_none()
77 && let Some(dir) = value.get("cwd").and_then(Value::as_str)
78 {
79 cwd = Some(PathBuf::from(dir));
80 }
81 match value.get("type").and_then(Value::as_str) {
82 Some("ai-title") => {
85 if let Some(title) = value.get("aiTitle").and_then(Value::as_str) {
86 ai_title = Some(title.to_owned());
87 }
88 }
89 Some("user") if first_prompt.is_none() && !is_meta(&value) => {
90 first_prompt = user_prompt(&value);
91 }
92 _ => {}
93 }
94 }
95
96 SessionPeek {
97 cwd,
98 title: ai_title.or(first_prompt),
99 }
100 }
101
102 pub fn panels(&self) -> Vec<Panel> {
106 let mut panels: Vec<Panel> = Vec::new();
107 for (index, turn) in self.turns.iter().enumerate() {
108 if turn.is_clear_command() {
109 continue;
110 }
111 if turn.is_tool_response()
112 && let Some(assistant) = panels.last_mut().filter(|p| p.role == Role::Assistant)
113 {
114 assistant.blocks.extend(turn.blocks());
115 continue;
116 }
117 panels.push(Panel::from_turn(turn, index + 1));
118 }
119 panels
120 }
121}
122
123#[derive(Debug, Default, PartialEq, Eq)]
126pub struct SessionPeek {
127 pub cwd: Option<PathBuf>,
131 pub title: Option<String>,
134}
135
136fn is_meta(entry: &Value) -> bool {
138 entry
139 .get("isMeta")
140 .and_then(Value::as_bool)
141 .unwrap_or(false)
142}
143
144fn user_prompt(entry: &Value) -> Option<String> {
148 const WRAPPERS: [&str; 4] = [
149 "<command-",
150 "<local-command-",
151 "<task-notification>",
152 "<system-reminder>",
153 ];
154
155 let content = entry.get("message")?.get("content")?;
156 let text = match content {
157 Value::String(text) => text.trim(),
158 Value::Array(blocks) => blocks
159 .iter()
160 .filter(|block| block.get("type").and_then(Value::as_str) == Some("text"))
161 .find_map(|block| block.get("text").and_then(Value::as_str))?
162 .trim(),
163 _ => return None,
164 };
165
166 let is_wrapper = WRAPPERS.iter().any(|tag| text.starts_with(tag));
167 (!text.is_empty() && !is_wrapper).then(|| text.to_owned())
168}
169
170#[derive(Debug)]
172pub struct Turn {
173 pub role: Role,
174 pub timestamp: Timestamp,
175 pub model: Option<String>,
176 pub content: Content,
177 pub is_sidechain: bool,
179 pub is_meta: bool,
181}
182
183impl Turn {
184 pub fn is_tool_response(&self) -> bool {
188 self.role == Role::User && self.content.is_only_tool_results()
189 }
190
191 pub fn is_clear_command(&self) -> bool {
195 matches!(&self.content, Content::Text(text)
196 if text.contains("<command-name>/clear</command-name>"))
197 }
198
199 fn blocks(&self) -> Vec<Block> {
202 match &self.content {
203 Content::Text(text) => vec![Block::Known(Known::Text { text: text.clone() })],
204 Content::Blocks(blocks) => blocks.clone(),
205 }
206 }
207}
208
209#[derive(Debug)]
214pub struct Panel {
215 pub turn_number: usize,
219 pub role: Role,
220 pub timestamp: Timestamp,
221 pub model: Option<String>,
222 pub blocks: Vec<Block>,
223 pub is_sidechain: bool,
225 pub is_meta: bool,
227}
228
229impl Panel {
230 fn from_turn(turn: &Turn, turn_number: usize) -> Self {
231 Self {
232 turn_number,
233 role: turn.role,
234 timestamp: turn.timestamp,
235 model: turn.model.clone(),
236 blocks: turn.blocks(),
237 is_sidechain: turn.is_sidechain,
238 is_meta: turn.is_meta,
239 }
240 }
241
242 pub fn kind(&self) -> PanelKind {
246 let speaker = match self.role {
247 Role::User => PanelKind::User,
248 Role::Assistant => PanelKind::Assistant,
249 };
250 if self.blocks.iter().any(Block::is_visible_text) {
251 speaker
252 } else if self.blocks.iter().any(Block::is_tool) {
253 PanelKind::Tool
254 } else if self.blocks.iter().any(Block::is_thinking) {
255 PanelKind::Thinking
256 } else {
257 speaker
258 }
259 }
260}
261
262#[derive(Debug, Clone, Copy, PartialEq, Eq)]
263pub enum Role {
264 User,
265 Assistant,
266}
267
268impl Role {
269 pub fn as_str(self) -> &'static str {
270 match self {
271 Role::User => "user",
272 Role::Assistant => "assistant",
273 }
274 }
275}
276
277#[derive(Debug, Deserialize)]
279#[serde(tag = "type")]
280enum Entry {
281 #[serde(rename = "user")]
282 User(RawTurn),
283 #[serde(rename = "assistant")]
284 Assistant(RawTurn),
285 #[serde(rename = "attachment")]
290 Attachment(RawAttachment),
291 #[serde(other)]
294 Bookkeeping,
295}
296
297#[derive(Debug, Deserialize)]
298struct RawTurn {
299 timestamp: Timestamp,
300 message: Message,
301 #[serde(default, rename = "isSidechain")]
302 is_sidechain: bool,
303 #[serde(default, rename = "isMeta")]
304 is_meta: bool,
305}
306
307impl RawTurn {
308 fn into_turn(self, role: Role) -> Turn {
309 Turn {
310 role,
311 timestamp: self.timestamp,
312 model: self.message.model,
313 content: self.message.content,
314 is_sidechain: self.is_sidechain,
315 is_meta: self.is_meta,
316 }
317 }
318}
319
320#[derive(Debug, Deserialize)]
323struct RawAttachment {
324 timestamp: Timestamp,
325 attachment: AttachmentBody,
326}
327
328impl RawAttachment {
329 fn into_turn(self) -> Option<Turn> {
332 let AttachmentBody::QueuedCommand { prompt } = self.attachment else {
333 return None;
334 };
335 Some(Turn {
336 role: Role::User,
337 timestamp: self.timestamp,
338 model: None,
339 content: Content::Text(prompt),
340 is_sidechain: false,
341 is_meta: false,
342 })
343 }
344}
345
346#[derive(Debug, Deserialize)]
347#[serde(tag = "type", rename_all = "snake_case")]
348enum AttachmentBody {
349 QueuedCommand { prompt: String },
352 #[serde(other)]
355 Other,
356}
357
358#[derive(Debug, Deserialize)]
359struct Message {
360 content: Content,
361 model: Option<String>,
362}
363
364#[derive(Debug, Clone, Deserialize)]
365#[serde(untagged)]
366pub enum Content {
367 Text(String),
368 Blocks(Vec<Block>),
369}
370
371impl Content {
372 fn is_only_tool_results(&self) -> bool {
373 match self {
374 Content::Text(_) => false,
375 Content::Blocks(blocks) => {
376 !blocks.is_empty() && blocks.iter().all(Block::is_tool_result)
377 }
378 }
379 }
380}
381
382impl Block {
383 fn is_tool_result(&self) -> bool {
384 matches!(self, Block::Known(Known::ToolResult { .. }))
385 }
386
387 fn is_tool(&self) -> bool {
388 matches!(
389 self,
390 Block::Known(Known::ToolUse { .. } | Known::ToolResult { .. })
391 )
392 }
393
394 fn is_thinking(&self) -> bool {
395 matches!(self, Block::Known(Known::Thinking { .. }))
396 }
397
398 pub(crate) fn is_visible_text(&self) -> bool {
399 matches!(self, Block::Known(Known::Text { text }) if !text.trim().is_empty())
400 }
401}
402
403#[derive(Debug, Clone, Copy, PartialEq, Eq)]
406pub enum PanelKind {
407 User,
408 Assistant,
409 Tool,
410 Thinking,
411}
412
413impl PanelKind {
414 pub fn label(self) -> &'static str {
415 match self {
416 PanelKind::User => "user",
417 PanelKind::Assistant => "assistant",
418 PanelKind::Tool => "tool",
419 PanelKind::Thinking => "thinking",
420 }
421 }
422}
423
424#[derive(Debug, Clone, Deserialize)]
430#[serde(untagged)]
431pub enum Block {
432 Known(Known),
433 Unknown(Value),
434}
435
436#[derive(Debug, Clone, Deserialize)]
437#[serde(tag = "type", rename_all = "snake_case")]
438pub enum Known {
439 Text {
440 text: String,
441 },
442 Thinking {
443 thinking: String,
444 },
445 ToolUse {
446 name: String,
447 input: Value,
448 },
449 ToolResult {
450 content: ToolResultContent,
451 #[serde(default)]
452 is_error: bool,
453 },
454 Image {
455 source: ImageSource,
456 },
457}
458
459#[derive(Debug, Clone, Deserialize)]
460#[serde(untagged)]
461pub enum ToolResultContent {
462 Text(String),
463 Blocks(Vec<Block>),
464}
465
466#[derive(Debug, Clone, Deserialize)]
467pub struct ImageSource {
468 pub media_type: String,
469 pub data: String,
470}