Skip to content

Commit c861d5f

Browse files
zippoxerclaude
andcommitted
Add Factory and OpenCode support, improve indexing and UI
New session sources: - Factory (Droid): ~/.factory/sessions/, filters <system-reminder> blocks - OpenCode: ~/.local/share/opencode/storage/, parses multi-file JSON structure Indexing improvements: - Add 50ms debounce to search input - Use Tantivy tokenizer for phrase queries and snippets - Preserve selection during indexing and add --reindex flag - Surface background indexer errors to user - Detect unexpected indexer thread death UI improvements: - Improve message truncation in preview pane - Join consecutive same-role messages at parse time - Filter CLI-injected blocks from Codex sessions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent ae866a0 commit c861d5f

16 files changed

+1475
-265
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
/target
2+
tests/fixtures/.cache

CLAUDE.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
## Purpose
44

5-
Search and resume past conversations from Claude Code and Codex CLI.
5+
Search and resume past conversations from Claude Code, Codex CLI, and Factory (Droid).
66

77
## Principles
88

@@ -23,6 +23,10 @@ cargo clippy # Lint
2323

2424
To test the TUI end-to-end, use tmux:
2525
```bash
26+
# Clear index cache if testing parser changes
27+
# macOS: ~/Library/Caches/recall, Linux: ~/.cache/recall
28+
rm -rf ~/Library/Caches/recall # macOS
29+
rm -rf ~/.cache/recall # Linux
2630
cargo build && tmux new-session -d -s test './target/debug/recall'
2731
tmux send-keys -t test 'search query'
2832
tmux capture-pane -t test -p # See output
@@ -37,16 +41,16 @@ cargo install --path .
3741

3842
## Architecture
3943

40-
Rust TUI for searching Claude Code and Codex CLI conversation history.
44+
Rust TUI for searching Claude Code, Codex CLI, and Factory conversation history.
4145

4246
- `src/main.rs` - Entry point, event loop, exec into CLI on resume
4347
- `src/app.rs` - Application state, search logic, background indexing thread
4448
- `src/ui.rs` - Two-pane ratatui rendering, match highlighting
4549
- `src/tui.rs` - Terminal setup/teardown
4650
- `src/theme.rs` - Light/dark theme with auto-detection
4751
- `src/session.rs` - Core types: Session, Message, SearchResult
48-
- `src/parser/` - JSONL parsers for Claude (`~/.claude/projects/`) and Codex (`~/.codex/sessions/`)
49-
- `src/index/` - Tantivy full-text search index, stored in `~/.cache/recall/`
52+
- `src/parser/` - JSONL parsers for Claude (`~/.claude/projects/`), Codex (`~/.codex/sessions/`), and Factory (`~/.factory/sessions/`)
53+
- `src/index/` - Tantivy full-text search index, stored in `~/Library/Caches/recall/` (macOS) or `~/.cache/recall/` (Linux)
5054

5155
## Key Patterns
5256

src/app.rs

Lines changed: 160 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,17 @@ use anyhow::Result;
55
use std::path::PathBuf;
66
use std::sync::mpsc::{self, Receiver, Sender};
77
use std::thread;
8+
use std::time::{Duration, Instant};
9+
10+
/// Debounce delay for search (avoid searching on every keystroke during fast typing/paste)
11+
const SEARCH_DEBOUNCE: Duration = Duration::from_millis(50);
812

913
/// Messages from the indexing thread
1014
pub enum IndexMsg {
1115
Progress { indexed: usize, total: usize },
1216
Done { total_sessions: usize },
1317
NeedsReload,
18+
Error(String),
1419
}
1520

1621
/// Search scope
@@ -25,6 +30,8 @@ pub enum SearchScope {
2530
pub struct App {
2631
/// Current search query
2732
pub query: String,
33+
/// Cursor position in query (char index)
34+
pub cursor: usize,
2835
/// Search results
2936
pub results: Vec<SearchResult>,
3037
/// Selected result index
@@ -57,6 +64,12 @@ pub struct App {
5764
pub search_scope: SearchScope,
5865
/// Launch directory (for folder-scoped search)
5966
pub launch_cwd: String,
67+
/// Whether a search is pending (for debouncing)
68+
search_pending: bool,
69+
/// When the last input occurred (for debouncing)
70+
last_input: Instant,
71+
/// Error from indexing thread (shown on exit)
72+
pub index_error: Option<String>,
6073
}
6174

6275
impl App {
@@ -89,8 +102,10 @@ impl App {
89102
background_index(index_path_clone, state_path, tx);
90103
});
91104

105+
let initial_cursor = initial_query.chars().count();
92106
let mut app = Self {
93107
query: initial_query,
108+
cursor: initial_cursor,
94109
results: Vec::new(),
95110
selected: 0,
96111
list_scroll: 0,
@@ -107,6 +122,9 @@ impl App {
107122
indexing: true,
108123
search_scope: SearchScope::Folder(launch_cwd.clone()),
109124
launch_cwd,
125+
search_pending: false,
126+
last_input: Instant::now(),
127+
index_error: None,
110128
};
111129

112130
// If there's an initial query, run the search immediately
@@ -119,16 +137,26 @@ impl App {
119137

120138
/// Check for indexing updates (call this in the main loop)
121139
pub fn poll_index_updates(&mut self) {
122-
if self.index_rx.is_none() {
123-
return;
124-
}
140+
use std::sync::mpsc::TryRecvError;
125141

126-
// Collect messages first to avoid borrow issues
127-
let messages: Vec<_> = {
128-
let rx = self.index_rx.as_ref().unwrap();
129-
std::iter::from_fn(|| rx.try_recv().ok()).collect()
142+
let Some(rx) = &self.index_rx else {
143+
return;
130144
};
131145

146+
// Collect messages, tracking if channel was disconnected
147+
let mut messages = Vec::new();
148+
let mut channel_disconnected = false;
149+
loop {
150+
match rx.try_recv() {
151+
Ok(msg) => messages.push(msg),
152+
Err(TryRecvError::Empty) => break,
153+
Err(TryRecvError::Disconnected) => {
154+
channel_disconnected = true;
155+
break;
156+
}
157+
}
158+
}
159+
132160
let mut should_close_rx = false;
133161
let mut needs_reload = false;
134162
let mut needs_search = false;
@@ -151,9 +179,23 @@ impl App {
151179
needs_reload = true;
152180
needs_search = true;
153181
}
182+
IndexMsg::Error(err) => {
183+
self.index_error = Some(err);
184+
self.status = Some("Index error • Ctrl+C for details".to_string());
185+
self.indexing = false;
186+
should_close_rx = true;
187+
}
154188
}
155189
}
156190

191+
// Detect unexpected indexer death (channel closed without Done/Error)
192+
if channel_disconnected && self.indexing {
193+
self.index_error = Some("Indexer stopped unexpectedly (possible crash)".to_string());
194+
self.status = Some("Index error • Ctrl+C for details".to_string());
195+
self.indexing = false;
196+
should_close_rx = true;
197+
}
198+
157199
if needs_reload {
158200
let _ = self.index.reload();
159201
}
@@ -167,6 +209,9 @@ impl App {
167209

168210
/// Perform a search (or show recent sessions if query is empty)
169211
pub fn search(&mut self) -> Result<()> {
212+
// Remember currently selected session to preserve selection
213+
let selected_session_id = self.results.get(self.selected).map(|r| r.session.id.clone());
214+
170215
let mut results = if self.query.is_empty() {
171216
self.index.recent(50)?
172217
} else {
@@ -179,8 +224,21 @@ impl App {
179224
}
180225

181226
self.results = results;
182-
self.selected = 0;
183-
self.list_scroll = 0;
227+
228+
// Try to preserve selection on the same session
229+
if let Some(ref id) = selected_session_id {
230+
if let Some(pos) = self.results.iter().position(|r| &r.session.id == id) {
231+
self.selected = pos;
232+
// Scroll to keep selection visible (at top of list area)
233+
self.list_scroll = pos;
234+
} else {
235+
self.selected = 0;
236+
self.list_scroll = 0;
237+
}
238+
} else {
239+
self.selected = 0;
240+
self.list_scroll = 0;
241+
}
184242
self.update_preview_scroll();
185243

186244
Ok(())
@@ -239,14 +297,31 @@ impl App {
239297

240298
/// Handle character input
241299
pub fn on_char(&mut self, c: char) {
242-
self.query.push(c);
243-
let _ = self.search();
300+
// Insert at cursor position
301+
let byte_pos = self.cursor_byte_pos();
302+
self.query.insert(byte_pos, c);
303+
self.cursor += 1;
304+
self.mark_search_pending();
244305
}
245306

246307
/// Handle backspace
247308
pub fn on_backspace(&mut self) {
248-
self.query.pop();
249-
let _ = self.search();
309+
if self.cursor > 0 {
310+
self.cursor -= 1;
311+
let byte_pos = self.cursor_byte_pos();
312+
self.query.remove(byte_pos);
313+
self.mark_search_pending();
314+
}
315+
}
316+
317+
/// Handle delete key
318+
pub fn on_delete(&mut self) {
319+
let char_count = self.query.chars().count();
320+
if self.cursor < char_count {
321+
let byte_pos = self.cursor_byte_pos();
322+
self.query.remove(byte_pos);
323+
self.mark_search_pending();
324+
}
250325
}
251326

252327
/// Clear search
@@ -255,6 +330,60 @@ impl App {
255330
self.should_quit = true;
256331
} else {
257332
self.query.clear();
333+
self.cursor = 0;
334+
self.mark_search_pending();
335+
}
336+
}
337+
338+
/// Move cursor left
339+
pub fn on_left(&mut self) {
340+
self.cursor = self.cursor.saturating_sub(1);
341+
}
342+
343+
/// Move cursor right
344+
pub fn on_right(&mut self) {
345+
let char_count = self.query.chars().count();
346+
if self.cursor < char_count {
347+
self.cursor += 1;
348+
}
349+
}
350+
351+
/// Move cursor to start
352+
pub fn on_home(&mut self) {
353+
self.cursor = 0;
354+
}
355+
356+
/// Move cursor to end
357+
pub fn on_end(&mut self) {
358+
self.cursor = self.query.chars().count();
359+
}
360+
361+
/// Convert cursor (char index) to byte position
362+
fn cursor_byte_pos(&self) -> usize {
363+
self.query.char_indices()
364+
.nth(self.cursor)
365+
.map(|(i, _)| i)
366+
.unwrap_or(self.query.len())
367+
}
368+
369+
/// Mark that a search is needed (debounced)
370+
fn mark_search_pending(&mut self) {
371+
self.search_pending = true;
372+
self.last_input = Instant::now();
373+
}
374+
375+
/// Check if debounce period has elapsed and trigger search if needed
376+
pub fn maybe_search(&mut self) {
377+
if self.search_pending && self.last_input.elapsed() >= SEARCH_DEBOUNCE {
378+
self.search_pending = false;
379+
let _ = self.search();
380+
}
381+
}
382+
383+
/// Force any pending search to run immediately (for tests)
384+
pub fn flush_pending_search(&mut self) {
385+
if self.search_pending {
386+
self.search_pending = false;
258387
let _ = self.search();
259388
}
260389
}
@@ -318,11 +447,19 @@ impl App {
318447

319448
/// Background indexing function
320449
fn background_index(index_path: PathBuf, state_path: PathBuf, tx: Sender<IndexMsg>) {
321-
let Ok(index) = SessionIndex::open_or_create(&index_path) else {
322-
return;
450+
let index = match SessionIndex::open_or_create(&index_path) {
451+
Ok(idx) => idx,
452+
Err(e) => {
453+
let _ = tx.send(IndexMsg::Error(format!("Failed to open index: {}", e)));
454+
return;
455+
}
323456
};
324-
let Ok(mut state) = IndexState::load(&state_path) else {
325-
return;
457+
let mut state = match IndexState::load(&state_path) {
458+
Ok(s) => s,
459+
Err(e) => {
460+
let _ = tx.send(IndexMsg::Error(format!("Failed to load index state: {}", e)));
461+
return;
462+
}
326463
};
327464

328465
// Discover and sort files by mtime (most recent first)
@@ -351,8 +488,12 @@ fn background_index(index_path: PathBuf, state_path: PathBuf, tx: Sender<IndexMs
351488
return;
352489
}
353490

354-
let Ok(mut writer) = index.writer() else {
355-
return;
491+
let mut writer = match index.writer() {
492+
Ok(w) => w,
493+
Err(e) => {
494+
let _ = tx.send(IndexMsg::Error(format!("Failed to create index writer: {}", e)));
495+
return;
496+
}
356497
};
357498

358499
for (i, file_path) in files_to_index.iter().enumerate() {

0 commit comments

Comments
 (0)