This commit is contained in:
Liam Rohan 2026-09-15 14:13:22 +01:00
commit 5e070dc7b9
20 changed files with 4566 additions and 0 deletions

2
.gitattributes vendored Normal file
View file

@ -0,0 +1,2 @@
* text=auto eol=lf
*.png binary

9
.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
/target/
.DS_Store
__pycache__/
*.py[cod]
.venv/
.env
.env.*
/cache/
/sessions/

32
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,32 @@
# Contributing
Keep changes focused. Open an issue before adding a major feature or dependency. Semantic search, dashboards, remote syncing and background services are outside the project's scope.
## Checks
Requires Rust, a C compiler and Python 3 for the terminal tests.
```sh
cargo fmt --check
cargo clippy --locked --all-targets -- -D warnings
cargo test --locked
cargo build --release --locked
python3 tests/pty_smoke.py target/release/cont
```
Tests use fictional data. Terminal tests launch stub executables in place of real agents.
## Bug reports and fixes
Include steps to reproduce and the relevant agent version. For parser bugs, provide a minimal fictional JSONL or SQLite fixture. Add a regression test with bug fixes where practical.
Do not commit real session histories, caches, credentials or screenshots of private work.
## Code structure
- `src/parse.rs`: Pi, Claude Code, Codex and OMP JSONL parsing.
- `src/opencode.rs`: OpenCode SQLite reader.
- `src/index.rs`: session discovery and caching.
- `src/search.rs`: title and human-message search.
- `src/session.rs`: session metadata and agent launch commands.
- `src/ui.rs`: terminal UI.

1191
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

29
Cargo.toml Normal file
View file

@ -0,0 +1,29 @@
[package]
name = "cont"
version = "0.2.0"
edition = "2024"
description = "A tiny, local session picker for Pi, Claude Code, Codex, OpenCode, and OMP"
license = "MIT"
repository = "https://src.liamrohan.com/ldr/cont"
homepage = "https://src.liamrohan.com/ldr/cont"
readme = "README.md"
keywords = ["cli", "tui", "agents", "sessions", "search"]
categories = ["command-line-utilities", "development-tools"]
[dependencies]
anyhow = "1"
clap = { version = "4", features = ["derive"] }
crossterm = "0.28"
dirs = "6"
nucleo-matcher = "0.3"
ratatui = "0.29"
rayon = "1"
rusqlite = { version = "0.38", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["raw_value"] }
tempfile = "3"
walkdir = "2"
[profile.release]
lto = "thin"
strip = true

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 resume contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

54
README.md Normal file
View file

