From a8c598fb059312c6b2f4c57fc9668cb374e00c71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sea=CC=81nan=20McCullough?= Date: Fri, 24 Apr 2026 15:56:43 -0700 Subject: [PATCH] feat(cli): add rat grep for transcript search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `rat grep ` subcommand that scans PtyOutput events across session logs for a substring. Uniquely enabled by the event-log design: works against live sessions, dead sessions with metas still on disk, or any raw .log file — no daemon round-trip needed. Flags: - `-s, --session ` — limit to one session - `--log ` — scan a specific log file - `--raw` — match raw bytes; default strips ANSI so colored prompts and editor decorations don't interfere with the match text. Output format: `{session_short}:{seq}: {line}`, which composes cleanly with unix piping. Plain substring match only for MVP; regex is a natural follow-up. Adds a tiny CSI-only ANSI stripper inline rather than pulling in `vte` — grep only needs to drop color codes from match lines, full terminal emulation would be overkill. --- README.md | 19 +++++++ src/bin/rat.rs | 136 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+) diff --git a/README.md b/README.md index 657d437..50c0f4e 100644 --- a/README.md +++ b/README.md @@ -270,6 +270,25 @@ Non-interactive playback of a session log file (the (including terminal control sequences and colors) to stdout, so piping it back through a terminal shows the transcript as it happened. +### `rat grep PATTERN [-s, --session ID_OR_NAME] [--log PATH] [--raw]` + +Search session transcripts for a substring. Unique to rat's event-log +design — you can grep *any* historical session, live or dead, without +asking the daemon. + +```sh +rat grep ERROR # all sessions' logs +rat grep ERROR -s agent # just session 'agent' +rat grep ERROR --log ~/.local/state/rat/.log +rat grep "\x1b\[31m" --raw # match raw bytes (ANSI codes) +``` + +Output format: `{session_short}:{seq}: {line}`. Lines are ANSI-stripped +by default so colored prompts and editor decorations don't break match +text; pass `--raw` to match against the literal byte stream. + +Search is a plain substring match for now — no regex. + ### `rat init SHELL` Print shell integration code to stdout. `SHELL` is `zsh`, `bash`, or diff --git a/src/bin/rat.rs b/src/bin/rat.rs index 70d98af..12df68a 100644 --- a/src/bin/rat.rs +++ b/src/bin/rat.rs @@ -131,6 +131,21 @@ enum Cmd { #[arg(value_enum)] shell: clap_complete::Shell, }, + /// Search session transcripts for a substring. Works against any .log + /// file in the state dir, whether or not the daemon is still running. + Grep { + /// Substring to search for (case-sensitive). + pattern: String, + /// Limit search to a specific session (id prefix / name / alias). + #[arg(short, long)] + session: Option, + /// Search a specific log file instead of all logs in the state dir. + #[arg(long)] + log: Option, + /// Match raw bytes instead of ANSI-stripped text. + #[arg(long)] + raw: bool, + }, } #[tokio::main] @@ -157,6 +172,12 @@ async fn main() -> Result<()> { Some(Cmd::Rename { id, new_name }) => rename(&id, &new_name), Some(Cmd::Alias { id, alias }) => add_alias(&id, &alias), Some(Cmd::Completions { shell }) => completions(shell), + Some(Cmd::Grep { + pattern, + session, + log, + raw, + }) => grep(&pattern, session.as_deref(), log.as_deref(), raw), } } @@ -167,6 +188,121 @@ fn completions(shell: clap_complete::Shell) -> Result<()> { Ok(()) } +// `rat grep`: scan PtyOutput events across one or more session logs and +// emit lines that contain the pattern. Output format is +// `{short}:{seq}: {line}` so it composes with normal unix piping. +fn grep( + pattern: &str, + session: Option<&str>, + log: Option<&std::path::Path>, + raw: bool, +) -> Result<()> { + let logs: Vec = if let Some(p) = log { + vec![p.to_path_buf()] + } else if let Some(q) = session { + let meta = read_all_metas()? + .into_iter() + .find(|m| meta_has_label(m, q) || m.id.to_string().starts_with(q)) + .ok_or_else(|| { + anyhow::anyhow!( + "no session matching '{q}' (need a meta file on disk; use --log for raw log paths)" + ) + })?; + vec![meta.log_path] + } else { + all_log_paths()? + }; + + if logs.is_empty() { + eprintln!("no logs to search"); + return Ok(()); + } + + let mut total = 0usize; + for path in logs { + let log_obj = match FileEventLog::open(&path) { + Ok(l) => l, + Err(e) => { + eprintln!("skip {}: {e}", path.display()); + continue; + } + }; + let events = log_obj.read_from(0)?; + let short = path + .file_stem() + .and_then(|s| s.to_str()) + .map(|s| s.chars().take(8).collect::()) + .unwrap_or_else(|| "?".into()); + for ev in events { + if let Event::PtyOutput { data } = &ev.event { + let text = if raw { + String::from_utf8_lossy(data).into_owned() + } else { + strip_ansi(data) + }; + for line in text.lines() { + if line.contains(pattern) { + total += 1; + println!("{short}:{}: {}", ev.seq, line); + } + } + } + } + } + if total == 0 { + eprintln!("no matches for '{pattern}'"); + } + Ok(()) +} + +// All .log files under the state dir. Unordered — callers typically just +// iterate and output in discovery order. +fn all_log_paths() -> Result> { + let dir = paths::state_dir(); + if !dir.exists() { + return Ok(Vec::new()); + } + let mut out = Vec::new(); + for entry in std::fs::read_dir(&dir)? { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) == Some("log") { + out.push(path); + } + } + Ok(out) +} + +// Quick-and-dirty ANSI stripper for grep output. Drops CSI sequences +// (ESC [ params+intermediates final) and the first byte after any bare +// ESC. OSC sequences (ESC ]) rarely carry grep-relevant text so we +// don't bother — a CSI-only sweep is enough to make `ls`, prompts, +// editor output, etc. readable on the matching side. +fn strip_ansi(data: &[u8]) -> String { + let mut out: Vec = Vec::with_capacity(data.len()); + let mut i = 0; + while i < data.len() { + if data[i] == 0x1b { + i += 1; + if i < data.len() && data[i] == b'[' { + i += 1; + while i < data.len() && !(0x40..=0x7e).contains(&data[i]) { + i += 1; + } + if i < data.len() { + i += 1; + } + } else if i < data.len() { + i += 1; + } + } else { + out.push(data[i]); + i += 1; + } + } + String::from_utf8_lossy(&out).into_owned() +} + fn kill(query: &str, yes: bool) -> Result<()> { // Locate metadata by UUID or name. We can't rely on resolve_session alone // because it filters out dead sessions — and one use of `kill` is cleaning