seher/claude_terminal/
transcript.rs1use 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#[must_use]
13pub fn encode_project_dir(cwd: &str) -> String {
14 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#[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 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 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}