init
This commit is contained in:
commit
5e070dc7b9
20 changed files with 4566 additions and 0 deletions
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
* text=auto eol=lf
|
||||
*.png binary
|
||||
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
/target/
|
||||
.DS_Store
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
.env
|
||||
.env.*
|
||||
/cache/
|
||||
/sessions/
|
||||
32
CONTRIBUTING.md
Normal file
32
CONTRIBUTING.md
Normal 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
1191
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
29
Cargo.toml
Normal file
29
Cargo.toml
Normal 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
21
LICENSE
Normal 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
54
README.md
Normal 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
BIN
docs/assets/resume.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 162 KiB |
68
docs/configuration.md
Normal file
68
docs/configuration.md
Normal 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
75
scripts/render_demo.py
Normal 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
542
src/index.rs
Normal 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
162
src/main.rs
Normal 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
210
src/opencode.rs
Normal 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
416
src/parse.rs
Normal 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
201
src/search.rs
Normal 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
206
src/session.rs
Normal 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
738
src/ui.rs
Normal 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
178
src/ui/demo.rs
Normal 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
202
tests/cli.rs
Normal 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
230
tests/pty_smoke.py
Normal 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()
|
||||
Loading…
Reference in a new issue