@ -0,0 +1,54 @@
# cont
Find and resume coding agent sessions from the terminal. Supports Pi, Claude Code, Codex, OpenCode and Oh My Pi.
## Install
Requires Rust and a C compiler. The agents you use must be on your `PATH`.
```sh
cargo install --git https://src.liamrohan.com/ldr/cont.git --locked
```
## Usage
```sh
cont # sessions in the current directory
cont --all # sessions across all directories
cont --all auth refactor # search sessions
cont --agent claude # filter by agent
cont -C ~/code/my-project # use a different directory
cont --all --list postgres # search without the terminal UI
cont --all --json # JSON output
cont --print # print the selected session's command
```
Type to search titles and human messages. Press Enter to resume a session in its original directory.
| Key | Action |
| --- | --- |
| Up / Down | Move selection |
| Tab | Switch between the current directory and all directories |
| Shift-Tab | Cycle agents |
| Ctrl-L | Choose a directory |
| Ctrl-R | Refresh sessions |
| Esc | Clear search, then quit |
| Ctrl-C | Quit |
Directory filtering matches the exact directory, excluding subdirectories.
Run `cont --help` for all options. See [configuration](docs/configuration.md) for session paths, custom stores and search behaviour.
## Privacy
Reads local session stores without uploading conversations or modifying session logs. The cache contains human messages in plaintext under `${XDG_CACHE_HOME:-~/.cache}/cont/`. Use `--no-cache` to disable it, or delete that directory to remove cached text.
## Development
See [CONTRIBUTING.md](CONTRIBUTING.md) for build and test instructions.
## Credits
Based on [resume](https://github.com/robertmartin8/resume). Inspired by [fast-resume](https://stanislas.blog/2026/01/tui-index-search-coding-agent-sessions/) and [coding-agent-session-search](https://github.com/dicklesworthstone/coding_agent_session_search).
[MIT licence](LICENSE).

BIN
docs/assets/resume.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

68
docs/configuration.md Normal file
View file

@ -0,0 +1,68 @@
# Storage, search, and privacy
## Session discovery
`cont` reads the agents' central stores, not every folder on your disk. Each session's recorded working directory supplies its location in the picker. Discovery includes sessions from all locations; the initial **view** is scoped to your current directory.
| Agent | Default store | Resume command |
| --- | --- | --- |
| Pi | `~/.pi/agent/sessions/**/*.jsonl` | `pi --session <absolute-file>` |
| Claude Code | `~/.claude/projects/*/*.jsonl` | `claude --resume <id>` |
| Codex | `~/.codex/sessions/**/*.jsonl` | `codex resume <id>` |
| OpenCode | `~/.local/share/opencode/opencode*.db` | `opencode --session <id>` |
| Oh My Pi | `~/.omp/agent/sessions/**/*.jsonl` | `omp --resume <absolute-file>` |
### Overrides
- **Pi:** honors `PI_CODING_AGENT_DIR` and `PI_CODING_AGENT_SESSION_DIR`.
- **Claude Code:** honors `CLAUDE_CONFIG_DIR`.
- **Codex:** honors `CODEX_HOME`; names also come from `session_index.jsonl` beside the session root.
- **OpenCode:** honors `XDG_DATA_HOME` and `OPENCODE_DB`. Relative database names resolve beneath its data directory. Resuming sets `OPENCODE_DB` to the selected store, including custom/channel databases.
- **OMP:** also scans `${XDG_DATA_HOME:-~/.local/share}/omp/sessions` for migrated stores. Its shared `PI_CODING_AGENT_DIR` override is deliberately not auto-used: that would mislabel Pi history as OMP. Use `--omp-dir` for custom/profile stores. The active OMP configuration/profile is inherited when resuming.
Repeatable `--pi-dir`, `--claude-dir`, `--codex-dir`, and `--omp-dir` add **session/project storage roots**, not working directories. `--opencode-db` adds a SQLite file.
```sh
cont --all --omp-dir /path/to/omp/sessions
cont --all --opencode-db /path/to/opencode.db
```
### Format support
- Claude subagent logs/sidechain messages and Codex sessions marked as subagents are excluded. Codex archived sessions are not scanned by default.
- OpenCode's current SQLite format is supported; older pre-SQLite JSON stores are not. Reads use a read-only transaction and include committed WAL contents. Child/archived sessions, synthetic/ignored parts, assistant messages, and tool output are excluded.
- OMP supports legacy header titles, current title slots/renames, and human attribution.
- Malformed JSONL records and incomplete live-write tails are skipped. Changed files are retried on refresh. The picker never rewrites session logs or changes OpenCode database records.
## Search ranking
1. Exact title phrases, with exact titles and prefixes first.
2. Titles containing every query word, in any order.
3. Fuzzy title matches using Nucleo's Unicode-aware subsequence matcher.
4. Human messages containing the phrase or every query word, in any order, **within one message**.
Recency breaks ties. Named/renamed titles take precedence over the first human prompt. Search is case-insensitive; fuzzy title matching supports accent normalization. Message search is literal, not semantic or typo-correcting. Assistant replies, tools, images, compaction summaries, and recognized injected notifications are not searched. Human text is not truncated for indexing.
## Cache and privacy
The TUI opens immediately, loads cached results, and refreshes changed files in a background worker. JSONL parsing is parallel; unchanged files are detected by nanosecond mtime and size. OpenCode cache invalidation checks both database and WAL metadata, so it catches new messages and renames before checkpointing. Deleted files/sessions are removed during refresh. **Ctrl-R** rescans; there is no watcher or daemon.
Cache location:
```text
${XDG_CACHE_HOME:-~/.cache}/cont/sessions-v1.json
```
Use `RESUME_CACHE_DIR` to choose a dedicated alternative directory.
**The cache contains metadata and human messages in plaintext.** Directory/file permissions are `0700`/`0600` on Unix, and writes are atomic. Nothing is sent over the network. Don't share the cache or commit it to a repository.
- `--no-cache`: don't read or write a cache.
- `--rebuild`: force a fresh parse.
- Delete the cache directory to remove copied text; the next launch rebuilds it.
## Resume behavior
Enter restores the terminal, changes to the session's original working directory, and launches the matching agent. On Unix, `exec` gives the agent the existing terminal directly.
The picker does not fork sessions, change your parent shell's directory, or add permission-bypass flags. Agents must be on `PATH`; their normal configuration and trust checks apply. Missing directories are reported rather than silently resuming elsewhere. `--print` prints the selected command without executing it.

75
scripts/render_demo.py Normal file
View file

@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""Render the real Ratatui layout with fictional sessions. Never reads user history.
uv run --with 'pillow==11.3.0' python scripts/render_demo.py
Use --font /path/to/monospace.ttf to override the locally available font.
The font is rasterized into the PNG, not bundled or redistributed.
"""
import argparse
import json
from pathlib import Path
import subprocess
from PIL import Image, ImageDraw, ImageFont
ROOT = Path(__file__).resolve().parents[1]
PALETTE = {
"Black": "#11151e", "White": "#eef2fa", "DarkGray": "#7c879d",
"Gray": "#b8c1d4", "Cyan": "#65d9df", "Magenta": "#b89cff",
"Yellow": "#edbd80", "Green": "#9bd39f", "Blue": "#8fb9ff",
"LightMagenta": "#e2a1dc",
}
BG = "#11151e"
FG = "#dbe2ef"
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--font", type=Path)
args = parser.parse_args()
candidates = [args.font, Path("/System/Library/Fonts/Menlo.ttc"),
Path("/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf")]
font_path = next((p for p in candidates if p and p.is_file()), None)
if not font_path:
parser.error("Provide a monospace font using --font")
font = ImageFont.truetype(str(font_path), 28)
small = ImageFont.truetype(str(font_path), 23)
subprocess.run(["cargo", "test", "--locked", "--release", "ui::demo::render_demo",
"--", "--ignored", "--exact"], cwd=ROOT, check=True)
data = json.loads((ROOT / "target/demo-screen.json").read_text())
columns, rows = data["width"], data["height"]
cell_w, cell_h = round(font.getlength("M")), 42
margin, padding, chrome = 48, 32, 78
window_w = columns * cell_w + padding * 2
window_h = rows * cell_h + padding * 2 + chrome
image = Image.new("RGB", (window_w + margin * 2, window_h + margin * 2), "#090c13")
draw = ImageDraw.Draw(image)
box = (margin, margin, margin + window_w, margin + window_h)
draw.rounded_rectangle(box, radius=22, fill=BG, outline="#2b3344", width=2)
for i, color in enumerate(["#ef7474", "#e6be68", "#83c995"]):
x, y = margin + 32 + i * 31, margin + 35
draw.ellipse((x, y - 9, x + 18, y + 9), fill=color)
title = "cont — one picker, five agents"
draw.text((image.width / 2, margin + 35), title, font=small, fill="#929db3", anchor="mm")
draw.line((margin + 1, margin + chrome, margin + window_w - 1, margin + chrome), fill="#252c3c", width=2)
left, top = margin + padding, margin + chrome + padding
for i, cell in enumerate(data["cells"]):
x, y = left + (i % columns) * cell_w, top + (i // columns) * cell_h
# ANSI colors are terminal-theme dependent. Use a readable dark palette;
# selection background is a darker rendition of the terminal's gray.
bg = "#303b50" if cell["bg"] == "DarkGray" else PALETTE.get(cell["bg"], BG)
fg = PALETTE.get(cell["fg"], FG)
if bg != BG:
draw.rectangle((x, y, x + cell_w - 1, y + cell_h - 1), fill=bg)
if cell["text"].strip():
draw.text((x, y + cell_h / 2), cell["text"], font=font, fill=fg, anchor="lm",
stroke_width=1 if cell["bold"] else 0)
destination = ROOT / "docs/assets/resume.png"
destination.parent.mkdir(parents=True, exist_ok=True)
image.save(destination, optimize=True)
print(f"Wrote {destination.relative_to(ROOT)} ({image.width}×{image.height}); all session data is fictional.")
if __name__ == "__main__":
main()

542
src/index.rs Normal file
View file

@ -0,0 +1,542 @@
use std::{
collections::{HashMap, HashSet},
env,
fs::{self, File},
io::{BufRead, BufReader, BufWriter, Write},
path::{Path, PathBuf},
sync::{
Mutex,
atomic::{AtomicUsize, Ordering},
},
time::UNIX_EPOCH,
};
use anyhow::{Context, Result};
use rayon::prelude::*;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use walkdir::WalkDir;
use crate::{
parse::parse_file,
session::{Agent, Session, canonical, compact},
};
const CACHE_VERSION: u32 = 1;
#[derive(Clone)]
pub struct Sources {
pub roots: Vec<(Agent, PathBuf)>,
pub codex_home: PathBuf,
pub cache: PathBuf,
}
impl Sources {
pub fn discover(extra: &[(Agent, Vec<PathBuf>)]) -> Result<Self> {
let home = dirs::home_dir().context("Cannot find home directory")?;
let pi = env_path("PI_CODING_AGENT_SESSION_DIR").unwrap_or_else(|| {
env_path("PI_CODING_AGENT_DIR")
.unwrap_or_else(|| home.join(".pi/agent"))
.join("sessions")
});
let claude = env_path("CLAUDE_CONFIG_DIR")
.unwrap_or_else(|| home.join(".claude"))
.join("projects");
let codex_home = env_path("CODEX_HOME").unwrap_or_else(|| home.join(".codex"));
let data_home = env_path("XDG_DATA_HOME").unwrap_or_else(|| home.join(".local/share"));
let opencode_dir = data_home.join("opencode");
let mut roots = vec![
(Agent::Pi, pi),
(Agent::Claude, claude),
(Agent::Codex, codex_home.join("sessions")),
// OMP and Pi share PI_CODING_AGENT_DIR; do not index the same override as both.
(Agent::Omp, home.join(".omp/agent/sessions")),
(Agent::Omp, data_home.join("omp/sessions")),
];
match env_path("OPENCODE_DB") {
Some(path) if path == Path::new(":memory:") => {}
Some(path) => roots.push((
Agent::Opencode,
if path.is_absolute() {
path
} else {
opencode_dir.join(path)
},
)),
None => roots.push((Agent::Opencode, opencode_dir)),
}
for (agent, paths) in extra {
roots.extend(paths.iter().cloned().map(|p| (*agent, p)));
}
let current = env::current_dir()?;
for (_, root) in &mut roots {
if root.is_relative() {
*root = current.join(&*root);
}
*root = canonical(root);
}
let cache_dir = env_path("RESUME_CACHE_DIR").unwrap_or_else(|| {
env_path("XDG_CACHE_HOME")
.unwrap_or_else(|| home.join(".cache"))
.join("cont")
});
Ok(Self {
roots,
codex_home,
cache: cache_dir.join("sessions-v1.json"),
})
}
}
fn env_path(name: &str) -> Option<PathBuf> {
env::var_os(name)
.filter(|s| !s.is_empty())
.map(PathBuf::from)
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
struct Stamp {
len: u64,
seconds: u64,
nanos: u32,
}
impl Stamp {
fn read(path: &Path) -> std::io::Result<Self> {
let meta = fs::metadata(path)?;
let time = meta
.modified()?
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
Ok(Self {
len: meta.len(),
seconds: time.as_secs(),
nanos: time.subsec_nanos(),
})
}
}
#[derive(Deserialize, Serialize)]
struct CachedFile {
agent: Agent,
path: PathBuf,
stamp: Stamp,
session: Option<Session>,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
struct DatabaseStamp {
database: Stamp,
wal: Option<Stamp>,
}
impl DatabaseStamp {
fn read(path: &Path) -> std::io::Result<Self> {
let mut wal_path = path.as_os_str().to_os_string();
wal_path.push("-wal");
let wal = match Stamp::read(Path::new(&wal_path)) {
Ok(stamp) => Some(stamp),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => return Err(e),
};
Ok(Self {
database: Stamp::read(path)?,
wal,
})
}
}
#[derive(Deserialize, Serialize)]
struct CachedDatabase {
path: PathBuf,
stamp: DatabaseStamp,
sessions: Vec<Session>,
}
#[derive(Default, Deserialize, Serialize)]
struct Cache {
version: u32,
files: Vec<CachedFile>,
#[serde(default)]
databases: Vec<CachedDatabase>,
}
pub enum Update {
Snapshot(Vec<Session>),
Progress(String),
}
pub struct Report {
pub sessions: Vec<Session>,
pub parsed: usize,
pub reused: usize,
pub warnings: Vec<String>,
}
pub fn refresh(
sources: &Sources,
rebuild: bool,
no_cache: bool,
notify: impl Fn(Update) + Sync,
) -> Result<Report> {
let mut warnings = vec![];
let old = if no_cache || rebuild {
Cache::default()
} else {
match File::open(&sources.cache) {
Ok(file) => match serde_json::from_reader::<_, Cache>(BufReader::new(file)) {
Ok(cache) if cache.version == CACHE_VERSION => cache,
_ => {
warnings.push("Cache unreadable/outdated; rebuilding".into());
Cache::default()
}
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Cache::default(),
Err(e) => {
warnings.push(format!("Cache: {e}"));
Cache::default()
}
}
};
let old_files: HashMap<_, _> = old
.files
.into_iter()
.filter(|f| {
sources
.roots
.iter()
.any(|(a, r)| *a == f.agent && f.path.starts_with(r))
})
.map(|f| ((f.agent, f.path.clone()), f))
.collect();
let old_databases: HashMap<_, _> = old
.databases
.into_iter()
.filter(|db| {
sources
.roots
.iter()
.any(|(a, r)| *a == Agent::Opencode && db.path.starts_with(r))
})
.map(|db| (db.path.clone(), db))
.collect();
if !old_files.is_empty() || !old_databases.is_empty() {
let mut cached: Vec<_> = old_files
.values()
.filter_map(|f| f.session.clone())
.chain(old_databases.values().flat_map(|db| db.sessions.clone()))
.collect();
finish_sessions(&mut cached, sources);
notify(Update::Snapshot(cached));
}
notify(Update::Progress("Looking for sessions…".into()));
let mut files = vec![];
let mut databases = HashSet::new();
let mut seen = HashSet::new();
for (agent, root) in &sources.roots {
if !root.exists() {
continue;
}
if *agent == Agent::Opencode {
if root.is_file() {
databases.insert(root.clone());
} else {
match fs::read_dir(root) {
Ok(entries) => {
for entry in entries {
match entry {
Ok(entry) => {
let path = entry.path();
let name = entry.file_name();
let name = name.to_string_lossy();
if name.starts_with("opencode")
&& name.ends_with(".db")
&& path.is_file()
{
databases.insert(canonical(&path));
}
}
Err(e) => warnings.push(format!("{}: {e}", root.display())),
}
}
}
Err(e) => warnings.push(format!("{}: {e}", root.display())),
}
}
continue;
}
let walk = WalkDir::new(root).max_depth(if *agent == Agent::Claude { 2 } else { 12 });
for entry in walk {
let entry = match entry {
Ok(e) => e,
Err(e) => {
warnings.push(e.to_string());
continue;
}
};
let path = entry.path();
if !entry.file_type().is_file() || path.extension().is_none_or(|s| s != "jsonl") {
continue;
}
if *agent == Agent::Claude
&& path
.file_name()
.is_some_and(|s| s.to_string_lossy().starts_with("agent-"))
{
continue;
}
if !seen.insert((*agent, path.to_path_buf())) {
continue;
}
match Stamp::read(path) {
Ok(stamp) => files.push((*agent, path.to_path_buf(), stamp)),
Err(e) => warnings.push(format!("{}: {e}", path.display())),
}
}
}
// Most recent sessions get parsed first on a cold start.
files.sort_by_key(|f| std::cmp::Reverse(f.2.seconds));
let total = files.len();
let parsed = AtomicUsize::new(0);
let reused = AtomicUsize::new(0);
let done = AtomicUsize::new(0);
let errors = Mutex::new(vec![]);
let new_files: Vec<_> = files
.into_par_iter()
.filter_map(|(agent, path, stamp)| {
let old = old_files.get(&(agent, path.clone()));
let session = if let Some(old) = old.filter(|f| f.stamp == stamp) {
reused.fetch_add(1, Ordering::Relaxed);
old.session.clone()
} else {
parsed.fetch_add(1, Ordering::Relaxed);
match parse_file(agent, &path, stamp.seconds) {
Ok(s) => s,
Err(e) => {
errors
.lock()
.unwrap()
.push(format!("{}: {e}", path.display()));
return None;
}
}
};
let count = done.fetch_add(1, Ordering::Relaxed) + 1;
if count.is_multiple_of(50) || count == total {
notify(Update::Progress(format!(
"Reading sessions {count}/{total}…"
)));
}
Some(CachedFile {
agent,
path,
stamp,
session,
})
})
.collect();
warnings.extend(errors.into_inner().unwrap());
let mut new_databases = vec![];
for path in databases {
notify(Update::Progress("Reading OpenCode sessions…".into()));
let result = (|| -> Result<CachedDatabase> {
let stamp = DatabaseStamp::read(&path)?;
let sessions =
if let Some(old) = old_databases.get(&path).filter(|db| db.stamp == stamp) {
reused.fetch_add(1, Ordering::Relaxed);
old.sessions.clone()
} else {
parsed.fetch_add(1, Ordering::Relaxed);
crate::opencode::read_database(&path)?
};
Ok(CachedDatabase {
path: path.clone(),
stamp,
sessions,
})
})();
match result {
Ok(db) => new_databases.push(db),
Err(e) => warnings.push(format!("{}: {e:#}", path.display())),
}
}
let cache = Cache {
version: CACHE_VERSION,
files: new_files,
databases: new_databases,
};
let mut sessions = cache
.files
.iter()
.filter_map(|f| f.session.clone())
.chain(cache.databases.iter().flat_map(|db| db.sessions.clone()))
.collect();
finish_sessions(&mut sessions, sources);
let changed = parsed.load(Ordering::Relaxed) > 0
|| cache.files.len() != old_files.len()
|| cache.databases.len() != old_databases.len();
if !no_cache
&& changed
&& let Err(e) = save_cache(&sources.cache, &cache)
{
warnings.push(format!("Could not save cache: {e:#}"));
}
Ok(Report {
sessions,
parsed: parsed.load(Ordering::Relaxed),
reused: reused.load(Ordering::Relaxed),
warnings,
})
}
fn finish_sessions(sessions: &mut Vec<Session>, sources: &Sources) {
let mut titles = HashMap::new();
// The index is tiny and can change independently of rollout mtimes (e.g. /rename).
let mut indexes = HashSet::from([sources.codex_home.join("session_index.jsonl")]);
for (agent, root) in &sources.roots {
if *agent == Agent::Codex
&& let Some(parent) = root.parent()
{
indexes.insert(parent.join("session_index.jsonl"));
}
}
for path in indexes {
if let Ok(file) = File::open(path) {
for line in BufReader::new(file).lines().map_while(Result::ok) {
if let Ok(v) = serde_json::from_str::<Value>(&line)
&& let (Some(id), Some(title)) = (v["id"].as_str(), v["thread_name"].as_str())
{
titles.insert(id.to_owned(), compact(title, 200));
}
}
}
}
let mut directories = HashMap::new();
for s in sessions.iter_mut() {
s.cwd = directories
.entry(s.cwd.clone())
.or_insert_with(|| canonical(&s.cwd))
.clone();
if s.agent == Agent::Codex
&& let Some(title) = titles.get(&s.id).filter(|t| !t.is_empty())
{
s.title.clone_from(title);
}
}
sessions.sort_by(|a, b| {
b.modified
.cmp(&a.modified)
.then_with(|| a.path.cmp(&b.path))
});
let mut seen = HashSet::new();
sessions.retain(|s| seen.insert((s.agent, s.id.clone())));
}
fn save_cache(path: &Path, cache: &Cache) -> Result<()> {
let dir = path.parent().context("Cache path has no parent")?;
fs::create_dir_all(dir)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(dir, fs::Permissions::from_mode(0o700))?;
}
let mut tmp = tempfile::NamedTempFile::new_in(dir)?; // 0600 on Unix; atomic replacement.
{
let mut writer = BufWriter::new(tmp.as_file_mut());
serde_json::to_writer(&mut writer, cache)?;
writer.flush()?;
}
tmp.persist(path)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cache_refresh_handles_change_deletion_and_corruption() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("sessions");
fs::create_dir(&root).unwrap();
let file = root.join("p.jsonl");
let data = "{\"type\":\"session\",\"id\":\"p\",\"cwd\":\"/work\"}\n";
fs::write(&file, data).unwrap();
let sources = Sources {
roots: vec![(Agent::Pi, root)],
codex_home: tmp.path().into(),
cache: tmp.path().join("cache/index.json"),
};
let run = || refresh(&sources, false, false, |_| {}).unwrap();
assert_eq!(run().parsed, 1);
assert_eq!(run().reused, 1);
fs::write(
&file,
format!("{data}{{\"type\":\"session_info\",\"name\":\"Changed\"}}\n"),
)
.unwrap();
let report = run();
assert_eq!(report.parsed, 1);
assert_eq!(report.sessions[0].title, "Changed");
fs::write(&sources.cache, "broken").unwrap();
assert_eq!(run().parsed, 1);
fs::remove_file(file).unwrap();
assert!(run().sessions.is_empty());
}
#[test]
fn opencode_cache_tracks_wal_renames_messages_and_deletions() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("opencode.db");
let conn = crate::opencode::tests::database(&path);
let sources = Sources {
roots: vec![(Agent::Opencode, path.clone())],
codex_home: tmp.path().into(),
cache: tmp.path().join("cache/index.json"),
};
let run = || refresh(&sources, false, false, |_| {}).unwrap();
assert_eq!(run().sessions.len(), 1);
assert_eq!(run().reused, 1);
let main_before = fs::read(&path).unwrap();
conn.execute(
"UPDATE session SET title = 'Renamed' WHERE id = 'ses_main'",
[],
)
.unwrap();
assert_eq!(fs::read(&path).unwrap(), main_before); // Only the WAL changed.
let report = run();
assert_eq!(report.parsed, 1);
assert_eq!(report.sessions[0].title, "Renamed");
conn.execute(
"UPDATE part SET data = '{\"type\":\"text\",\"text\":\"edited\"}' WHERE id = 'p1'",
[],
)
.unwrap();
assert_eq!(run().sessions[0].messages, ["edited\nworld"]);
conn.execute("DELETE FROM session WHERE id = 'ses_main'", [])
.unwrap();
assert!(run().sessions.is_empty());
drop(conn);
fs::remove_file(path).unwrap();
assert!(run().sessions.is_empty());
}
#[test]
fn codex_title_sidecar_updates_without_reparsing() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("sessions");
fs::create_dir(&root).unwrap();
fs::write(
root.join("x.jsonl"),
r#"{"type":"session_meta","payload":{"id":"x","cwd":"/work"}}"#,
)
.unwrap();
let sources = Sources {
roots: vec![(Agent::Codex, root)],
codex_home: tmp.path().into(),
cache: tmp.path().join("cache/index.json"),
};
refresh(&sources, false, false, |_| {}).unwrap();
fs::write(
tmp.path().join("session_index.jsonl"),
"{\"id\":\"x\",\"thread_name\":\"Renamed\"}\n",
)
.unwrap();
let report = refresh(&sources, false, false, |_| {}).unwrap();
assert_eq!(report.reused, 1);
assert_eq!(report.sessions[0].title, "Renamed");
}
}

162
src/main.rs Normal file
View file

@ -0,0 +1,162 @@
mod index;
mod opencode;
mod parse;
mod search;
mod session;
mod ui;
use anyhow::{Context, Result, bail};
use clap::Parser;
use serde_json::json;
use session::{Agent, canonical, compact};
use std::{
io::{self, Write},
path::PathBuf,
};
#[derive(Parser)]
#[command(
version,
about = "Find and resume Pi, Claude Code, Codex, OpenCode, and OMP sessions.",
after_help = "Keys: type to search · Enter resume · Tab here/all · Shift-Tab agent\n Ctrl-L locations · ↑/↓ or Ctrl-P/N move · Ctrl-R refresh · Esc clear/quit\n\nSearch: fuzzy titles first, then case-insensitive words in human messages.\nThe default scope is exactly the current directory (symlinks resolved)."
)]
struct Args {
/// Search across all working directories
#[arg(short, long)]
all: bool,
/// Only show one agent
#[arg(long, value_enum)]
agent: Option<Agent>,
/// Start in this location rather than the current directory
#[arg(short = 'C', long)]
dir: Option<PathBuf>,
/// Initial search text
query: Vec<String>,
/// List matches without opening the TUI
#[arg(long)]
list: bool,
/// Output matches as JSON (implies --list)
#[arg(long)]
json: bool,
/// Maximum matches in --list/--json output
#[arg(long, default_value_t = 30)]
limit: usize,
/// Print the selected session's shell command instead of running it
#[arg(long, conflicts_with_all = ["list", "json"])]
print: bool,
/// Rebuild the local cache from source session files
#[arg(long)]
rebuild: bool,
/// Do not read or write the on-disk cache
#[arg(long)]
no_cache: bool,
/// Additional Pi session root (repeatable)
#[arg(long)]
pi_dir: Vec<PathBuf>,
/// Additional Claude projects root (repeatable)
#[arg(long)]
claude_dir: Vec<PathBuf>,
/// Additional Codex sessions root (repeatable)
#[arg(long)]
codex_dir: Vec<PathBuf>,
/// Additional Oh My Pi session root (repeatable)
#[arg(long)]
omp_dir: Vec<PathBuf>,
/// Additional OpenCode SQLite database file (repeatable)
#[arg(long)]
opencode_db: Vec<PathBuf>,
}
fn main() {
if let Err(error) = run() {
if error
.downcast_ref::<io::Error>()
.is_some_and(|e| e.kind() == io::ErrorKind::BrokenPipe)
{
return;
}
eprintln!("cont: {error:#}");
std::process::exit(1);
}
}
fn run() -> Result<()> {
let args = Args::parse();
let folder = canonical(
&args
.dir
.unwrap_or(std::env::current_dir().context("Cannot read current directory")?),
);
if !folder.is_dir() {
bail!("Not a directory: {}", folder.display());
}
let query = args.query.join(" ");
let sources = index::Sources::discover(&[
(Agent::Pi, args.pi_dir),
(Agent::Claude, args.claude_dir),
(Agent::Codex, args.codex_dir),
(Agent::Omp, args.omp_dir),
(Agent::Opencode, args.opencode_db),
])?;
if args.list || args.json {
let report = index::refresh(&sources, args.rebuild, args.no_cache, |_| {})?;
for warning in &report.warnings {
eprintln!("cont: {warning}");
}
let index = search::SearchIndex::new(report.sessions);
let hits = index.search(
&query,
args.agent,
if args.all { None } else { Some(&folder) },
);
let mut out = io::BufWriter::new(io::stdout().lock());
if args.json {
let rows: Vec<_> = hits.iter().take(args.limit).map(|h| {
let s = &index.sessions[h.index];
json!({ "agent": s.agent, "id": s.id, "title": s.title, "cwd": s.cwd, "path": s.path, "modified": s.modified, "messages": s.messages.len(), "match": match h.kind { search::MatchKind::Recent => "recent", search::MatchKind::Title => "title", search::MatchKind::Message => "message" }, "excerpt": h.message.map(|i| search::snippet(&s.messages[i], &query, 300)), "command": s.shell_command() })
}).collect();
serde_json::to_writer_pretty(&mut out, &rows)?;
writeln!(out)?;
} else {
for hit in hits.iter().take(args.limit) {
let s = &index.sessions[hit.index];
writeln!(
out,
"{:<8} {:<65} {}{}",
s.agent.name(),
compact(&s.title, 63),
compact(&s.cwd.to_string_lossy(), 300),
if hit.kind == search::MatchKind::Message {
" [text]"
} else {
""
}
)?;
}
eprintln!(
"{} matches · {} refreshed · {} cached",
hits.len(),
report.parsed,
report.reused
);
}
out.flush()?;
} else if let Some(session) = ui::run(
sources,
ui::Options {
folder,
all: args.all,
agent: args.agent,
query,
rebuild: args.rebuild,
no_cache: args.no_cache,
},
)? {
if args.print {
println!("{}", session.shell_command());
} else {
session.resume()?;
}
}
Ok(())
}

210
src/opencode.rs Normal file
View file

@ -0,0 +1,210 @@
//! Read OpenCode's existing SQLite store. No migrations, CLI subprocesses, or writes.
use std::{collections::HashMap, path::Path, time::Duration};
use anyhow::{Context, Result};
use rusqlite::{Connection, OpenFlags};
use serde::Deserialize;
use crate::{
parse::push_human,
session::{Agent, Session, compact},
};
pub fn read_database(path: &Path) -> Result<Vec<Session>> {
let mut connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)
.with_context(|| format!("OpenCode database {}", path.display()))?;
connection.busy_timeout(Duration::from_millis(500))?;
// A single snapshot prevents mixed metadata/messages during a live agent write.
// Do not use immutable=1: committed messages may still be in the WAL.
let transaction = connection.transaction()?;
let mut sessions = Vec::new();
let mut positions = HashMap::new();
{
let mut query = transaction.prepare(
"SELECT id, directory, title, time_updated FROM session
WHERE parent_id IS NULL AND time_archived IS NULL ORDER BY time_updated DESC, id",
)?;
let rows = query.query_map([], |row| {
Ok(Session {
agent: Agent::Opencode,
id: row.get(0)?,
path: path.to_path_buf(),
cwd: row.get::<_, String>(1)?.into(),
title: compact(&row.get::<_, String>(2)?, 200),
modified: row.get::<_, i64>(3)?.max(0) as u64 / 1000,
messages: vec![],
})
})?;
for row in rows {
let session = row?;
if session.id.is_empty() || !session.cwd.is_absolute() {
continue;
}
positions.insert(session.id.clone(), sessions.len());
sessions.push(session);
}
}
{
let mut query = transaction.prepare(
"SELECT m.session_id, m.id, p.data FROM message m
JOIN part p ON p.message_id = m.id AND p.session_id = m.session_id
JOIN session s ON s.id = m.session_id
WHERE s.parent_id IS NULL AND s.time_archived IS NULL
AND json_valid(m.data) AND json_extract(m.data, '$.role') = 'user'
AND json_valid(p.data) AND json_extract(p.data, '$.type') = 'text'
ORDER BY m.session_id, m.time_created, m.id, p.time_created, p.id",
)?;
let mut rows = query.query([])?;
let mut previous = None;
let mut text = String::new();
while let Some(row) = rows.next()? {
let session_id: String = row.get(0)?;
let Some(&index) = positions.get(&session_id) else {
continue;
};
let message_id: String = row.get(1)?;
let key = (index, message_id);
if previous.as_ref() != Some(&key) {
if let Some((i, _)) = previous.take() {
push_human(&mut sessions[i].messages, &text);
}
text.clear();
previous = Some(key);
}
let data: String = row.get(2)?;
let Ok(part) = serde_json::from_str::<TextPart>(&data) else {
continue;
};
if !part.synthetic
&& !part.ignored
&& let Some(value) = part.text
{
if !text.is_empty() {
text.push('\n');
}
text.push_str(&value);
}
}
if let Some((i, _)) = previous {
push_human(&mut sessions[i].messages, &text);
}
}
for session in &mut sessions {
if session.title.is_empty() {
session.title = session
.messages
.first()
.map(|s| compact(s, 160))
.unwrap_or_else(|| "Untitled session".into());
}
}
transaction.commit()?;
Ok(sessions)
}
#[derive(Deserialize)]
struct TextPart {
text: Option<String>,
#[serde(default)]
synthetic: bool,
#[serde(default)]
ignored: bool,
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use serde_json::json;
pub fn database(path: &Path) -> Connection {
let conn = Connection::open(path).unwrap();
conn.execute_batch("PRAGMA journal_mode=WAL;
CREATE TABLE session (id TEXT PRIMARY KEY, directory TEXT, title TEXT, time_updated INTEGER, parent_id TEXT, time_archived INTEGER);
CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, time_created INTEGER, data TEXT);
CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT, session_id TEXT, time_created INTEGER, data TEXT);
INSERT INTO session VALUES ('ses_main', '/work', 'Database task', 10000, NULL, NULL);
INSERT INTO session VALUES ('ses_child', '/work', 'Child', 20000, 'ses_main', NULL);
INSERT INTO session VALUES ('ses_archived', '/work', 'Archived', 30000, NULL, 30000);
INSERT INTO message VALUES ('m1', 'ses_main', 1, '{\"role\":\"user\"}');
INSERT INTO message VALUES ('m2', 'ses_main', 2, '{\"role\":\"assistant\"}');
INSERT INTO message VALUES ('m3', 'ses_child', 1, '{\"role\":\"user\"}');
").unwrap();
for (id, message, session, data) in [
(
"p1",
"m1",
"ses_main",
json!({"type":"text", "text":"hello"}),
),
(
"p2",
"m1",
"ses_main",
json!({"type":"text", "text":"world"}),
),
(
"p3",
"m1",
"ses_main",
json!({"type":"text", "text":"generated", "synthetic":true}),
),
(
"p4",
"m1",
"ses_main",
json!({"type":"text", "text":"ignored", "ignored":true}),
),
(
"p5",
"m1",
"ses_main",
json!({"type":"tool", "text":"tool output"}),
),
(
"p6",
"m2",
"ses_main",
json!({"type":"text", "text":"assistant secret"}),
),
(
"p7",
"m3",
"ses_child",
json!({"type":"text", "text":"subagent"}),
),
] {
conn.execute(
"INSERT INTO part VALUES (?1, ?2, ?3, 1, ?4)",
[id, message, session, &data.to_string()],
)
.unwrap();
}
conn
}
#[test]
fn reads_wal_human_parts_only_without_modifying_database() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("opencode.db");
let conn = database(&path); // Keep connection open: inserts live in WAL.
let before = std::fs::read(&path).unwrap();
let sessions = read_database(&path).unwrap();
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].modified, 10);
assert_eq!(sessions[0].messages, ["hello\nworld"]);
assert_eq!(std::fs::read(&path).unwrap(), before);
assert_eq!(
conn.query_row("SELECT COUNT(*) FROM session", [], |r| r.get::<_, i64>(0))
.unwrap(),
3
);
}
#[test]
fn missing_database_is_not_created() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("missing.db");
assert!(read_database(&path).is_err());
assert!(!path.exists());
}
}

416
src/parse.rs Normal file
View file

@ -0,0 +1,416 @@
//! Only human text and session metadata enter the cache. Large tool/image/assistant
//! payloads are skipped by serde rather than materialized as JSON trees.
use std::{
collections::{HashMap, HashSet},
fs::File,
io::{self, BufRead, BufReader},
path::Path,
};
use serde::Deserialize;
use serde_json::{Value, value::RawValue};
use crate::session::{Agent, Session, compact};
#[derive(Default, Deserialize)]
#[serde(default, rename_all = "camelCase")]
struct Entry<'a> {
#[serde(rename = "type")]
kind: &'a str,
id: Option<String>,
uuid: Option<String>,
session_id: Option<String>,
cwd: Option<String>,
name: Option<String>,
title: Option<String>,
attribution: Option<String>,
#[serde(borrow)]
content: Option<&'a RawValue>,
custom_title: Option<String>,
ai_title: Option<String>,
summary: Option<String>,
is_meta: bool,
is_sidechain: bool,
#[serde(borrow)]
message: Option<&'a RawValue>,
#[serde(borrow)]
payload: Option<&'a RawValue>,
#[serde(borrow)]
origin: Option<&'a RawValue>,
}
#[derive(Default, Deserialize)]
#[serde(default)]
struct Payload<'a> {
#[serde(rename = "type")]
kind: &'a str,
role: &'a str,
attribution: Option<String>,
id: Option<String>,
session_id: Option<String>,
cwd: Option<String>,
thread_source: &'a str,
#[serde(borrow)]
source: Option<&'a RawValue>,
#[serde(borrow)]
content: Option<&'a RawValue>,
message: Option<String>,
}
pub fn parse_file(agent: Agent, path: &Path, modified: u64) -> io::Result<Option<Session>> {
parse(
agent,
path,
modified,
BufReader::with_capacity(128 * 1024, File::open(path)?),
)
}
fn parse(
agent: Agent,
path: &Path,
modified: u64,
mut reader: impl BufRead,
) -> io::Result<Option<Session>> {
let mut session = Session {
agent,
id: String::new(),
path: path.to_path_buf(),
cwd: Default::default(),
title: String::new(),
modified,
messages: vec![],
};
let mut seen = HashSet::new();
let mut events = vec![];
let mut custom_title = None;
let mut title_slot = None;
let mut ai_title = None;
let mut summary = None;
let mut line = String::new();
loop {
line.clear();
match reader.read_line(&mut line) {
Ok(0) => break,
Ok(_) => {}
Err(e) if e.kind() == io::ErrorKind::InvalidData => continue,
Err(e) => return Err(e),
}
// A live writer may leave a partial final record. It is retried next refresh.
let Ok(e) = serde_json::from_str::<Entry>(&line) else {
continue;
};
match agent {
Agent::Opencode => return Ok(None), // SQLite adapter; never JSONL.
Agent::Pi | Agent::Omp => match e.kind {
"session" if session.id.is_empty() => {
session.id = e.id.unwrap_or_default();
session.cwd = e.cwd.unwrap_or_default().into();
if agent == Agent::Omp {
custom_title = e.title;
}
}
"title" if agent == Agent::Omp => {
title_slot = e.title.filter(|t| !t.trim().is_empty())
}
"title_change" if agent == Agent::Omp => custom_title = e.title,
"custom_message"
if agent == Agent::Omp && e.attribution.as_deref() == Some("user") =>
{
push_text(&mut session.messages, e.content);
}
"session_info" => custom_title = e.name,
"message" => {
if let Some(raw) = e.message
&& let Ok(p) = serde_json::from_str::<Payload>(raw.get())
&& p.role == "user"
&& p.attribution.as_deref() != Some("agent")
{
push_text(&mut session.messages, p.content);
}
}
_ => {}
},
Agent::Claude => {
if e.is_sidechain {
continue;
}
if session.cwd.as_os_str().is_empty() {
session.cwd = e.cwd.unwrap_or_default().into();
}
if session.id.is_empty() {
session.id = e.session_id.unwrap_or_default();
}
match e.kind {
"custom-title" => custom_title = e.custom_title,
"ai-title" => ai_title = e.ai_title,
"summary" => summary = e.summary,
"user" if !e.is_meta => {
let nonhuman = e
.origin
.and_then(|r| serde_json::from_str::<Value>(r.get()).ok())
.and_then(|v| v["kind"].as_str().map(|k| k != "human"))
.unwrap_or(false);
if nonhuman || e.uuid.is_some_and(|id| !seen.insert(id)) {
continue;
}
if let Some(raw) = e.message
&& let Ok(p) = serde_json::from_str::<Payload>(raw.get())
&& p.role == "user"
{
push_text(&mut session.messages, p.content);
}
}
_ => {}
}
}
Agent::Codex => {
if !matches!(e.kind, "session_meta" | "response_item" | "event_msg") {
continue;
}
let Some(raw) = e.payload else { continue };
let Ok(p) = serde_json::from_str::<Payload>(raw.get()) else {
continue;
};
match e.kind {
"session_meta" if session.id.is_empty() => {
// Fork logs can contain their parent's header later: first header wins.
let source = p
.source
.and_then(|r| serde_json::from_str::<Value>(r.get()).ok());
if p.thread_source == "subagent"
|| source.as_ref().is_some_and(|v| {
v.get("subagent").is_some() || v.as_str() == Some("subagent")
})
{
return Ok(None);
}
session.id = p.id.or(p.session_id).unwrap_or_default();
session.cwd = p.cwd.unwrap_or_default().into();
}
"event_msg" if p.kind == "user_message" => {
if let Some(text) = p.message {
push_human(&mut events, &text);
}
}
"response_item" if p.kind == "message" && p.role == "user" => {
if p.id.is_some_and(|id| !seen.insert(id)) {
continue;
}
push_text(&mut session.messages, p.content);
}
_ => {}
}
}
}
}
// Older Codex records input twice. Keep events, plus response-only prompts
// from newer turns in logs that have been resumed across CLI versions.
if !events.is_empty() {
let mut mirrored = HashMap::<String, usize>::new();
for text in &events {
*mirrored.entry(text.clone()).or_default() += 1;
}
for text in session.messages {
let remaining = mirrored.entry(text.clone()).or_default();
if *remaining > 0 {
*remaining -= 1;
} else {
events.push(text);
}
}
session.messages = events;
}
session.title = title_slot
.or(custom_title)
.or(ai_title)
.or(summary)
.filter(|s| !s.trim().is_empty())
.map(|s| compact(&s, 200))
.or_else(|| session.messages.first().map(|s| compact(s, 160)))
.unwrap_or_else(|| "Untitled session".into());
if session.id.is_empty() || !session.cwd.is_absolute() {
return Ok(None);
}
Ok(Some(session))
}
#[derive(Deserialize)]
struct TextBlock {
#[serde(rename = "type")]
kind: String,
text: Option<String>,
}
fn push_text(messages: &mut Vec<String>, raw: Option<&RawValue>) {
let Some(raw) = raw else { return };
if raw.get().starts_with('"') {
if let Ok(text) = serde_json::from_str::<String>(raw.get()) {
push_human(messages, &text);
}
} else if let Ok(blocks) = serde_json::from_str::<Vec<TextBlock>>(raw.get()) {
// Ignore tool_result.content and image.data without allocating them.
let text = blocks
.iter()
.filter(|b| matches!(b.kind.as_str(), "text" | "input_text"))
.filter_map(|b| b.text.as_deref())
.filter(|s| is_human(s))
.collect::<Vec<_>>()
.join("\n");
push_human(messages, &text);
}
}
pub(crate) fn push_human(messages: &mut Vec<String>, text: &str) {
if is_human(text) {
// Terminal escape/control sequences from stored content must never be emitted.
messages.push(
text.trim()
.chars()
.filter(|c| !c.is_control() || matches!(c, '\n' | '\t'))
.collect(),
);
}
}
fn is_human(text: &str) -> bool {
let text = text.trim();
!text.is_empty()
&& ![
"# AGENTS.md instructions",
"<environment_context>",
"<environment>",
"<permissions instructions>",
"<task-notification>",
"<subagent_notification>",
"<local-command-caveat>",
"<local-command-stdout>",
"<command-name>",
"[Request interrupted by user",
"This session is being continued from a previous conversation",
"<system-reminder>",
"<turn_aborted>",
]
.iter()
.any(|prefix| text.starts_with(prefix))
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture(agent: Agent, input: &str) -> Session {
parse(
agent,
Path::new("/tmp/session.jsonl"),
42,
io::Cursor::new(input),
)
.unwrap()
.unwrap()
}
#[test]
fn pi_latest_title_and_only_human_text() {
let s = fixture(
Agent::Pi,
r#"{"type":"session","id":"p","cwd":"/work"}
{"type":"session_info","name":"Old"}
{"type":"message","message":{"role":"user","content":[{"type":"text","text":"fix auth"},{"type":"image","data":"secret"}]}}
{"type":"message","message":{"role":"toolResult","content":"not searchable"}}
{"type":"message","message":{"role":"assistant","content":"not searchable"}}
{"type":"session_info","name":"New"}
not json
{"type":"message","message":
"#,
);
assert_eq!(s.title, "New");
assert_eq!(s.messages, ["fix auth"]);
}
#[test]
fn omp_title_slot_header_and_human_attribution() {
let s = fixture(
Agent::Omp,
r#"{"type":"title","title":"Current title"}
{"type":"session","id":"omp-id","cwd":"/work","title":"Legacy title"}
{"type":"message","message":{"role":"user","attribution":"user","content":[{"type":"text","text":"real request"}]}}
{"type":"message","message":{"role":"user","attribution":"agent","content":"injected request"}}
{"type":"custom_message","attribution":"user","content":"user skill request"}
{"type":"title_change","title":"Old title event"}
"#,
);
assert_eq!(s.agent, Agent::Omp);
assert_eq!(s.title, "Current title");
assert_eq!(s.messages, ["real request", "user skill request"]);
let s = fixture(
Agent::Omp,
r#"{"type":"session","id":"old","cwd":"/work","title":"Legacy title"}"#,
);
assert_eq!(s.title, "Legacy title");
let s = fixture(
Agent::Omp,
r#"{"type":"title","title":""}
{"type":"session","id":"old","cwd":"/work"}
{"type":"title_change","title":"Renamed"}
"#,
);
assert_eq!(s.title, "Renamed");
}
#[test]
fn claude_titles_dedup_and_generated_records() {
let s = fixture(
Agent::Claude,
r#"{"type":"user","uuid":"1","sessionId":"c","cwd":"/work","message":{"role":"user","content":"hello"}}
{"type":"user","uuid":"1","message":{"role":"user","content":"hello"}}
{"type":"user","isMeta":true,"message":{"role":"user","content":"injected"}}
{"type":"user","origin":{"kind":"agent"},"message":{"role":"user","content":"injected"}}
{"type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"tool output"}]}}
{"type":"user","message":{"role":"user","content":"<task-notification>background"}}
{"type":"custom-title","customTitle":"Custom title"}
{"type":"ai-title","aiTitle":"Generated title"}
"#,
);
assert_eq!(s.title, "Custom title");
assert_eq!(s.messages, ["hello"]);
}
#[test]
fn codex_events_and_responses_are_not_double_counted() {
let s = fixture(
Agent::Codex,
r##"{"type":"session_meta","payload":{"id":"x","cwd":"/work","source":"cli"}}
{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"# AGENTS.md instructions for /work"}]}}
{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"real prompt"}]}}
{"type":"event_msg","payload":{"type":"user_message","message":"real prompt"}}
{"type":"session_meta","payload":{"id":"parent","cwd":"/wrong"}}
"##,
);
assert_eq!(s.id, "x");
assert_eq!(s.cwd, Path::new("/work"));
assert_eq!(s.messages, ["real prompt"]);
}
#[test]
fn codex_mixed_versions_keep_new_response_only_prompts() {
let s = fixture(
Agent::Codex,
r#"{"type":"session_meta","payload":{"id":"x","cwd":"/work"}}
{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"old prompt"}]}}
{"type":"event_msg","payload":{"type":"user_message","message":"old prompt"}}
{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"new prompt"}]}}
"#,
);
assert_eq!(s.messages, ["old prompt", "new prompt"]);
}
#[test]
fn modern_codex_response_fallback() {
let s = fixture(
Agent::Codex,
r#"{"type":"session_meta","payload":{"id":"x","cwd":"/work"}}
{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"<environment_context>injected"},{"type":"input_text","text":"日本語 request"}]}}
"#,
);
assert_eq!(s.messages, ["日本語 request"]);
}
#[test]
fn excludes_codex_subagents() {
let s = parse(Agent::Codex, Path::new("x"), 0, io::Cursor::new(r#"{"type":"session_meta","payload":{"id":"child","cwd":"/work","source":{"subagent":{}}}}"#)).unwrap();
assert!(s.is_none());
}
}

201
src/search.rs Normal file
View file

@ -0,0 +1,201 @@
use std::{cmp::Reverse, path::Path};
use crate::session::{Agent, Session};
use nucleo_matcher::{
Config, Matcher, Utf32Str,
pattern::{CaseMatching, Normalization, Pattern},
};
pub struct SearchIndex {
pub sessions: Vec<Session>,
text: Vec<SearchText>,
}
struct SearchText {
title: String,
messages: Vec<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MatchKind {
Recent,
Title,
Message,
}
#[derive(Clone, Debug)]
pub struct Hit {
pub index: usize,
pub kind: MatchKind,
pub message: Option<usize>,
score: u32,
tier: u8,
}
impl SearchIndex {
pub fn new(sessions: Vec<Session>) -> Self {
let text = sessions
.iter()
.map(|s| SearchText {
title: s.title.to_lowercase(),
messages: s.messages.iter().map(|s| s.to_lowercase()).collect(),
})
.collect();
Self { sessions, text }
}
pub fn search(&self, query: &str, agent: Option<Agent>, cwd: Option<&Path>) -> Vec<Hit> {
let query = query.trim().to_lowercase();
let words: Vec<_> = query.split_whitespace().collect();
let pattern = Pattern::new(
&query,
CaseMatching::Ignore,
Normalization::Smart,
nucleo_matcher::pattern::AtomKind::Fuzzy,
);
let mut matcher = Matcher::new(Config::DEFAULT);
let mut buffer = vec![];
let mut hits = vec![];
for (index, (session, text)) in self.sessions.iter().zip(&self.text).enumerate() {
if agent.is_some_and(|a| a != session.agent) || cwd.is_some_and(|p| p != session.cwd) {
continue;
}
let mut hit = Hit {
index,
kind: MatchKind::Recent,
message: None,
score: 0,
tier: 0,
};
if !query.is_empty() {
if text.title.contains(&query) {
hit.kind = MatchKind::Title;
hit.tier = 4;
hit.score = if text.title == query {
2
} else if text.title.starts_with(&query) {
1
} else {
0
};
} else if words.iter().all(|w| text.title.contains(w)) {
hit.kind = MatchKind::Title;
hit.tier = 3;
} else if let Some(score) =
pattern.score(Utf32Str::new(&session.title, &mut buffer), &mut matcher)
{
hit.kind = MatchKind::Title;
hit.tier = 2;
hit.score = score;
} else {
let mut found = None;
for (i, message) in text.messages.iter().enumerate() {
if message.contains(&query) {
found = Some((i, 1));
break;
}
if found.is_none() && words.iter().all(|w| message.contains(w)) {
found = Some((i, 0));
}
}
let Some((i, score)) = found else { continue };
hit.kind = MatchKind::Message;
hit.tier = 1;
hit.score = score;
hit.message = Some(i);
}
}
hits.push(hit);
}
hits.sort_by_key(|h| {
(
Reverse(h.tier),
Reverse(h.score),
Reverse(self.sessions[h.index].modified),
h.index,
)
});
hits
}
}
/// Context around the matching portion, not just the start of a long prompt.
pub fn snippet(text: &str, query: &str, max_chars: usize) -> String {
let lower = text.to_lowercase();
let query = query.to_lowercase();
let pos = query
.split_whitespace()
.filter_map(|w| lower.find(w))
.min()
.unwrap_or(0);
// Lowercasing can change byte lengths. Convert to a character offset first.
let char_pos = lower[..pos].chars().count();
let start = char_pos.saturating_sub(70);
let mut output = if start > 0 {
"".to_owned()
} else {
String::new()
};
output.extend(text.chars().skip(start).take(max_chars));
if text.chars().count() > start + max_chars {
output.push('…');
}
output
}
#[cfg(test)]
mod tests {
use super::*;
fn session(title: &str, message: &str, modified: u64) -> Session {
Session {
agent: Agent::Pi,
id: title.into(),
path: "/x".into(),
cwd: "/work".into(),
title: title.into(),
modified,
messages: vec![message.into()],
}
}
#[test]
fn title_hits_always_beat_body_hits_and_recency_breaks_ties() {
let index = SearchIndex::new(vec![
session("Other", "authentication failure", 999),
session("Authentication", "", 1),
session("Auth guide", "", 2),
]);
let hits = index.search("auth", None, None);
assert_eq!(hits.iter().map(|h| h.index).collect::<Vec<_>>(), [2, 1, 0]);
assert_eq!(hits[2].kind, MatchKind::Message);
}
#[test]
fn fuzzy_titles_and_unordered_message_tokens() {
let index = SearchIndex::new(vec![
session("Authentication refactor", "", 1),
session("Other", "cache invalidation fix", 2),
]);
assert_eq!(index.search("athref", None, None)[0].index, 0);
let hits = index.search("fix cache", None, None);
assert_eq!(hits[0].index, 1);
assert_eq!(hits[0].message, Some(0));
assert!(index.search("no match at all", None, None).is_empty());
}
#[test]
fn scope_is_exact_and_filters_compose() {
let index = SearchIndex::new(vec![session("title", "text", 1)]);
assert!(index.search("", Some(Agent::Claude), None).is_empty());
assert!(index.search("", None, Some(Path::new("/wor"))).is_empty());
assert_eq!(
index
.search("", Some(Agent::Pi), Some(Path::new("/work")))
.len(),
1
);
}
#[test]
fn unicode_and_match_context() {
let index = SearchIndex::new(vec![session("École 日本語", "the café is open", 1)]);
assert_eq!(index.search("日本", None, None).len(), 1);
assert_eq!(index.search("CAFÉ", None, None).len(), 1);
let text = format!("{}needle", "x".repeat(1000));
assert!(snippet(&text, "needle", 100).contains("needle"));
}
}

206
src/session.rs Normal file
View file

@ -0,0 +1,206 @@
use std::{
ffi::OsString,
path::{Path, PathBuf},
process::Command,
};
use anyhow::{Context, Result, bail};
use clap::ValueEnum;
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, Hash, ValueEnum)]
#[serde(rename_all = "lowercase")]
pub enum Agent {
Pi,
Claude,
Codex,
Opencode,
Omp,
}
impl Agent {
pub const ALL: [Self; 5] = [
Self::Pi,
Self::Claude,
Self::Codex,
Self::Opencode,
Self::Omp,
];
pub fn next_filter(current: Option<Self>) -> Option<Self> {
match current {
None => Some(Self::ALL[0]),
Some(agent) => Self::ALL
.iter()
.position(|a| *a == agent)
.and_then(|i| Self::ALL.get(i + 1))
.copied(),
}
}
pub fn name(self) -> &'static str {
match self {
Self::Pi => "pi",
Self::Claude => "claude",
Self::Codex => "codex",
Self::Opencode => "opencode",
Self::Omp => "omp",
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Session {
pub agent: Agent,
pub id: String,
pub path: PathBuf,
pub cwd: PathBuf,
pub title: String,
pub modified: u64,
pub messages: Vec<String>,
}
impl Session {
pub fn args(&self) -> Vec<OsString> {
match self.agent {
Agent::Pi => vec!["--session".into(), self.path.as_os_str().into()],
Agent::Claude => vec!["--resume".into(), self.id.clone().into()],
Agent::Codex => vec!["resume".into(), self.id.clone().into()],
Agent::Opencode => vec!["--session".into(), self.id.clone().into()],
Agent::Omp => vec!["--resume".into(), self.path.as_os_str().into()],
}
}
pub fn shell_command(&self) -> String {
let args = self
.args()
.iter()
.map(|a| quote(&a.to_string_lossy()))
.collect::<Vec<_>>()
.join(" ");
format!(
"cd -- {} && {}{} {}",
quote(&self.cwd.to_string_lossy()),
if self.agent == Agent::Opencode {
format!("OPENCODE_DB={} ", quote(&self.path.to_string_lossy()))
} else {
String::new()
},
self.agent.name(),
args
)
}
pub fn resume(&self) -> Result<()> {
if !self.cwd.is_dir() {
bail!("Session directory no longer exists: {}", self.cwd.display());
}
if !self.path.is_file() {
bail!("Session file no longer exists: {}", self.path.display());
}
let mut command = Command::new(self.agent.name());
command.args(self.args()).current_dir(&self.cwd);
if self.agent == Agent::Opencode {
// Explicitly select the discovered database, including non-default/channel stores.
command.env("OPENCODE_DB", &self.path);
}
// Do not pass any flags that bypass the agent's permission/trust checks.
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
Err(command.exec())
.with_context(|| format!("Could not start {}; is it on PATH?", self.agent.name()))
}
#[cfg(not(unix))]
{
let status = command.status().context("Could not start agent")?;
std::process::exit(status.code().unwrap_or(1));
}
}
}
fn quote(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}
pub fn canonical(path: &Path) -> PathBuf {
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
}
pub fn compact(s: &str, limit: usize) -> String {
let text = s.split_whitespace().collect::<Vec<_>>().join(" ");
let mut chars = text.chars();
let mut result: String = chars
.by_ref()
.take(limit)
.filter(|c| !c.is_control())
.collect();
if chars.next().is_some() {
result.push('…');
}
result
}
#[cfg(test)]
mod tests {
use super::*;
pub fn fixture(agent: Agent) -> Session {
Session {
agent,
id: "uuid".into(),
path: "/tmp/a b.jsonl".into(),
cwd: "/tmp/project's dir".into(),
title: "Example".into(),
modified: 1,
messages: vec![],
}
}
#[test]
fn commands_preserve_arguments_and_quote_paths() {
let mut s = fixture(Agent::Pi);
assert_eq!(
s.args(),
vec![
OsString::from("--session"),
OsString::from("/tmp/a b.jsonl")
]
);
assert!(s.shell_command().contains("project'\\''s dir"));
s.agent = Agent::Claude;
assert_eq!(
s.args(),
vec![OsString::from("--resume"), OsString::from("uuid")]
);
s.agent = Agent::Codex;
assert_eq!(
s.args(),
vec![OsString::from("resume"), OsString::from("uuid")]
);
}
#[test]
fn new_agent_commands_and_filter_cycle() {
let s = fixture(Agent::Omp);
assert_eq!(
s.args(),
[OsString::from("--resume"), OsString::from("/tmp/a b.jsonl")]
);
let s = fixture(Agent::Opencode);
assert_eq!(
s.args(),
[OsString::from("--session"), OsString::from("uuid")]
);
assert!(
s.shell_command()
.contains("OPENCODE_DB='/tmp/a b.jsonl' opencode '--session' 'uuid'")
);
let mut selected = None;
for agent in Agent::ALL {
selected = Agent::next_filter(selected);
assert_eq!(selected, Some(agent));
}
assert_eq!(Agent::next_filter(selected), None);
}
#[test]
fn compact_is_unicode_safe() {
assert_eq!(compact(" 日本語\n test", 3), "日本語…");
}
}

738
src/ui.rs Normal file
View file

@ -0,0 +1,738 @@
use std::{
collections::BTreeMap,
io::{self, IsTerminal},
path::PathBuf,
sync::mpsc::{self, Receiver},
thread,
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
use anyhow::{Result, bail};
use crossterm::{
cursor::Show,
event::{
self, DisableBracketedPaste, EnableBracketedPaste, Event, KeyCode, KeyEvent, KeyEventKind,
KeyModifiers,
},
execute,
terminal::{self, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
prelude::*,
widgets::{
Block, Borders, Cell, Clear, List, ListItem, ListState, Paragraph, Row, Table, TableState,
Wrap,
},
};
use crate::{
index::{self, Report, Sources, Update},
search::{Hit, MatchKind, SearchIndex, snippet},
session::{Agent, Session, compact},
};
#[cfg(test)]
mod demo;
const ACCENT: Color = Color::Cyan;
const DIM: Color = Color::DarkGray;
struct TerminalGuard;
impl TerminalGuard {
fn enter() -> Result<Self> {
terminal::enable_raw_mode()?;
let guard = Self;
execute!(io::stdout(), EnterAlternateScreen, EnableBracketedPaste)?;
Ok(guard)
}
}
impl Drop for TerminalGuard {
fn drop(&mut self) {
restore();
}
}
fn restore() {
let _ = terminal::disable_raw_mode();
let _ = execute!(
io::stdout(),
DisableBracketedPaste,
LeaveAlternateScreen,
Show
);
}
enum WorkerUpdate {
Index(Update),
Done(Result<Report>),
}
fn load(sources: Sources, rebuild: bool, no_cache: bool) -> Receiver<WorkerUpdate> {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let report = index::refresh(&sources, rebuild, no_cache, |u| {
let _ = tx.send(WorkerUpdate::Index(u));
});
let _ = tx.send(WorkerUpdate::Done(report));
});
rx
}
struct Locations {
query: String,
selected: usize,
state: ListState,
}
struct App {
index: SearchIndex,
query: String,
agent: Option<Agent>,
folder: PathBuf,
all: bool,
hits: Vec<Hit>,
selected: usize,
table: TableState,
locations: Option<Locations>,
status: String,
loading: bool,
elapsed: Duration,
}
impl App {
fn search(&mut self, preserve: bool) {
let previous = if preserve {
self.hits.get(self.selected).map(|h| {
(
self.index.sessions[h.index].agent,
self.index.sessions[h.index].id.clone(),
)
})
} else {
None
};
let start = Instant::now();
self.hits = self.index.search(
&self.query,
self.agent,
if self.all { None } else { Some(&self.folder) },
);
self.elapsed = start.elapsed();
self.selected = previous
.and_then(|(a, id)| {
self.hits.iter().position(|h| {
let s = &self.index.sessions[h.index];
s.agent == a && s.id == id
})
})
.unwrap_or(0);
self.table.select(if self.hits.is_empty() {
None
} else {
Some(self.selected)
});
}
fn replace(&mut self, sessions: Vec<Session>) {
let previous = self.hits.get(self.selected).map(|h| {
(
self.index.sessions[h.index].agent,
self.index.sessions[h.index].id.clone(),
)
});
self.index = SearchIndex::new(sessions);
self.search(false);
if let Some((a, id)) = previous
&& let Some(i) = self.hits.iter().position(|h| {
let s = &self.index.sessions[h.index];
s.agent == a && s.id == id
})
{
self.selected = i;
self.table.select(Some(i));
}
}
fn move_by(&mut self, amount: isize) {
self.selected = self
.selected
.saturating_add_signed(amount)
.min(self.hits.len().saturating_sub(1));
self.table.select(if self.hits.is_empty() {
None
} else {
Some(self.selected)
});
}
fn location_rows(&self) -> Vec<(PathBuf, usize)> {
let mut counts = BTreeMap::<PathBuf, usize>::new();
for session in &self.index.sessions {
if self.agent.is_none_or(|a| a == session.agent) {
*counts.entry(session.cwd.clone()).or_default() += 1;
}
}
let query = self
.locations
.as_ref()
.map(|l| l.query.to_lowercase())
.unwrap_or_default();
let mut rows: Vec<_> = counts
.into_iter()
.filter(|(p, _)| {
query
.split_whitespace()
.all(|w| p.to_string_lossy().to_lowercase().contains(w))
})
.collect();
rows.sort_by(|a, b| {
(a.0 != self.folder)
.cmp(&(b.0 != self.folder))
.then_with(|| b.1.cmp(&a.1))
.then_with(|| a.0.cmp(&b.0))
});
rows
}
fn draw(&mut self, frame: &mut Frame) {
let area = frame.area();
if area.width < 35 || area.height < 10 {
frame.render_widget(
Paragraph::new("Enlarge terminal (35×10 minimum)\nEsc / Ctrl-C to quit"),
area,
);
return;
}
let preview_height = if area.height >= 22 { 7 } else { 0 };
let chunks = Layout::vertical([
Constraint::Length(2),
Constraint::Length(3),
Constraint::Min(3),
Constraint::Length(preview_height),
Constraint::Length(2),
])
.split(area);
let mut header = vec![Span::styled(" cont ", Style::default().fg(ACCENT).bold())];
let filters: Vec<_> = if area.width < 80 {
vec![self.agent]
} else {
std::iter::once(None)
.chain(Agent::ALL.into_iter().map(Some))
.collect()
};
for agent in filters {
let count = self
.index
.sessions
.iter()
.filter(|s| {
agent.is_none_or(|a| a == s.agent) && (self.all || s.cwd == self.folder)
})
.count();
let label = format!(" {} {} ", agent.map_or("all", Agent::name), count);
header.push(Span::styled(
label,
if agent == self.agent {
Style::default().fg(Color::Black).bg(ACCENT).bold()
} else {
Style::default().fg(DIM)
},
));
header.push(Span::raw(" "));
}
frame.render_widget(
Paragraph::new(Line::from(header)).wrap(Wrap { trim: true }),
chunks[0],
);
let scope = if self.all {
"All locations".to_owned()
} else {
path_tail(&self.folder, chunks[1].width.saturating_sub(35) as usize)
};
let label = format!(
" {} · {} results · {:.1} ms ",
scope,
self.hits.len(),
self.elapsed.as_secs_f64() * 1000.0
);
// Show the tail of long queries so the insertion point remains visible.
let max = chunks[1].width.saturating_sub(6) as usize / 2;
let visible: String = self
.query
.chars()
.skip(self.query.chars().count().saturating_sub(max))
.collect();
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(" ", Style::default().fg(ACCENT)),
Span::raw(visible),
Span::styled("", Style::default().fg(ACCENT)),
]))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(DIM))
.title(label),
),
chunks[1],
);
let wide = area.width >= 90;
let location_width = (area.width.saturating_sub(25) as usize * 2 / 5).saturating_sub(2);
let rows: Vec<_> = self
.hits
.iter()
.map(|hit| {
let s = &self.index.sessions[hit.index];
let mut cells = vec![
Cell::from(s.agent.name()).style(Style::default().fg(agent_color(s.agent))),
Cell::from(s.title.as_str()),
];
if wide {
cells.push(
Cell::from(path_tail(&s.cwd, location_width))
.style(Style::default().fg(DIM)),
);
}
cells.push(Cell::from(age(s.modified)).style(Style::default().fg(DIM)));
cells.push(
Cell::from(if hit.kind == MatchKind::Message {
"text"
} else {
""
})
.style(Style::default().fg(ACCENT)),
);
Row::new(cells)
})
.collect();
let mut widths = vec![Constraint::Length(8), Constraint::Fill(3)];
let mut labels = vec!["Agent", "Session"];
if wide {
widths.push(Constraint::Fill(2));
labels.push("Location");
}
widths.extend([Constraint::Length(5), Constraint::Length(4)]);
labels.extend(["Age", "Hit"]);
let table = Table::new(rows, widths)
.header(
Row::new(labels)
.style(Style::default().fg(DIM))
.bottom_margin(1),
)
.row_highlight_style(Style::default().bg(Color::DarkGray).fg(Color::White).bold())
.highlight_symbol(" ")
.column_spacing(2);
frame.render_stateful_widget(table, chunks[2], &mut self.table);
if self.hits.is_empty() {
let text = if self.loading && self.index.sessions.is_empty() {
"Reading local session history…"
} else if !self.all {
"No matches here. Tab searches all locations."
} else {
"No matching sessions. Clear search or change agent with Shift-Tab."
};
frame.render_widget(
Paragraph::new(text).style(Style::default().fg(DIM)),
Rect {
y: chunks[2].y + 2,
height: chunks[2].height.saturating_sub(2),
..chunks[2]
},
);
}
if let Some(hit) = self.hits.get(self.selected) {
let s = &self.index.sessions[hit.index];
let message = hit
.message
.or_else(|| s.messages.len().checked_sub(1))
.and_then(|i| s.messages.get(i));
let preview = message
.map(|m| snippet(m, &self.query, 1800))
.unwrap_or_else(|| "No human messages recorded.".into());
let kind = if hit.kind == MatchKind::Message {
"Matching human message"
} else {
"Latest human message"
};
let lines = vec![
Line::styled(
format!(
"{} · {} · {} {}",
display_path(&s.cwd),
s.id,
s.messages.len(),
if s.messages.len() == 1 {
"message"
} else {
"messages"
}
),
Style::default().fg(DIM),
),
Line::raw(preview),
];
frame.render_widget(
Paragraph::new(lines).wrap(Wrap { trim: false }).block(
Block::default()
.borders(Borders::TOP)
.border_style(Style::default().fg(DIM))
.title(format!(" {kind} ")),
),
chunks[3],
);
}
frame.render_widget(Paragraph::new(vec![Line::styled(" Enter resume ↑↓ move Tab here/all Shift-Tab agent Ctrl-L locations Esc clear/quit", Style::default().fg(DIM)), Line::styled(format!(" {}{}", if self.loading { "" } else { "" }, self.status), Style::default().fg(DIM))]), chunks[4]);
if self.locations.is_some() {
self.draw_locations(frame);
}
}
fn draw_locations(&mut self, frame: &mut Frame) {
let rows = self.location_rows();
let locations = self.locations.as_mut().unwrap();
locations.selected = locations.selected.min(rows.len().saturating_sub(1));
locations.state.select(if rows.is_empty() {
None
} else {
Some(locations.selected)
});
let outer = frame.area();
let area = Rect {
x: outer.x + 2,
y: outer.y + 2,
width: outer.width.saturating_sub(4),
height: outer.height.saturating_sub(4),
};
frame.render_widget(Clear, area);
let block = Block::default()
.borders(Borders::ALL)
.title(" Locations · Enter select · Tab all · Esc close ")
.border_style(Style::default().fg(ACCENT));
let inner = block.inner(area);
frame.render_widget(block, area);
let chunks = Layout::vertical([Constraint::Length(2), Constraint::Min(1)]).split(inner);
frame.render_widget(Paragraph::new(format!(" {}", locations.query)), chunks[0]);
let items = rows
.iter()
.map(|(path, count)| ListItem::new(format!("{:>4} {}", count, display_path(path))));
frame.render_stateful_widget(
List::new(items)
.highlight_symbol(" ")
.highlight_style(Style::default().bg(DIM).fg(Color::White)),
chunks[1],
&mut locations.state,
);
}
fn location_key(&mut self, key: KeyEvent) {
let rows = self.location_rows();
let locations = self.locations.as_mut().unwrap();
match key.code {
KeyCode::Esc => self.locations = None,
KeyCode::Tab => {
self.all = true;
self.locations = None;
self.search(false);
}
KeyCode::Enter => {
if let Some((path, _)) = rows.get(locations.selected) {
self.folder = path.clone();
self.all = false;
self.locations = None;
self.search(false);
}
}
KeyCode::Up => locations.selected = locations.selected.saturating_sub(1),
KeyCode::Down => {
locations.selected = (locations.selected + 1).min(rows.len().saturating_sub(1))
}
_ => {
if edit_query(&mut locations.query, key) {
locations.selected = 0;
}
}
}
}
}
pub struct Options {
pub folder: PathBuf,
pub all: bool,
pub agent: Option<Agent>,
pub query: String,
pub rebuild: bool,
pub no_cache: bool,
}
pub fn run(sources: Sources, options: Options) -> Result<Option<Session>> {
if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
bail!("The picker needs a terminal. Use --list or --json for non-interactive output.");
}
let hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
restore();
hook(info);
}));
let _guard = TerminalGuard::enter()?;
let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?;
terminal.clear()?;
let mut receiver = load(sources.clone(), options.rebuild, options.no_cache);
let mut app = App {
index: SearchIndex::new(vec![]),
query: options.query,
agent: options.agent,
folder: options.folder,
all: options.all,
hits: vec![],
selected: 0,
table: TableState::default(),
locations: None,
status: "Loading…".into(),
loading: true,
elapsed: Duration::ZERO,
};
let mut dirty = true;
loop {
while let Ok(update) = receiver.try_recv() {
dirty = true;
match update {
WorkerUpdate::Index(Update::Snapshot(sessions)) => app.replace(sessions),
WorkerUpdate::Index(Update::Progress(status)) => app.status = status,
WorkerUpdate::Done(result) => {
app.loading = false;
match result {
Ok(report) => {
app.status = if report.warnings.is_empty() {
format!(
"{} sessions · {} refreshed · Ctrl-R refresh",
report.sessions.len(),
report.parsed
)
} else {
format!("Warning: {}", report.warnings.join("; "))
};
app.replace(report.sessions);
}
Err(e) => app.status = format!("Error: {e:#} · Ctrl-R retry"),
}
}
}
}
if dirty {
terminal.draw(|f| app.draw(f))?;
dirty = false;
}
if !event::poll(Duration::from_millis(30))? {
continue;
}
match event::read()? {
Event::Key(key) if key.kind != KeyEventKind::Release => {
dirty = true;
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
return Ok(None);
}
if app.locations.is_some() {
app.location_key(key);
continue;
}
match key.code {
KeyCode::Esc if app.query.is_empty() => return Ok(None),
KeyCode::Esc => {
app.query.clear();
app.search(false);
}
KeyCode::Enter => {
if let Some(hit) = app.hits.get(app.selected) {
let session = &app.index.sessions[hit.index];
if !session.cwd.is_dir() {
app.status = format!(
"Directory no longer exists: {}",
session.cwd.display()
);
} else if !session.path.is_file() {
app.status = "Session file disappeared; Ctrl-R to refresh".into();
} else {
return Ok(Some(session.clone()));
}
}
}
KeyCode::Tab => {
app.all = !app.all;
app.search(false);
}
KeyCode::BackTab => {
app.agent = Agent::next_filter(app.agent);
app.search(false);
}
KeyCode::Up => app.move_by(-1),
KeyCode::Down => app.move_by(1),
KeyCode::PageUp => app.move_by(-10),
KeyCode::PageDown => app.move_by(10),
KeyCode::Char(c) if key.modifiers.contains(KeyModifiers::CONTROL) => match c {
'n' => app.move_by(1),
'p' => app.move_by(-1),
'l' => {
app.locations = Some(Locations {
query: String::new(),
selected: 0,
state: ListState::default(),
})
}
'r' if !app.loading => {
receiver = load(sources.clone(), false, options.no_cache);
app.loading = true;
}
_ => {
if edit_query(&mut app.query, key) {
app.search(false);
}
}
},
_ => {
if edit_query(&mut app.query, key) {
app.search(false);
}
}
}
}
Event::Paste(text) => {
let text = compact(&text, 500);
if let Some(l) = &mut app.locations {
l.query.push_str(&text);
l.selected = 0;
} else {
app.query.push_str(&text);
app.search(false);
}
dirty = true;
}
Event::Resize(..) => dirty = true,
_ => {}
}
}
}
fn edit_query(query: &mut String, key: KeyEvent) -> bool {
match (key.code, key.modifiers.contains(KeyModifiers::CONTROL)) {
(KeyCode::Char('u'), true) => query.clear(),
(KeyCode::Char('w'), true) => {
while query.ends_with(char::is_whitespace) {
query.pop();
}
while !query.is_empty() && !query.ends_with(char::is_whitespace) {
query.pop();
}
}
(KeyCode::Backspace, _) => {
query.pop();
}
(KeyCode::Char(c), false)
if !c.is_control() && !key.modifiers.contains(KeyModifiers::ALT) =>
{
query.push(c)
}
_ => return false,
}
true
}
fn display_path(path: &std::path::Path) -> String {
if let Some(home) = dirs::home_dir()
&& let Ok(rest) = path.strip_prefix(home)
{
return format!("~/{}", rest.display());
}
compact(&path.to_string_lossy(), 300)
}
fn path_tail(path: &std::path::Path, width: usize) -> String {
let text = display_path(path);
let len = text.chars().count();
if len <= width {
return text;
}
format!(
"…{}",
text.chars()
.skip(len.saturating_sub(width.saturating_sub(1)))
.collect::<String>()
)
}
fn agent_color(agent: Agent) -> Color {
match agent {
Agent::Pi => Color::Magenta,
Agent::Claude => Color::Yellow,
Agent::Codex => Color::Green,
Agent::Opencode => Color::Blue,
Agent::Omp => Color::LightMagenta,
}
}
fn age(modified: u64) -> String {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let seconds = now.saturating_sub(modified);
if seconds < 60 {
"now".into()
} else if seconds < 3600 {
format!("{}m", seconds / 60)
} else if seconds < 86400 {
format!("{}h", seconds / 3600)
} else {
format!("{}d", seconds / 86400)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn query_edits_unicode_without_panicking() {
let mut query = "日本語 café".into();
edit_query(
&mut query,
KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE),
);
assert_eq!(query, "日本語 caf");
edit_query(
&mut query,
KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL),
);
assert_eq!(query, "日本語 ");
edit_query(
&mut query,
KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL),
);
assert_eq!(query, "");
}
#[test]
fn renders_small_and_normal_terminals() {
let mut app = App {
index: SearchIndex::new(vec![]),
query: "query".into(),
agent: None,
folder: "/work".into(),
all: false,
hits: vec![],
selected: 0,
table: TableState::default(),
locations: None,
status: "ready".into(),
loading: false,
elapsed: Duration::ZERO,
};
for (width, height) in [(20, 5), (35, 10), (80, 24), (140, 40)] {
let backend = ratatui::backend::TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| app.draw(f)).unwrap();
if width >= 35 {
app.locations = Some(Locations {
query: "".into(),
selected: 0,
state: ListState::default(),
});
terminal.draw(|f| app.draw(f)).unwrap();
app.locations = None;
}
}
}
}

178
src/ui/demo.rs Normal file
View file

@ -0,0 +1,178 @@
//! Documentation rendering from fictional in-memory sessions only.
//! This module is compiled for tests, never into the installed binary.
use super::*;
use serde_json::json;
#[test]
#[ignore = "writes target/demo-screen.json for the documentation renderer"]
fn render_demo() {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let examples = [
(
Agent::Pi,
"Auth token refresh",
"atlas-api",
120,
"Refresh the auth token before retrying a failed request.",
),
(
Agent::Claude,
"Auth middleware cleanup",
"tinyshop",
1080,
"Keep the auth middleware small and easy to test.",
),
(
Agent::Codex,
"Auth callback tests",
"atlas-api",
3600,
"Add tests for the auth callback and expired tokens.",
),
(
Agent::Omp,
"Auth cookies across subdomains",
"paperplane",
10800,
"Check the auth cookie settings on the staging subdomain.",
),
(
Agent::Opencode,
"Auth flow for the demo app",
"compass",
86400,
"Build a minimal auth flow for the fictional demo app.",
),
(
Agent::Pi,
"Fix login redirect loop",
"atlas-api",
720,
"The auth callback loses the return URL after a token refresh. Please add a regression test, then fix the redirect without changing the public API.",
),
(
Agent::Opencode,
"Session expiry edge cases",
"tinyshop",
5400,
"Check the auth expiry boundary when the client clock is slightly ahead.",
),
(
Agent::Omp,
"Add sign-in regression tests",
"paperplane",
14400,
"Exercise the auth callback with a missing state parameter.",
),
(
Agent::Claude,
"Replace the legacy login screen",
"compass",
172800,
"Keep the existing auth contract while simplifying the login screen.",
),
(
Agent::Pi,
"Improve image caching",
"paperplane",
900,
"Use long-lived cache headers for immutable images.",
),
(
Agent::Claude,
"Tidy the release script",
"atlas-api",
7200,
"Make release scripts easier to read.",
),
(
Agent::Codex,
"Fix pagination boundaries",
"tinyshop",
1800,
"Cover empty and single-page lists.",
),
(
Agent::Codex,
"Document the plugin API",
"compass",
21600,
"Add a small plugin example to the documentation.",
),
(
Agent::Opencode,
"Improve keyboard navigation",
"paperplane",
28800,
"Make every control usable with the keyboard.",
),
(
Agent::Omp,
"Reduce build time",
"compass",
43200,
"Remove unused dependencies from the build.",
),
];
let sessions = examples
.into_iter()
.enumerate()
.map(|(i, (agent, title, project, age, message))| Session {
agent,
id: format!("demo-session-{:02}", i + 1),
path: format!("/fictional/sessions/{i}.jsonl").into(),
cwd: format!("/work/{project}").into(),
title: title.into(),
modified: now - age,
messages: vec![message.into()],
})
.collect();
let mut app = App {
index: SearchIndex::new(sessions),
query: "auth".into(),
agent: None,
folder: "/work/atlas-api".into(),
all: true,
hits: vec![],
selected: 0,
table: TableState::default(),
locations: None,
status: "15 sessions · 0 refreshed · Ctrl-R refresh".into(),
loading: false,
elapsed: Duration::ZERO,
};
app.search(false);
let selected = app
.hits
.iter()
.position(|hit| app.index.sessions[hit.index].title == "Fix login redirect loop")
.unwrap();
assert_eq!(app.hits[selected].kind, MatchKind::Message);
app.move_by(selected as isize);
let (width, height) = (112, 26);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
terminal.draw(|frame| app.draw(frame)).unwrap();
let buffer = terminal.backend().buffer();
let cells: Vec<_> = buffer
.content
.iter()
.map(|cell| {
json!({
"text": cell.symbol(),
"fg": format!("{:?}", cell.fg),
"bg": format!("{:?}", cell.bg),
"bold": cell.modifier.contains(Modifier::BOLD),
})
})
.collect();
let output = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("target/demo-screen.json");
std::fs::create_dir_all(output.parent().unwrap()).unwrap();
std::fs::write(
output,
serde_json::to_vec(&json!({"width": width, "height": height, "cells": cells})).unwrap(),
)
.unwrap();
}

202
tests/cli.rs Normal file
View file

@ -0,0 +1,202 @@
use serde_json::{Value, json};
use std::{
fs,
path::Path,
process::{Command, Output},
};
struct Fixture {
dir: tempfile::TempDir,
}
impl Fixture {
fn new() -> Self {
let dir = tempfile::tempdir().unwrap();
for path in [
"pi",
"claude/projects/project",
"codex/sessions",
"work",
"other",
] {
fs::create_dir_all(dir.path().join(path)).unwrap();
}
let f = Self { dir };
let root = f.dir.path();
f.jsonl("pi/pi.jsonl", &[
json!({"type":"session", "id":"pi-id", "cwd":root.join("work")}),
json!({"type":"session_info", "name":"Database migration"}),
json!({"type":"message", "message":{"role":"user","content":"add postgres indexes"}}),
json!({"type":"message", "message":{"role":"assistant","content":"assistant-only-secret"}}),
]);
f.jsonl("claude/projects/project/claude.jsonl", &[
json!({"type":"user", "sessionId":"claude-id", "cwd":root.join("other"), "message":{"role":"user","content":"consider migration rollback"}}),
json!({"type":"ai-title", "aiTitle":"Different topic"}),
]);
f.jsonl("codex/sessions/codex.jsonl", &[
json!({"type":"session_meta", "payload":{"id":"codex-id", "cwd":root.join("work"), "source":"cli"}}),
json!({"type":"event_msg", "payload":{"type":"user_message", "message":"refactor rust"}}),
]);
f
}
fn jsonl(&self, path: &str, rows: &[Value]) {
fs::write(
self.dir.path().join(path),
rows.iter().map(|v| format!("{v}\n")).collect::<String>(),
)
.unwrap();
}
fn run(&self, args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_cont"))
.env("HOME", self.dir.path())
.env("XDG_DATA_HOME", self.dir.path().join("data"))
.env_remove("OPENCODE_DB")
.env("PI_CODING_AGENT_SESSION_DIR", self.dir.path().join("pi"))
.env("CLAUDE_CONFIG_DIR", self.dir.path().join("claude"))
.env("CODEX_HOME", self.dir.path().join("codex"))
.env("RESUME_CACHE_DIR", self.dir.path().join("cache"))
.current_dir(self.dir.path().join("work"))
.args(args)
.output()
.unwrap()
}
fn rows(&self, args: &[&str]) -> Vec<Value> {
let out = self.run(args);
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
serde_json::from_slice(&out.stdout).unwrap()
}
}
#[test]
fn cli_scope_agent_and_title_first_ranking() {
let f = Fixture::new();
assert_eq!(f.rows(&["--json"]).len(), 2);
assert_eq!(f.rows(&["--json", "--all"]).len(), 3);
let rows = f.rows(&["--json", "--all", "migration"]);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0]["agent"], "pi");
assert_eq!(rows[0]["match"], "title");
assert_eq!(rows[1]["agent"], "claude");
assert_eq!(rows[1]["match"], "message");
assert_eq!(
f.rows(&["--json", "--all", "--agent", "codex"])[0]["id"],
"codex-id"
);
assert!(
f.rows(&["--json", "--all", "assistant-only-secret"])
.is_empty()
);
}
#[test]
fn omp_discovery_and_custom_opencode_database() {
let f = Fixture::new();
let root = f.dir.path();
fs::create_dir_all(root.join(".omp/agent/sessions/project")).unwrap();
f.jsonl(
".omp/agent/sessions/project/session.jsonl",
&[
json!({"type":"title","title":"OMP task"}),
json!({"type":"session","id":"omp-id","cwd":root.join("work")}),
json!({"type":"message","message":{"role":"user","content":"unicorn request"}}),
],
);
let rows = f.rows(&["--json", "--agent", "omp", "unicorn"]);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0]["title"], "OMP task");
assert_eq!(rows[0]["match"], "message");
assert!(
rows[0]["command"]
.as_str()
.unwrap()
.contains("omp '--resume'")
);
let database = root.join("custom.db");
let conn = rusqlite::Connection::open(&database).unwrap();
conn.execute_batch("CREATE TABLE session (id TEXT, directory TEXT, title TEXT, time_updated INTEGER, parent_id TEXT, time_archived INTEGER);
CREATE TABLE message (id TEXT, session_id TEXT, time_created INTEGER, data TEXT);
CREATE TABLE part (id TEXT, message_id TEXT, session_id TEXT, time_created INTEGER, data TEXT);").unwrap();
conn.execute(
"INSERT INTO session VALUES ('ses_test', ?1, 'OpenCode task', 1000, NULL, NULL)",
[root.join("other").to_str().unwrap()],
)
.unwrap();
let rows = f.rows(&[
"--json",
"--all",
"--agent",
"opencode",
"--opencode-db",
database.to_str().unwrap(),
]);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0]["id"], "ses_test");
assert!(
rows[0]["command"]
.as_str()
.unwrap()
.contains("OPENCODE_DB=")
);
assert!(
f.rows(&["--json", "--all", "--agent", "opencode"])
.is_empty()
);
}
#[test]
fn additional_root_and_invalid_cache_are_safe() {
let f = Fixture::new();
let extra = f.dir.path().join("extra");
fs::create_dir(&extra).unwrap();
fs::write(
extra.join("extra.jsonl"),
format!(
"{}\n",
json!({"type":"session","id":"extra","cwd":f.dir.path().join("work")})
),
)
.unwrap();
assert_eq!(
f.rows(&["--json", "--all", "--pi-dir", extra.to_str().unwrap()])
.len(),
4
);
// Changing the configured roots must not leak old cached results into the final list.
assert_eq!(f.rows(&["--json", "--all"]).len(), 3);
fs::write(f.dir.path().join("cache/sessions-v1.json"), "bad cache").unwrap();
assert_eq!(f.rows(&["--json", "--all"]).len(), 3);
}
#[test]
fn non_tty_help_and_no_cache() {
let f = Fixture::new();
assert!(f.run(&["--help"]).status.success());
let out = f.run(&[]);
assert!(!out.status.success());
assert!(String::from_utf8_lossy(&out.stderr).contains("needs a terminal"));
assert_eq!(f.rows(&["--json", "--no-cache"]).len(), 2);
assert!(!f.dir.path().join("cache").exists());
assert!(f.rows(&["--json", "--limit", "0"]).is_empty());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let cache = f.dir.path().join("cache/sessions-v1.json");
assert_eq!(
fs::metadata(cache).unwrap().permissions().mode() & 0o777,
0o600
);
}
}
#[test]
#[cfg(unix)]
fn symlinked_working_directory_matches() {
let f = Fixture::new();
let link = f.dir.path().join("alias");
std::os::unix::fs::symlink(f.dir.path().join("work"), &link).unwrap();
assert_eq!(f.rows(&["--json", "-C", link.to_str().unwrap()]).len(), 2);
assert!(Path::new(f.rows(&["--json"])[0]["cwd"].as_str().unwrap()).is_absolute());
}

230
tests/pty_smoke.py Normal file
View file

@ -0,0 +1,230 @@
#!/usr/bin/env python3
"""Unix PTY integration checks. No real agents or user session files are touched."""
import fcntl
import json
import os
from pathlib import Path
import pty
import re
import unicodedata
import select
import signal
import struct
import sqlite3
import subprocess
import sys
import tempfile
import termios
import time
BINARY = str(Path(sys.argv[1] if len(sys.argv) > 1 else "target/release/cont").resolve())
class Picker:
def __init__(self, args, env, cwd):
self.master, self.slave = pty.openpty()
self.resize(120, 30)
self.before = termios.tcgetattr(self.slave)
self.output = b""
self.history = b""
def setup():
os.setsid()
fcntl.ioctl(0, termios.TIOCSCTTY, 0)
self.proc = subprocess.Popen([BINARY, *args], stdin=self.slave, stdout=self.slave,
stderr=self.slave, env=env, cwd=cwd, preexec_fn=setup)
self.wait_for(b"Ctrl-R refresh")
def resize(self, width, height):
self.output = b""
self.width, self.height = width, height
fcntl.ioctl(self.slave, termios.TIOCSWINSZ, struct.pack("HHHH", height, width, 0, 0))
if hasattr(self, "proc"):
os.kill(self.proc.pid, signal.SIGWINCH)
def read(self, timeout=0.05):
if select.select([self.master], [], [], timeout)[0]:
try:
data = os.read(self.master, 65536)
self.output += data
self.history += data
except OSError:
pass
def screen(self):
# Minimal emulator for Ratatui's absolute-cursor/SGR output. Reconstruct
# screen cells because later frames emit diffs, not complete lines.
cells = [[" "] * self.width for _ in range(self.height)]
x = y = 0
for match in re.finditer(r"\x1b\[([0-?]*)([ -/]*)([@-~])|([^\x1b])", self.history.decode(errors="replace"), re.S):
params, _, command, char = match.groups()
if command in ("H", "f"):
parts = params.split(";")
y = int(parts[0] or 1) - 1
x = int(parts[1] or 1) - 1 if len(parts) > 1 else 0
elif command == "J" and params == "2":
cells = [[" "] * self.width for _ in range(self.height)]
elif char == "\r":
x = 0
elif char == "\n":
y += 1
elif char and ord(char) >= 32:
wide = unicodedata.east_asian_width(char) in ("W", "F")
if 0 <= x < self.width and 0 <= y < self.height:
cells[y][x] = char
if wide and x + 1 < self.width:
cells[y][x + 1] = ""
x += 2 if wide else 1
return "\n".join("".join(row) for row in cells)
def wait_for(self, marker):
end = time.monotonic() + 8
self.read()
while marker not in self.output and marker.decode() not in self.screen() and time.monotonic() < end:
self.read()
assert marker in self.output or marker.decode() in self.screen(), (marker, self.screen())
def send(self, keys):
self.output = b""
os.write(self.master, keys)
def finish(self, code):
end = time.monotonic() + 8
while self.proc.poll() is None and time.monotonic() < end:
self.read()
if self.proc.poll() is None:
self.proc.kill()
raise AssertionError(("Picker did not exit", self.screen(), self.output[-2000:]))
self.read()
assert self.proc.returncode == code, (self.proc.returncode, self.output[-4000:])
after = termios.tcgetattr(self.master)
assert after == self.before, "Terminal settings were not restored"
assert b"\x1b[?1049l" in self.output, "Alternate screen was not left"
os.close(self.master)
os.close(self.slave)
def main():
with tempfile.TemporaryDirectory(prefix="cont-test-") as tmp:
root = Path(tmp)
work = root / "work with 'quotes'"
other = root / "other"
for p in [work, other, root / "pi", root / "claude/projects/p", root / "codex/sessions", root / ".omp/agent/sessions", root / "data/opencode", root / "bin"]:
p.mkdir(parents=True)
env = os.environ | {
"HOME": str(root),
"XDG_DATA_HOME": str(root / "data"),
"OPENCODE_DB": str(root / "data/opencode/opencode.db"),
"PI_CODING_AGENT_SESSION_DIR": str(root / "pi"),
"CLAUDE_CONFIG_DIR": str(root / "claude"),
"CODEX_HOME": str(root / "codex"),
"RESUME_CACHE_DIR": str(root / "cache"),
"PATH": str(root / "bin") + os.pathsep + os.environ["PATH"],
"RESUME_TEST_LOG": str(root / "handoff.json"),
"TERM": "xterm-256color",
}
sessions = {
".omp/agent/sessions/omp.jsonl": [
{"type": "title", "title": "OMP needle"},
{"type": "session", "id": "omp-id", "cwd": str(other)},
{"type": "message", "message": {"role": "user", "content": "omp prompt"}},
],
"pi/a.jsonl": [
{"type": "session", "id": "pi-id", "cwd": str(work)},
{"type": "session_info", "name": "Pi needle"},
{"type": "message", "message": {"role": "user", "content": "日本語 café"}},
],
"claude/projects/p/c.jsonl": [
{"type": "user", "sessionId": "claude-id", "cwd": str(other), "message": {"role": "user", "content": "Claude needle"}},
],
"codex/sessions/x.jsonl": [
{"type": "session_meta", "payload": {"id": "codex-id", "cwd": str(work)}},
{"type": "event_msg", "payload": {"type": "user_message", "message": "Codex needle"}},
],
}
for path, records in sessions.items():
(root / path).write_text("".join(json.dumps(r) + "\n" for r in records))
database = root / "data/opencode/opencode.db"
conn = sqlite3.connect(database)
conn.executescript("""
CREATE TABLE session (id TEXT, directory TEXT, title TEXT, time_updated INTEGER, parent_id TEXT, time_archived INTEGER);
CREATE TABLE message (id TEXT, session_id TEXT, time_created INTEGER, data TEXT);
CREATE TABLE part (id TEXT, message_id TEXT, session_id TEXT, time_created INTEGER, data TEXT);
""")
conn.execute("INSERT INTO session VALUES ('ses_oc', ?, 'OpenCode needle', 1000, NULL, NULL)", [str(other)])
conn.commit()
conn.close()
stub = f"#!{sys.executable}\n" + '''import json, os, sys, termios
flags = termios.tcgetattr(0)[3]
with open(os.environ["RESUME_TEST_LOG"], "w") as f:
json.dump({"agent": os.path.basename(sys.argv[0]), "args": sys.argv[1:], "cwd": os.getcwd(),
"canonical": bool(flags & termios.ICANON), "echo": bool(flags & termios.ECHO),
"database": os.environ.get("OPENCODE_DB")}, f)
sys.exit(23)
'''
for agent in ["pi", "claude", "codex", "opencode", "omp"]:
path = root / "bin" / agent
path.write_text(stub)
path.chmod(0o755)
# Actual exec handoff, argv boundaries, original cwd, exit status, terminal flags.
for agent, args, cwd in [
("pi", ["--session", str((root / "pi/a.jsonl").resolve())], work),
("claude", ["--resume", "claude-id"], other),
("codex", ["resume", "codex-id"], work),
("opencode", ["--session", "ses_oc"], other),
("omp", ["--resume", str((root / ".omp/agent/sessions/omp.jsonl").resolve())], other),
]:
p = Picker(["--all", "--agent", agent], env, work)
p.send(b"\r")
p.finish(23)
log = json.loads((root / "handoff.json").read_text())
selected_database = log.pop("database")
if agent == "opencode":
assert selected_database == str(database.resolve())
assert log == {"agent": agent, "args": args, "cwd": str(cwd.resolve()), "canonical": True, "echo": True}, log
# Scope, agent cycling, Unicode paste, clearing, location selection, resize, quit.
p = Picker([], env, work)
assert "2 results" in p.screen(), p.screen()
p.send(b"\t")
p.wait_for(b"All locations")
p.send(b"\x1b[Z")
p.wait_for(b"1 results")
p.send(b"\x1b[200~" + "日本語".encode() + b"\x1b[201~")
p.wait_for("日本語".encode())
p.send(b"\x15") # Ctrl-U
p.wait_for(b"All locations")
p.send(b"\x1b[Z") # Claude
p.wait_for(b"Claude needle")
p.send(b"\x0c") # Ctrl-L
p.wait_for(b"Locations")
p.send(b"other")
p.wait_for(b"other")
p.send(b"\r")
p.wait_for(b"1 results")
p.resize(30, 8)
p.wait_for(b"Enlarge terminal")
p.resize(120, 30)
p.wait_for(b"Enter resume")
p.send(b"\x03")
p.finish(0)
# Print-only selection and failed executable must also restore the terminal.
p = Picker(["--all", "--agent", "pi", "--print"], env, work)
p.send(b"\r")
p.finish(0)
assert b"cd -- '" in p.output
assert b"'--session'" in p.output
(root / "bin/pi").unlink()
env["PATH"] = str(root / "bin") # Deliberately no fallback to the real Pi.
p = Picker(["--all", "--agent", "pi"], env, work)
p.send(b"\r")
p.finish(1)
assert b"Could not start pi" in p.output
print("PTY smoke tests passed: all agents, scope/filter/paste/resize, print, errors, terminal restoration")
if __name__ == "__main__":
main()