commit 5e070dc7b911ed5d9f0c3201c67a07edbedebbdf Author: Liam Rohan Date: Tue Sep 15 14:13:22 2026 +0100 init diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d0d6371 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +* text=auto eol=lf +*.png binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..70a6e68 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +/target/ +.DS_Store +__pycache__/ +*.py[cod] +.venv/ +.env +.env.* +/cache/ +/sessions/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..8f533b6 --- /dev/null +++ b/CONTRIBUTING.md @@ -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. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..4afa31c --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1191 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "compact_str" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.5", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hashlink" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf84e73fa6f27f299dec58e13223cf70db80da872eb921d4f6138342a0eabc8" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libredox" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed" +dependencies = [ + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nucleo-matcher" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85" +dependencies = [ + "memchr", + "unicode-segmentation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + +[[package]] +name = "cont" +version = "0.2.0" +dependencies = [ + "anyhow", + "clap", + "crossterm", + "dirs", + "nucleo-matcher", + "ratatui", + "rayon", + "rusqlite", + "serde", + "serde_json", + "tempfile", + "walkdir", +] + +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror", +] + +[[package]] +name = "rusqlite" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "smallvec" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" + +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.119", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..c1bb441 --- /dev/null +++ b/Cargo.toml @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..06681b3 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..85bdbb3 --- /dev/null +++ b/README.md @@ -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). diff --git a/docs/assets/resume.png b/docs/assets/resume.png new file mode 100644 index 0000000..7466968 Binary files /dev/null and b/docs/assets/resume.png differ diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..995b7a1 --- /dev/null +++ b/docs/configuration.md @@ -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 ` | +| Claude Code | `~/.claude/projects/*/*.jsonl` | `claude --resume ` | +| Codex | `~/.codex/sessions/**/*.jsonl` | `codex resume ` | +| OpenCode | `~/.local/share/opencode/opencode*.db` | `opencode --session ` | +| Oh My Pi | `~/.omp/agent/sessions/**/*.jsonl` | `omp --resume ` | + +### 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. diff --git a/scripts/render_demo.py b/scripts/render_demo.py new file mode 100644 index 0000000..62ccc45 --- /dev/null +++ b/scripts/render_demo.py @@ -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() diff --git a/src/index.rs b/src/index.rs new file mode 100644 index 0000000..f4f4be4 --- /dev/null +++ b/src/index.rs @@ -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)]) -> Result { + 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 { + 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 { + 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, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +struct DatabaseStamp { + database: Stamp, + wal: Option, +} +impl DatabaseStamp { + fn read(path: &Path) -> std::io::Result { + 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, +} + +#[derive(Default, Deserialize, Serialize)] +struct Cache { + version: u32, + files: Vec, + #[serde(default)] + databases: Vec, +} + +pub enum Update { + Snapshot(Vec), + Progress(String), +} + +pub struct Report { + pub sessions: Vec, + pub parsed: usize, + pub reused: usize, + pub warnings: Vec, +} + +pub fn refresh( + sources: &Sources, + rebuild: bool, + no_cache: bool, + notify: impl Fn(Update) + Sync, +) -> Result { + 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 { + 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, 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::(&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"); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..0601dfa --- /dev/null +++ b/src/main.rs @@ -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, + /// Start in this location rather than the current directory + #[arg(short = 'C', long)] + dir: Option, + /// Initial search text + query: Vec, + /// 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, + /// Additional Claude projects root (repeatable) + #[arg(long)] + claude_dir: Vec, + /// Additional Codex sessions root (repeatable) + #[arg(long)] + codex_dir: Vec, + /// Additional Oh My Pi session root (repeatable) + #[arg(long)] + omp_dir: Vec, + /// Additional OpenCode SQLite database file (repeatable) + #[arg(long)] + opencode_db: Vec, +} + +fn main() { + if let Err(error) = run() { + if error + .downcast_ref::() + .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(()) +} diff --git a/src/opencode.rs b/src/opencode.rs new file mode 100644 index 0000000..f98f833 --- /dev/null +++ b/src/opencode.rs @@ -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> { + 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::(&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, + #[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()); + } +} diff --git a/src/parse.rs b/src/parse.rs new file mode 100644 index 0000000..2578550 --- /dev/null +++ b/src/parse.rs @@ -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, + uuid: Option, + session_id: Option, + cwd: Option, + name: Option, + title: Option, + attribution: Option, + #[serde(borrow)] + content: Option<&'a RawValue>, + custom_title: Option, + ai_title: Option, + summary: Option, + 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, + id: Option, + session_id: Option, + cwd: Option, + thread_source: &'a str, + #[serde(borrow)] + source: Option<&'a RawValue>, + #[serde(borrow)] + content: Option<&'a RawValue>, + message: Option, +} + +pub fn parse_file(agent: Agent, path: &Path, modified: u64) -> io::Result> { + 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> { + 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::(&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::(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::(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::(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::(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::(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::::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, +} + +fn push_text(messages: &mut Vec, raw: Option<&RawValue>) { + let Some(raw) = raw else { return }; + if raw.get().starts_with('"') { + if let Ok(text) = serde_json::from_str::(raw.get()) { + push_human(messages, &text); + } + } else if let Ok(blocks) = serde_json::from_str::>(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::>() + .join("\n"); + push_human(messages, &text); + } +} + +pub(crate) fn push_human(messages: &mut Vec, 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", + "", + "", + "", + "", + "", + "", + "", + "", + "[Request interrupted by user", + "This session is being continued from a previous conversation", + "", + "", + ] + .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":"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":"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()); + } +} diff --git a/src/search.rs b/src/search.rs new file mode 100644 index 0000000..e4759db --- /dev/null +++ b/src/search.rs @@ -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, + text: Vec, +} +struct SearchText { + title: String, + messages: Vec, +} + +#[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, + score: u32, + tier: u8, +} + +impl SearchIndex { + pub fn new(sessions: Vec) -> 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, cwd: Option<&Path>) -> Vec { + 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::>(), [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")); + } +} diff --git a/src/session.rs b/src/session.rs new file mode 100644 index 0000000..9de274b --- /dev/null +++ b/src/session.rs @@ -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) -> Option { + 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, +} + +impl Session { + pub fn args(&self) -> Vec { + 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::>() + .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::>().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), "日本語…"); + } +} diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..8f5a1a0 --- /dev/null +++ b/src/ui.rs @@ -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 { + 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), +} +fn load(sources: Sources, rebuild: bool, no_cache: bool) -> Receiver { + 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, + folder: PathBuf, + all: bool, + hits: Vec, + selected: usize, + table: TableState, + locations: Option, + 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) { + 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::::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, + pub query: String, + pub rebuild: bool, + pub no_cache: bool, +} + +pub fn run(sources: Sources, options: Options) -> Result> { + 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::() + ) +} + +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; + } + } + } +} diff --git a/src/ui/demo.rs b/src/ui/demo.rs new file mode 100644 index 0000000..12694b7 --- /dev/null +++ b/src/ui/demo.rs @@ -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(); +} diff --git a/tests/cli.rs b/tests/cli.rs new file mode 100644 index 0000000..787d2cf --- /dev/null +++ b/tests/cli.rs @@ -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::(), + ) + .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 { + 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()); +} diff --git a/tests/pty_smoke.py b/tests/pty_smoke.py new file mode 100644 index 0000000..b755682 --- /dev/null +++ b/tests/pty_smoke.py @@ -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()