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