Skip to main content

seher/claude_terminal/
transcript.rs

1use std::collections::HashSet;
2use std::path::PathBuf;
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use super::types::{
6    ClaudeSessionRef, ClaudeTerminalError, ClaudeTerminalResponse, ClaudeTranscriptReader,
7    FindClaudeSessionOptions, TranscriptMessage, WaitForAssistantResponseOptions,
8};
9
10/// Claude Code encodes the cwd into the directory name under `~/.claude/projects/`.
11/// Every character that is not an ASCII letter, digit, or hyphen is replaced with `-`.
12#[must_use]
13pub fn encode_project_dir(cwd: &str) -> String {
14    // canonicalize so relative paths are resolved before encoding
15    let path = std::fs::canonicalize(cwd).unwrap_or_else(|_| {
16        std::env::current_dir().map_or_else(|_| PathBuf::from(cwd), |base| base.join(cwd))
17    });
18    path.to_string_lossy()
19        .chars()
20        .map(|c| {
21            if c.is_ascii_alphanumeric() || c == '-' {
22                c
23            } else {
24                '-'
25            }
26        })
27        .collect()
28}
29
30#[must_use]
31pub fn default_transcript_root() -> String {
32    dirs::home_dir()
33        .unwrap_or_else(|| PathBuf::from("."))
34        .join(".claude")
35        .join("projects")
36        .to_string_lossy()
37        .into_owned()
38}
39
40fn now_ms() -> u64 {
41    let dur = SystemTime::now()
42        .duration_since(UNIX_EPOCH)
43        .unwrap_or_default()
44        .as_millis();
45    u64::try_from(dur).unwrap_or(u64::MAX)
46}
47
48fn has_jsonl_extension(name: &str) -> bool {
49    std::path::Path::new(name)
50        .extension()
51        .is_some_and(|ext| ext.eq_ignore_ascii_case("jsonl"))
52}
53
54fn project_dir(root: &str, cwd: &str) -> PathBuf {
55    PathBuf::from(root).join(encode_project_dir(cwd))
56}
57
58// ── FileSystemTranscriptReader ───────────────────────────────────────────────
59
60#[derive(Default)]
61pub struct FileSystemTranscriptReader;
62
63impl FileSystemTranscriptReader {
64    #[must_use]
65    pub fn new() -> Self {
66        Self
67    }
68}
69
70impl ClaudeTranscriptReader for FileSystemTranscriptReader {
71    fn list_session_names(
72        &self,
73        root: &str,
74        cwd: &str,
75    ) -> Result<HashSet<String>, ClaudeTerminalError> {
76        let dir = project_dir(root, cwd);
77        let Ok(entries) = std::fs::read_dir(&dir) else {
78            return Ok(HashSet::new());
79        };
80        Ok(entries
81            .filter_map(|e| e.ok()?.file_name().to_str().map(str::to_string))
82            .filter(|n| has_jsonl_extension(n))
83            .collect())
84    }
85
86    fn find_session(
87        &self,
88        options: FindClaudeSessionOptions,
89    ) -> Result<ClaudeSessionRef, ClaudeTerminalError> {
90        let dir = project_dir(&options.root, &options.cwd);
91        let deadline = now_ms().saturating_add(options.timeout_ms);
92        loop {
93            let entries: Vec<String> = std::fs::read_dir(&dir)
94                .map(|rd| {
95                    rd.filter_map(|e| e.ok()?.file_name().to_str().map(str::to_string))
96                        .collect()
97                })
98                .unwrap_or_default();
99
100            let mut candidates: Vec<(PathBuf, u64)> = entries
101                .into_iter()
102                .filter(|name| has_jsonl_extension(name) && !options.exclude_names.contains(name))
103                .filter_map(|name| {
104                    let path = dir.join(&name);
105                    let mtime = std::fs::metadata(&path)
106                        .and_then(|m| m.modified())
107                        .ok()
108                        .map(|t| t.duration_since(UNIX_EPOCH).unwrap_or_default().as_millis())
109                        .and_then(|ms| u64::try_from(ms).ok())?;
110                    if mtime >= options.after_ms {
111                        Some((path, mtime))
112                    } else {
113                        None
114                    }
115                })
116                .collect();
117
118            candidates.sort_by_key(|(_, mtime)| *mtime);
119
120            if let Some((path, _)) = candidates.into_iter().next() {
121                let session_id = path
122                    .file_stem()
123                    .map(|s| s.to_string_lossy().into_owned())
124                    .unwrap_or_default();
125                return Ok(ClaudeSessionRef {
126                    session_id,
127                    transcript_path: path.to_string_lossy().into_owned(),
128                });
129            }
130            if now_ms() >= deadline {
131                return Err(ClaudeTerminalError::Timeout(format!(
132                    "timed out finding Claude transcript under {}",
133                    dir.display()
134                )));
135            }
136            std::thread::sleep(std::time::Duration::from_millis(options.poll_interval_ms));
137        }
138    }
139
140    fn wait_for_assistant_response(
141        &self,
142        session: &ClaudeSessionRef,
143        options: WaitForAssistantResponseOptions,
144    ) -> Result<ClaudeTerminalResponse, ClaudeTerminalError> {
145        let deadline = now_ms().saturating_add(options.timeout_ms);
146        loop {
147            let raw = std::fs::read_to_string(&session.transcript_path).unwrap_or_default();
148            let messages = parse_jsonl(&raw);
149            let scan = scan_transcript(&messages);
150            if scan.last_result.is_some() {
151                return Ok(ClaudeTerminalResponse {
152                    session_id: session.session_id.clone(),
153                    assistant_messages: scan.assistant_messages,
154                    last_result_message: scan.last_result,
155                });
156            }
157            if scan.turn_complete && !scan.assistant_messages.is_empty() {
158                return Ok(ClaudeTerminalResponse {
159                    session_id: session.session_id.clone(),
160                    assistant_messages: scan.assistant_messages,
161                    last_result_message: None,
162                });
163            }
164            if now_ms() >= deadline {
165                if !scan.assistant_messages.is_empty() {
166                    return Ok(ClaudeTerminalResponse {
167                        session_id: session.session_id.clone(),
168                        assistant_messages: scan.assistant_messages,
169                        last_result_message: None,
170                    });
171                }
172                return Err(ClaudeTerminalError::Timeout(format!(
173                    "timed out waiting for Claude assistant response in {}",
174                    session.transcript_path
175                )));
176            }
177            std::thread::sleep(std::time::Duration::from_millis(options.poll_interval_ms));
178        }
179    }
180}
181
182struct TranscriptScan {
183    assistant_messages: Vec<TranscriptMessage>,
184    last_result: Option<TranscriptMessage>,
185    turn_complete: bool,
186}
187
188fn scan_transcript(messages: &[TranscriptMessage]) -> TranscriptScan {
189    let mut assistant_messages = Vec::new();
190    let mut last_result = None;
191    let mut turn_complete = false;
192    for m in messages {
193        match m.msg_type.as_str() {
194            "assistant" => assistant_messages.push(m.clone()),
195            "result" => last_result = Some(m.clone()),
196            "system" if m.subtype.as_deref() == Some("turn_duration") => {
197                turn_complete = true;
198            }
199            _ => {}
200        }
201    }
202    TranscriptScan {
203        assistant_messages,
204        last_result,
205        turn_complete,
206    }
207}
208
209#[must_use]
210pub fn parse_jsonl(raw: &str) -> Vec<TranscriptMessage> {
211    let valid_types = ["assistant", "user", "result", "system"];
212    raw.lines()
213        .filter(|l| !l.is_empty())
214        .filter_map(|line| {
215            let v: serde_json::Value = serde_json::from_str(line).ok()?;
216            let t = v.get("type")?.as_str()?;
217            if !valid_types.contains(&t) {
218                return None;
219            }
220            serde_json::from_value(v).ok()
221        })
222        .collect()
223}
224
225#[cfg(test)]
226#[expect(clippy::unwrap_used, reason = "tests may panic on unexpected fixtures")]
227mod tests {
228    use super::*;
229
230    #[test]
231    fn encode_project_dir_replaces_non_alnum() {
232        // Use a real absolute path so canonicalize succeeds on macOS CI
233        let encoded = encode_project_dir("/tmp");
234        assert!(!encoded.contains('/'), "slashes should be replaced");
235    }
236
237    #[test]
238    fn encode_project_dir_dot_becomes_dash() {
239        // Build a path under /tmp which always exists
240        let encoded = encode_project_dir("/tmp/.seher-test");
241        assert!(!encoded.contains('.'), "dots should be replaced");
242        assert!(!encoded.contains('/'), "slashes should be replaced");
243    }
244
245    #[test]
246    fn parse_jsonl_skips_invalid_lines() {
247        let raw = r#"{"type":"assistant","message":{"content":"hi"}}
248not-json
249{"type":"unknown_type"}
250{"type":"result","result":"done"}"#;
251        let msgs = parse_jsonl(raw);
252        assert_eq!(msgs.len(), 2);
253        assert_eq!(msgs[0].msg_type, "assistant");
254        assert_eq!(msgs[1].msg_type, "result");
255    }
256
257    #[test]
258    fn scan_transcript_detects_turn_complete() {
259        let raw = r#"{"type":"assistant","message":{"content":"hello"}}
260{"type":"system","subtype":"turn_duration"}"#;
261        let msgs = parse_jsonl(raw);
262        let scan = scan_transcript(&msgs);
263        assert_eq!(scan.assistant_messages.len(), 1);
264        assert!(scan.turn_complete);
265        assert!(scan.last_result.is_none());
266    }
267
268    #[test]
269    fn scan_transcript_detects_result() {
270        let raw = r#"{"type":"assistant","message":{"content":"hello"}}
271{"type":"result","result":"final answer"}"#;
272        let msgs = parse_jsonl(raw);
273        let scan = scan_transcript(&msgs);
274        assert!(scan.last_result.is_some());
275        assert_eq!(
276            scan.last_result.unwrap().result.as_deref(),
277            Some("final answer")
278        );
279    }
280}