diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 59a831f..6df81b4 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -22,19 +22,42 @@ updates: patch-updates: update-types: ["patch"] ignore: - # The composer is `tui-textarea` 0.7, and it is built against exactly - # ratatui 0.29 and crossterm 0.28. Upstream has published nothing newer - # since 2024-10, so bumping either crate puts two crossterm versions in the - # tree and the build stops: `Input: From` and - # `&TextArea: Widget` both stop resolving. Dependabot raised exactly that - # pair and both PRs were closed rather than merged. These two entries are - # what keeps it from raising them again every week; delete them the day - # tui-textarea publishes a release against the 0.30 line. Both crates are - # pre-1.0, so the minor bump is the breaking one — patches still come + # The `ratatui` entry that stood here is GONE as of 0.18.0, and the note it + # carried is worth keeping because of how it ended. It said the composer was + # `tui-textarea` 0.7 built against exactly ratatui 0.29 and crossterm 0.28, + # that upstream had published nothing since 2024-10, that bumping either + # crate put two crossterm versions in the tree and stopped the build, that + # Dependabot had raised exactly that pair and both PRs were closed — and + # "delete them the day tui-textarea publishes a release against the 0.30 + # line." + # + # It never did. Twenty-two months on, 0.7.0 was still both the newest + # release and the state of upstream `main`, and the freeze had acquired a + # cost: ratatui 0.29 required `lru ^0.12.0`, and `lru` carried + # GHSA-rhfx-m35p-ff5j (Dependabot alert #1) with the fix landing in 0.16.3 + # and never backported to the 0.12 line. Because tui-textarea required + # ratatui through EVERY feature path it had — `no-backend` included — + # bumping io-cli's own ratatui would have left both 0.29 and 0.30 in the + # graph and closed nothing. So the dependency was removed rather than + # bumped: the editing model is `src/editor.rs` now, and ratatui is on 0.30. + # + # Alert #1 was dismissed as not-reachable rather than fixed by a version, + # and the evidence is this: in ratatui 0.29 `lru` backed the layout cache + # only (`src/layout/layout.rs:9` and `:31`), and `iter_mut` appears nowhere + # under `ratatui-0.29.0/src/layout/` — so `lru::IterMut`, which is the + # entire subject of the advisory, was never constructed on any path this + # binary could reach. The tree is clean by version now in any case: + # `ratatui-core` 0.30 takes a current `lru`, and + # `tests/dependencies.rs::n1_the_composer_is_free_and_no_lru_is_in_the_advisorys_range` + # fails the build if any `lru` in the lockfile falls inside the range. + # + # `crossterm` stays pinned, and now for a reason of our own choosing rather + # than a dependency's: ratatui 0.30 is taken with the `crossterm_0_28` + # feature, so a Dependabot bump of the direct `crossterm` to 0.29 would put + # two versions in the tree again. Moving to crossterm 0.29 is a deliberate + # change of that feature flag and this entry together, not a bot's to make. + # It is pre-1.0, so the minor bump is the breaking one — patches still come # through. - - dependency-name: ratatui - update-types: - ["version-update:semver-minor", "version-update:semver-major"] - dependency-name: crossterm update-types: ["version-update:semver-minor", "version-update:semver-major"] diff --git a/CHANGELOG.md b/CHANGELOG.md index bb7b6b9..6370ac5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,53 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +## [0.18.0] - 2026-08-26 + +Tell it to remember something, and it is still remembered tomorrow. + +**`/remember` writes a line into a markdown file you can open, read, edit, diff +and delete.** The scope is chosen at the moment it is written, because the +difference between the three is whether it gets committed: `AGENTS.md` for what +the team should share, `AGENTS.local.md` for what only this checkout should know, +and `~/.io-cli/IO.md` for what is true of every project. Nothing here is a store +nobody can inspect — these are files, and io-harness has read `AGENTS.md` since +its 0.45.0. + +**A remembered line takes effect on the next turn, with nothing restarted.** The +configuration is discovered again for every turn rather than once at startup, so +an edit you make in your own editor counts too. A file that stops parsing no +longer ends the session: the last configuration that read cleanly stays in force, +you are told which file refused and why, and the repaired file is picked up with +nothing further asked. + +**`/memory` shows both memories in one place.** The three instruction files with +what each holds and — the part no other surface can tell you — **which of them is +actually being read**. Naming files in `[instructions]` replaces the default +rather than adding to it, and a named file that does not exist is skipped in +silence, so a project's own table can quietly displace a wider list and nothing +warns you. Beside the files sits the durable memory the *agent* wrote for itself: +every entry with its key, its kind, whether it is pinned, the run and step that +wrote it, and how many runs have since drawn on it, in both the workspace scope +and the global one, with the caps named per scope because a run drawing on both +can carry twice the number a single figure suggests. + +**Pin what should survive, forget what should not.** A pinned entry is exempt +from being overwritten and from being evicted when the cap is reached, which is +the only lever an operator has over a store the agent otherwise manages alone. +Forgetting leaves a restore point, so an entry removed by accident can be put +back; an entry that is pinned reports the refusal and names the pin rather than +quietly doing nothing. Evictions, refusals and recalls are shown too — they emit +no event at all and have been happening invisibly since the store gained a cap. + +**`tui-textarea` is gone, and with it the last thing holding this product on +ratatui 0.29.** The composer and the wizard now use an editing model io-cli owns. +Nothing about typing changes: the multi-line keys, prompt-history recall, the +wrapped rows, the single block cursor, selection, cut and paste, the masked +credential field, and undo and redo on `Ctrl+U` and `Ctrl+R` all behave exactly as +they did, one step per character as before. ratatui moves to 0.30 and crossterm +stays at 0.28. The dependency that carried a security advisory with no fix +available for the version in reach is no longer in the tree. + ## [0.17.0] - 2026-08-26 Say something while it works, and have it land. diff --git a/Cargo.lock b/Cargo.lock index e616c89..ef205eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -82,6 +82,30 @@ dependencies = [ "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 = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -138,33 +162,69 @@ dependencies = [ "serde", ] +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec 0.6.3", +] + [[package]] name = "bit-set" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bit-vec", + "bit-vec 0.8.0", ] +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + [[package]] name = "bit-vec" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + [[package]] name = "bytemuck" version = "1.25.2" @@ -183,12 +243,6 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" -[[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" @@ -229,8 +283,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", - "cpufeatures", - "rand_core", + "cpufeatures 0.3.0", + "rand_core 0.10.1", ] [[package]] @@ -318,9 +372,9 @@ dependencies = [ [[package]] name = "compact_str" -version = "0.8.2" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" dependencies = [ "castaway", "cfg-if", @@ -330,6 +384,15 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -346,6 +409,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.0" @@ -364,13 +436,19 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossterm" version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags", + "bitflags 2.13.1", "crossterm_winapi", "mio", "parking_lot", @@ -380,6 +458,24 @@ dependencies = [ "winapi", ] +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.1", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix 1.1.4", + "signal-hook", + "signal-hook-mio", + "winapi", +] + [[package]] name = "crossterm_winapi" version = "0.9.1" @@ -395,6 +491,26 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + [[package]] name = "darling" version = "0.24.0" @@ -429,6 +545,50 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "displaydoc" version = "0.2.7" @@ -440,6 +600,15 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dunce" version = "1.0.5" @@ -468,6 +637,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -480,13 +658,23 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set 0.5.3", + "regex", +] + [[package]] name = "fancy-regex" version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" dependencies = [ - "bit-set", + "bit-set 0.8.0", "regex-automata", "regex-syntax", ] @@ -512,12 +700,35 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "find-msvc-tools" version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "flate2" version = "1.1.9" @@ -534,12 +745,6 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" -[[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" @@ -649,6 +854,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -662,6 +877,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -671,8 +898,8 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", - "rand_core", + "r-efi 6.0.0", + "rand_core 0.10.1", "wasm-bindgen", ] @@ -697,24 +924,15 @@ dependencies = [ "zerocopy", ] -[[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", + "allocator-api2", + "equivalent", + "foldhash", ] [[package]] @@ -723,7 +941,9 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ - "foldhash 0.2.0", + "allocator-api2", + "equivalent", + "foldhash", ] [[package]] @@ -741,6 +961,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "http" version = "1.5.0" @@ -1035,10 +1261,10 @@ dependencies = [ [[package]] name = "io-cli" -version = "0.17.0" +version = "0.18.0" dependencies = [ "clap", - "crossterm", + "crossterm 0.28.1", "image", "io-harness", "ratatui", @@ -1048,7 +1274,6 @@ dependencies = [ "tempfile", "tokio", "toml", - "tui-textarea", ] [[package]] @@ -1068,7 +1293,7 @@ dependencies = [ "serde", "serde_json", "tempfile", - "thiserror", + "thiserror 2.0.20", "tokio", "toml", "tracing", @@ -1089,9 +1314,9 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itertools" -version = "0.13.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" dependencies = [ "either", ] @@ -1114,7 +1339,7 @@ dependencies = [ "jni-sys", "log", "simd_cesu8", - "thiserror", + "thiserror 2.0.20", "walkdir", "windows-link", ] @@ -1172,12 +1397,41 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.20", +] + +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libsqlite3-sys" version = "0.38.2" @@ -1189,6 +1443,15 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "line-clipping" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" +dependencies = [ + "bitflags 2.13.1", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -1207,6 +1470,12 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -1224,11 +1493,11 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "lru" -version = "0.12.5" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a" dependencies = [ - "hashbrown 0.15.5", + "hashbrown 0.17.1", ] [[package]] @@ -1237,12 +1506,43 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix 0.29.0", + "winapi", +] + [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1275,18 +1575,58 @@ dependencies = [ "pxfm", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nix" version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags", + "bitflags 2.13.1", "cfg-if", "cfg_aliases", "libc", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1296,6 +1636,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1314,40 +1663,170 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "palette" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" +dependencies = [ + "approx", + "libm", + "palette_derive", + "palette_math", +] + +[[package]] +name = "palette_derive" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "palette_math" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12" +dependencies = [ + "libm", +] + [[package]] name = "parking_lot" version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +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 = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a07a60cc7a4d00c91f95c685609d1d2f79050e6804b70ebedd7650f0b839bcf" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a83744a5c8455b8b3e0dc5031362780a347c878bdd11584d1a8984228cc88d" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0cd3451aa3de60d4b9a1e736885e4dea6b31617598026f12256ad566d63304a" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pest_meta" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e04d3a0849e241d7dfce834c83b1c5edc8622009e8dd51a12ba1927c32f05496" +dependencies = [ + "pest", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" dependencies = [ - "lock_api", - "parking_lot_core", + "phf_generator", + "phf_shared", ] [[package]] -name = "parking_lot_core" -version = "0.9.12" +name = "phf_generator" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", + "phf_shared", + "rand 0.8.8", ] [[package]] -name = "paste" -version = "1.0.15" +name = "phf_macros" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] -name = "percent-encoding" -version = "2.3.2" +name = "phf_shared" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] [[package]] name = "pin-project-lite" @@ -1367,13 +1846,19 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags", + "bitflags 2.13.1", "crc32fast", "fdeflate", "flate2", "miniz_oxide", ] +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + [[package]] name = "potential_utf" version = "0.1.6" @@ -1383,6 +1868,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "proc-macro2" version = "1.0.107" @@ -1400,7 +1891,7 @@ checksum = "2e842efad9119158434d193c6682e2ebee4b44d6ad801d7b349623b3f57cdf55" dependencies = [ "futures", "indexmap", - "nix", + "nix 0.31.3", "tokio", "tracing", "windows", @@ -1432,7 +1923,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror", + "thiserror 2.0.20", "tokio", "tracing", "web-time", @@ -1448,14 +1939,14 @@ dependencies = [ "bytes", "getrandom 0.4.3", "lru-slab", - "rand", + "rand 0.10.2", "rand_pcg", "ring", "rustc-hash", "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.20", "tinyvec", "tracing", "web-time", @@ -1484,12 +1975,27 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.10.2" @@ -1498,9 +2004,15 @@ checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", "getrandom 0.4.3", - "rand_core", + "rand_core 0.10.1", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + [[package]] name = "rand_core" version = "0.10.1" @@ -1513,28 +2025,98 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "rand_core", + "rand_core 0.10.1", ] [[package]] name = "ratatui" -version = "0.29.0" +version = "0.30.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" dependencies = [ - "bitflags", - "cassowary", - "compact_str", - "crossterm", - "indoc", "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-termina", + "ratatui-termwiz", + "ratatui-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags 2.13.1", + "compact_str", + "critical-section", + "hashbrown 0.17.1", "itertools", + "kasuari", "lru", - "paste", + "palette", + "serde", "strum", + "thiserror 2.0.20", "unicode-segmentation", "unicode-truncate", - "unicode-width 0.2.0", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" +dependencies = [ + "cfg-if", + "crossterm 0.28.1", + "crossterm 0.29.0", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-termina" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" +dependencies = [ + "instability", + "ratatui-core", + "termina", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags 2.13.1", + "hashbrown 0.17.1", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "serde", + "strum", + "time", + "unicode-segmentation", + "unicode-width", ] [[package]] @@ -1543,7 +2125,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.13.1", ] [[package]] @@ -1646,7 +2228,7 @@ dependencies = [ "serde", "serde_json", "sse-stream", - "thiserror", + "thiserror 2.0.20", "tokio", "tokio-stream", "tokio-util", @@ -1660,7 +2242,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" dependencies = [ "hashbrown 0.16.1", - "thiserror", + "thiserror 2.0.20", ] [[package]] @@ -1669,7 +2251,7 @@ version = "0.40.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" dependencies = [ - "bitflags", + "bitflags 2.13.1", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -1699,7 +2281,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -1712,7 +2294,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.12.1", @@ -1836,7 +2418,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.13.1", "core-foundation", "core-foundation-sys", "libc", @@ -1911,6 +2493,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "shlex" version = "2.0.1" @@ -1970,6 +2563,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "slab" version = "0.4.12" @@ -2037,23 +2636,22 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "strum" -version = "0.26.3" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" dependencies = [ "strum_macros", ] [[package]] name = "strum_macros" -version = "0.26.4" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" dependencies = [ "heck", "proc-macro2", "quote", - "rustversion", "syn 2.0.119", ] @@ -2063,6 +2661,17 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.119" @@ -2112,14 +2721,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" dependencies = [ "bincode", - "fancy-regex", + "fancy-regex 0.16.2", "flate2", "fnv", "once_cell", "regex-syntax", "serde", "serde_derive", - "thiserror", + "thiserror 2.0.20", "walkdir", ] @@ -2136,13 +2745,109 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.1", + "parking_lot", + "rustix 1.1.4", + "signal-hook", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64 0.22.1", + "bitflags 2.13.1", + "fancy-regex 0.11.0", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix 0.29.0", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf", + "sha2", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -2170,6 +2875,27 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + [[package]] name = "tinystr" version = "0.8.4" @@ -2317,7 +3043,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags", + "bitflags 2.13.1", "bytes", "futures-util", "http", @@ -2379,15 +3105,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] -name = "tui-textarea" -version = "0.7.0" +name = "typenum" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a5318dd619ed73c52a9417ad19046724effc1287fb75cdcc4eca1d6ac1acbae" -dependencies = [ - "crossterm", - "ratatui", - "unicode-width 0.2.0", -] +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" [[package]] name = "unicode-ident" @@ -2403,21 +3130,15 @@ checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-truncate" -version = "1.1.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ "itertools", "unicode-segmentation", - "unicode-width 0.1.14", + "unicode-width", ] -[[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" @@ -2454,12 +3175,39 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f053576934f05a761a402421fbbe3d425d9366f75f978806a037b3ca481abecc" +dependencies = [ + "atomic", + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "vcpkg" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -2485,6 +3233,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.127" @@ -2588,6 +3345,78 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + [[package]] name = "winapi" version = "0.3.9" @@ -2826,6 +3655,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "writeable" version = "0.6.4" diff --git a/Cargo.toml b/Cargo.toml index b0aba7e..9280faa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "io-cli" -version = "0.17.0" +version = "0.18.0" edition = "2021" # io-harness declares 1.95, set by `libsqlite3-sys` calling the std `cfg_select!` # macro from its build script. Nothing here can be lower than what the harness @@ -41,18 +41,36 @@ path = "src/lib.rs" # interface release and it is stated in the README and the CHANGELOG rather than # left to be discovered. It is governed by the same policy as any other read. io-harness = { version = "0.69", features = ["media", "browser"] } -# ratatui stays at 0.29 and crossterm at 0.28 because `tui-textarea` 0.7 — the -# composer — is built against exactly that pair. The 0.30 line has forks of the -# textarea widget rather than a release of it, and taking one would mean depending -# on somebody's fork of the one widget the composer is made of. +# **`tui-textarea` is gone as of 0.18.0, and it is why this pair was frozen.** +# The comment that stood here said ratatui stayed at 0.29 and crossterm at 0.28 +# because the composer was built out of that widget and the 0.30 line carried +# forks of it rather than a release. That was true and it had a cost: the widget +# has published nothing since 2024-10-22, it required `ratatui ^0.29.0` through +# every feature path it had including `no-backend`, and 0.29 in turn required +# `lru ^0.12.0` — a crate carrying an advisory whose fix landed in 0.16.3 and was +# never backported. So the pin could not be bumped around; the dependency had to +# go. io-cli owns its editing model now (`src/editor.rs`), and `lru` is out of the +# tree entirely. +# +# `default-features = false` with `crossterm_0_28` rather than the default +# `crossterm`, which resolves to 0.29: that bump is a separate decision from this +# one, and taking both at once would put two crossterm versions in the tree for as +# long as anything disagreed. Of the remaining defaults, `all-widgets` is only the +# calendar and `macros` is only the declarative builders — this crate uses +# neither, and the base widgets are not behind either flag. +# # `unstable-rendered-line-info` is enabled for one method: `Paragraph::line_count`, # which answers how many rows a wrapped paragraph will occupy. `insert_before` # takes that height as an argument, and a caller that estimates it truncates # exactly the content this product exists to keep. It is a feature of a dependency # already taken, not a dependency. -ratatui = { version = "0.29", features = ["unstable-rendered-line-info"] } +ratatui = { version = "0.30", default-features = false, features = [ + "crossterm_0_28", + "layout-cache", + "underline-color", + "unstable-rendered-line-info", +] } crossterm = "0.28" -tui-textarea = "0.7" clap = { version = "4", features = ["derive"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] } serde = { version = "1", features = ["derive"] } diff --git a/README.md b/README.md index 8a66105..30988c7 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,27 @@ same prompt as a clearly attributed section. That is where "in this codebase, do it this way" goes. There is no `[app.io-cli]` key for the prompt itself: a second place the agent's manner is decided is a second thing to keep true. +**Naming files in `[instructions]` replaces that default rather than adding to +it.** `AGENTS.md` is the whole automatic list, so a file that says +`files = ["docs/RULES.md"]` has stopped `AGENTS.md` reaching the agent — silently, +because a named file that does not exist is skipped without complaint. To keep it, +list it. `/remember` writes the complete list for you, and `/memory` shows which +files are actually being read, which is the only way to see that a project-scope +`[instructions]` table has replaced a wider one from your own configuration: +`files` is not one of the keys io-harness appends across scopes, so the +nearest file wins outright. + +**Three scopes, and the difference between them is whether it gets committed.** +`/remember` asks which one at the moment it writes: `AGENTS.md` for what the team +should share, `AGENTS.local.md` for what only this checkout should know, and +`~/.io-cli/IO.md` for what is true of every project. The first two are named +relative to the repository; the third is written as an absolute path, because +instruction names resolve against the directory the run was started in and io's +own home is not that directory. A line takes effect on the **next turn** of the +same session — io re-reads the configuration for every turn, so an edit you make +in your own editor counts too, and a file that stops parsing leaves the last good +one in force rather than ending the session. + ## Keys @@ -366,6 +387,8 @@ above a row that ranked there for reasons having nothing to do with it. | --- | --- | | `/config` | every setting, the value in force and the file that decided it | | `/theme` | change the theme for this session | +| `/remember` | remember a line of guidance, in the scope you choose | +| `/memory` | what io remembers: the instruction files, and the agent's own notes | `/usage` answers what `/status` answers and is deliberately not listed above: an alias earns no row of its own, because a second row for one screen reads as a @@ -747,8 +770,9 @@ configuration file is in it, and so is the run store `runs.db` with the `-wal` and `-shm` SQLite keeps beside it — which is where the agent's durable memory lives too, because that is rows inside the store rather than a file of its own — and the skills directory, which is `~/.io-cli/skills` when `skills` names none. -That is one directory to copy to a new machine, and one path to put in a bug -report. +`~/.io-cli/IO.md` is in it as well: the guidance you want in every project, which +`/remember` writes when you pick that scope. That is one directory to copy to a +new machine, and one path to put in a bug report. The file is found in this order, which is io-harness's and is unchanged: `$IO_CONFIG`, else `$IO_CONFIG_HOME/io.toml`, else `$XDG_CONFIG_HOME/io/io.toml` diff --git a/src/commands.rs b/src/commands.rs index fce6e81..6fa77f0 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -164,6 +164,17 @@ pub const COMMANDS: &[(&str, &str)] = &[ "/config", "every setting, the value in force and the file that decided it", ), + // Beside `/config` because they are the other two surfaces that write a file + // the operator keeps, and because the scope question is the same one: three + // files, and *which* one is half of every decision made here. + ( + "/remember", + "remember a line of guidance, in the scope you choose", + ), + ( + "/memory", + "what io remembers: the instruction files, and the agent's own notes", + ), ( "/mcp", "the MCP servers configured, and what this session has seen of each", @@ -196,11 +207,15 @@ pub const COMMANDS: &[(&str, &str)] = &[ /// /// **Grouped by the operator's intent rather than by which part of the harness /// answers**, because the second is an implementation detail and the first is -/// the only thing somebody scanning a list of twenty is holding in their head. +/// the only thing somebody scanning a list of twenty-five is holding in their +/// head. /// /// Four groups and none longer than ten, which is the bound `tests/commands.rs` -/// asserts. A flat list of twenty is a list nobody reads, and this release is the -/// one that made it twenty. +/// asserts. A flat list of twenty-five is a list nobody reads; 0.16.0 is the +/// release that grouped them, at twenty, and every release since has added to a +/// group rather than to a list. **The count in this paragraph is the one number +/// here that goes stale on its own** — it said twenty through 0.17.0, which had +/// twenty-three — so it is written out rather than left as "a few". #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum Group { /// The conversation itself: start one, leave one, come back to one. @@ -274,7 +289,10 @@ pub const GROUPS: &[(Group, &[&str])] = &[ "/copy diff", ], ), - (Group::Configure, &["/config", "/theme"]), + ( + Group::Configure, + &["/config", "/theme", "/remember", "/memory"], + ), ]; /// The group a command belongs to, or `None` for a name that is in none. @@ -766,6 +784,29 @@ pub enum Action { /// where, and a value coerced on the way through would be io-cli inventing a /// second opinion about a schema it does not own. Config(Option<(String, String)>), + /// Append one line of guidance to a memory file. + /// + /// The line as the operator typed it, and **no scope**: which of the three + /// files it goes into is the other half of the decision and it is asked for + /// afterwards, in a picker, exactly as [`Action::Config`]'s write is. The + /// three differ only in who else reads them — a repository, a checkout, or + /// this machine — so guessing one is guessing whether a private note is + /// about to be committed. + /// + /// An empty string is the operator typing the word with nothing after it. + /// It is carried rather than refused here so the driver can say what to + /// type instead of opening a picker over a file it would write nothing to. + Remember(String), + /// The memory page: the instruction files, and the agent's own notes. + /// + /// **Two lists on one page, and they are two different memories.** The + /// first is [`crate::memory`] — markdown a person writes, which io-harness + /// reads as an instruction at the start of every run. The second is + /// [`crate::recall`] — notes the *agent* wrote for itself, which the harness + /// carries into every later prompt over the same workspace. One page, + /// because "what does it already know" is one question; two lists, because + /// the answer has two authors and only one of them is the operator. + Memory, /// Show the configured MCP servers and what the session has seen of them. Mcp, /// Show the provider chain, in the order a turn tries it. @@ -783,6 +824,463 @@ pub enum Copied { Diff, } +// --------------------------------------------------------------------------- +// The memory page, and the scope question `/remember` asks +// --------------------------------------------------------------------------- + +/// What marks a note an operator pinned. +/// +/// **Four marks on this page and every one of them is ASCII by rule**, the same +/// rule [`COMMAND_MARK`] and its two neighbours are held to: a mark is text +/// rather than a colour, so it survives `NO_COLOR`, `--plain` and the ASCII +/// glyph set unchanged, and `tests/glyphs.rs` sweeps the rendered page for +/// anything that is not. +/// +/// They are all one cell wide, so no column shifts between rows, and they are +/// paired on one axis: `*` and `+` say **yes**, `-` says **no**, and the reader +/// learns one thing rather than four. A pin is what stops a run overwriting a +/// correction a person made and what stops the caps dropping it — see +/// [`crate::recall::pin`] — so it is the state worth the mark column on an +/// entry row. +pub const PINNED_MARK: &str = "*"; + +/// What marks a note nothing has pinned: a run may overwrite it, and the caps +/// may drop it. +pub const LOOSE_MARK: &str = "-"; + +/// What marks an instruction file io-harness is **actually reading**. +/// +/// Read back from what the harness composed rather than from what was +/// configured — see [`crate::memory::view`] — so a project `[instructions] +/// files` that replaced the list shows here as [`UNREAD_MARK`] instead of being +/// argued away. +pub const READ_MARK: &str = "+"; + +/// What marks an instruction file the harness is **not** reading. +/// +/// The case this page exists for. A file that is there and is not read looks, +/// from everywhere else in this product, exactly like one that is read: nothing +/// warns, and a missing or skipped instruction file is passed over in silence +/// (`io-harness-0.69.0/src/config.rs:1886`, `:1892`). +pub const UNREAD_MARK: &str = "-"; + +/// What one row of the memory page stands for. +/// +/// The parallel vector [`memory_page`] returns beside its rows, in the same +/// order and of the same length. It exists for the reason every other picker in +/// this product carries one: [`crate::picker::Outcome::Chosen`] indexes the +/// **caller's unfiltered rows**, and reading a key back off a rendered label +/// would be matching on a string the fitter may have shortened. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Held { + /// A group heading. Nothing to act on, and nothing the picker can put the + /// marker on — see [`Row::heading`]. + Nothing, + /// One of the instruction files, whole, so choosing it can say what it is + /// without the page being rebuilt to find out. + File(crate::memory::Instruction), + /// One of the agent's own notes: which of the two buckets holds it, the key + /// it is recalled by, and whether an operator has pinned it. + /// + /// The bucket is carried because [`crate::recall::pin`] and + /// [`crate::recall::forget`] both take one, and a key can exist in both: a + /// verb applied to the wrong bucket either does nothing or acts on a + /// same-named note the operator was not looking at. + Note { + scope: crate::recall::Scope, + key: String, + pinned: bool, + }, +} + +/// The memory page's rows, and what each of them stands for. +/// +/// **Built in one pass so the two can never be different lengths.** This is the +/// shape the palette's own `entries` already uses, and for the same reason it +/// was given one: a list with headings interleaved makes "the n-th row is the +/// n-th thing" false, and two functions that walked the same inventories +/// separately would agree only for as long as somebody kept checking. +/// +/// The two lists are kept distinguishable by a heading each, and neither is +/// dropped when it is empty: an operator whose agent has learnt nothing is +/// entitled to see the heading over the absence rather than a page that quietly +/// looks like the other list is all there is. +/// +/// `draws_cut` comes from [`crate::recall::View::draws_cut`]. When it is set the +/// draw counts are a **lower bound** — the scan stopped at +/// [`crate::recall::MAX_RUNS_SCANNED`] runs — and the detail says so in words +/// rather than printing a number that reads as exact. +pub fn memory_page( + files: &[crate::memory::Instruction], + entries: &[crate::recall::Remembered], + draws_cut: bool, + glyphs: &crate::glyphs::Glyphs, +) -> (Vec, Vec) { + let mut rows = Vec::new(); + let mut held = Vec::new(); + let separator = glyphs.separator; + + rows.push(Row::heading("instruction files")); + held.push(Held::Nothing); + for file in files { + rows.push(Row::marked( + if file.read { READ_MARK } else { UNREAD_MARK }, + // The file's own name, which is what an operator knows it by and + // what makes the three tell each other apart. The path rides in the + // detail, where a narrow terminal may drop it — the name is the part + // that must survive. + crate::memory::file_name(file.scope), + // **The state leads.** The detail is fitted from the head, so what + // is drawn on the narrowest terminal that still has room for one is + // the answer to the only question this list is asked. + format!( + "{}{separator}{}{separator}{}", + state_of(file), + crate::configure::Decided::File { + scope: file.scope, + path: file.path.clone(), + } + .word(), + file.path.display(), + ), + )); + held.push(Held::File(file.clone())); + } + + rows.push(Row::heading("what the agent remembers")); + held.push(Held::Nothing); + for entry in entries { + rows.push(Row::marked( + if entry.pinned { + PINNED_MARK + } else { + LOOSE_MARK + }, + entry.key.clone(), + format!( + "{}{separator}{}{separator}run {} step {}{separator}{}", + // The scope leads for the reason the file state does: "is this + // true here, or true everywhere" is the only question the two + // buckets exist to answer, and it is the first thing to go if + // the detail is cut from the tail. + entry.scope.label(), + entry.kind, + entry.run_id, + entry.step, + draws(entry.draws, draws_cut), + ), + )); + held.push(Held::Note { + scope: entry.scope, + key: entry.key.clone(), + pinned: entry.pinned, + }); + } + + (rows, held) +} + +/// How many runs have drawn on a note, and whether that is a count or a floor. +/// +/// **Never a bare number when the scan was cut.** `n draws` and `n draws or +/// more` are different claims, and the second is the true one whenever +/// [`crate::recall::View::draws_cut`] is set — a store with more than +/// [`crate::recall::MAX_RUNS_SCANNED`] runs in it has evidence this page did not +/// look at. Reading a floor as a count is what makes an ordinary eviction look +/// like a note nothing ever used. +fn draws(count: usize, cut: bool) -> String { + if cut { + format!("{count} draws or more") + } else { + format!("{count} draws") + } +} + +/// What one instruction file's row says about itself, in three words or so. +/// +/// The middle case is the whole reason this page exists, and it is stated rather +/// than implied: **a file that is there and is not being read**. Nothing else in +/// this product says so, because io-harness skips a file it will not read +/// without a word. +fn state_of(file: &crate::memory::Instruction) -> String { + match (file.exists, file.read) { + (false, _) => "not written yet".to_string(), + (true, true) => format!("read{}", plural(file.lines)), + (true, false) => format!("NOT read{}", plural(file.lines)), + } +} + +/// `, 12 lines` — or nothing at all for a file with none. +fn plural(lines: usize) -> String { + match lines { + 0 => String::new(), + 1 => ", 1 line".to_string(), + many => format!(", {many} lines"), + } +} + +/// What the memory page commits above its list. +/// +/// **Committed rather than said, and the caps are why.** The bucket that +/// answered, the ceilings in force and the note about a cut scan are facts about +/// the configuration and the store: they outlive the keystroke that asked for +/// them, and [`crate::app::App::say`] would put each on the footer's one row, +/// where the next thing typed replaces it — and where only the last of the three +/// would ever be seen. +/// +/// **The caps are per scope, and this says so rather than printing one number.** +/// `src/contract.rs:376-379` states it: each scope holds its own, so a run +/// drawing on both may carry up to twice `max_entries` and twice `max_chars`. +/// Quoting the single figure the contract carries tells an operator half the +/// real ceiling — and the half that makes a legitimate eviction read as a defect. +pub fn memory_notes(view: &crate::recall::View, glyphs: &crate::glyphs::Glyphs) -> Vec { + let dash = glyphs.dash; + let each: Vec = view + .caps + .iter() + .map(|caps| { + format!( + "{} {} entries, {} chars", + caps.scope.label(), + caps.limits.max_entries, + caps.limits.max_chars, + ) + }) + .collect(); + + let mut notes = vec![ + // Reported rather than assumed. The bucket is the **canonicalised** root + // — a checkout reached through a symlink has its notes filed under the + // resolved path — so a reader looking at an empty list can tell "the + // agent has learnt nothing" from "you are looking at the wrong bucket". + format!("workspace memory is keyed on {}", view.workspace), + format!( + "the caps are per scope {dash} {} {dash} so one run may carry {} entries \ + and {} chars in all", + each.join(glyphs.separator), + view.entries_ceiling(), + view.chars_ceiling(), + ), + ]; + if view.draws_cut { + notes.push(format!( + "the draw scan stopped at {} runs, so every draw count on this page is a lower bound", + crate::recall::MAX_RUNS_SCANNED, + )); + } + notes +} + +/// What choosing one instruction file's row says. +/// +/// Three sentences for three states, and the middle one is the one worth +/// writing: a file that exists and is not read. It names what would make it +/// read rather than only reporting the fact, because `[instructions] files` +/// **replaces** the default list rather than adding to it +/// (`io-harness-0.69.0/src/config.rs:1879-1882`) and there is nothing on any +/// other surface to pull on. +pub fn instruction_said( + file: &crate::memory::Instruction, + glyphs: &crate::glyphs::Glyphs, +) -> String { + let dash = glyphs.dash; + let at = file.path.display(); + match (file.exists, file.read) { + (false, _) => format!("{at} is not written yet {dash} /remember creates it"), + (true, true) => { + format!("{at} is read at the start of every run") + } + (true, false) => format!( + "{at} exists and is NOT read {dash} nothing in `[instructions] files` reaches it, or \ + it holds only whitespace. Writing to any scope with /remember names all three.", + ), + } +} + +/// The three memory files, as the rows that ask which one a line goes into. +/// +/// **Each row says what committing it means**, because that is the entire +/// difference between the three: the file names are `IO.md`, `AGENTS.md` and +/// `AGENTS.local.md`, and an operator who has not read [`crate::memory`] cannot +/// tell from those which one goes to everybody who clones the repository. A +/// picker that offered three filenames would be asking a question whose answer +/// is only knowable somewhere else. +/// +/// The consequence leads and the path follows, because the detail is fitted from +/// the head: on a narrow terminal the sentence that decides the answer is what +/// survives, and the path — which the confirmation names in full afterwards — +/// is what goes. +pub fn scope_rows( + paths: &[(io_harness::config::Scope, std::path::PathBuf)], + glyphs: &crate::glyphs::Glyphs, +) -> Vec { + paths + .iter() + .map(|(scope, path)| { + Row::with_detail( + crate::memory::file_name(*scope), + format!("{} {} {}", committing(*scope), glyphs.dash, path.display()), + ) + }) + .collect() +} + +/// What writing into one scope commits the operator to. +/// +/// The same three facts [`crate::memory`] writes into the head of each file it +/// creates, said before the write rather than after it. +fn committing(scope: io_harness::config::Scope) -> &'static str { + match scope { + io_harness::config::Scope::User => { + "every project on this machine, and part of no repository" + } + io_harness::config::Scope::Project => { + "committed: everyone who clones this repository reads it" + } + io_harness::config::Scope::Local => "this checkout only, and never committed", + } +} + +/// A verb `/memory` offers on one of the agent's notes. +/// +/// Two at a time and never three: pinning and unpinning are one switch, and +/// offering both would put a row on screen that does nothing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Verb { + /// Stop a run overwriting it, and stop the caps dropping it. + Pin, + /// Let both happen again — and the prerequisite for [`Verb::Forget`] on a + /// pinned note. + Unpin, + /// Withdraw it, leaving a restore point. + Forget, +} + +impl Verb { + /// What is offered on a note, given whether it is already pinned. + pub fn of(pinned: bool) -> [Verb; 2] { + [if pinned { Verb::Unpin } else { Verb::Pin }, Verb::Forget] + } + + /// The word on the row. + pub fn label(self) -> &'static str { + match self { + Verb::Pin => "pin", + Verb::Unpin => "unpin", + Verb::Forget => "forget", + } + } + + /// What it does, in the detail column. + /// + /// The pin row states the cost as well as the effect: a pinned note still + /// counts towards both caps (`src/state/memory.rs:736-739`), so pinning + /// everything buys writes that fail loudly rather than a bigger memory. + /// io-harness made that choice and this surface does not soften it. + pub fn detail(self) -> &'static str { + match self { + Verb::Pin => { + "no run may overwrite it and the caps may not drop it; it still counts \ + towards both" + } + Verb::Unpin => "a run may overwrite it again, and the caps may drop it", + Verb::Forget => "withdraw it, leaving a restore point; a pinned note is refused", + } + } +} + +/// The verbs offered on one note, as rows. +pub fn verb_rows(pinned: bool) -> Vec { + Verb::of(pinned) + .into_iter() + .map(|verb| Row::with_detail(verb.label(), verb.detail())) + .collect() +} + +/// What pinning or unpinning one note is reported as. +/// +/// **Two outcomes and not a `bool`.** `Store::memory_pin` answers `false` for +/// *there was no such entry*, which is not "the pin failed" — see +/// [`crate::recall::Pinned`]. A surface that read it as success would draw a pin +/// the store does not hold. +/// +/// It lives here rather than at its one call site in `src/main.rs` because +/// nothing under `tests/` can link the binary: a sentence written there is one +/// no test drives and no sabotage can make fail. +pub fn pinned_said( + key: &str, + scope: crate::recall::Scope, + pinned: bool, + outcome: crate::recall::Pinned, +) -> (Tone, String) { + match outcome { + crate::recall::Pinned::Set if pinned => ( + Tone::Success, + format!( + "{key} is pinned in the {} memory; no run may overwrite it", + scope.label() + ), + ), + crate::recall::Pinned::Set => ( + Tone::Muted, + format!( + "{key} is unpinned in the {} memory; a run may overwrite it again", + scope.label() + ), + ), + crate::recall::Pinned::NoEntry => ( + Tone::Muted, + format!( + "there is no {key} in the {} memory, so nothing was changed", + scope.label() + ), + ), + } +} + +/// What withdrawing one note is reported as. +/// +/// **[`crate::recall::Forgotten::Refused`] is a refusal and names why, never a +/// success.** The note is pinned, so it is not a run's to withdraw and io-cli +/// asks on a run's behalf; it stands, unchanged, and it will go on being carried +/// into every later prompt. Reporting that as a removal is the same failure the +/// pin flag exists to prevent one level down — the operator believes the note is +/// gone and it is not. +/// +/// [`crate::recall::Forgotten::Absent`] is a third thing again: not an error and +/// not a removal. +pub fn forgotten_said( + key: &str, + scope: crate::recall::Scope, + outcome: crate::recall::Forgotten, + glyphs: &crate::glyphs::Glyphs, +) -> (Tone, String) { + match outcome { + crate::recall::Forgotten::Removed { restore } => ( + Tone::Success, + format!( + "{key} is withdrawn from the {} memory; run {restore} holds the way back", + scope.label(), + ), + ), + crate::recall::Forgotten::Refused => ( + Tone::Refused, + format!( + "{key} is pinned, so it is not a run's to withdraw {} it is still there, and \ + still carried into every later prompt. Unpin it, then forget it.", + glyphs.dash, + ), + ), + crate::recall::Forgotten::Absent => ( + Tone::Muted, + format!( + "there is no {key} in the {} memory, so nothing was withdrawn", + scope.label() + ), + ), + } +} + /// The key table as this session actually behaves. /// /// **`/help` renders this, never [`KEYS`] directly, and that is the whole point @@ -867,6 +1365,28 @@ pub fn parse(input: &str, keys: &Keys, theme: &Theme) -> Action { // everything after the key rather than the next word, so an array or an // inline table can be typed whole — `allowed_domains = ["a", "b"]` is one // value with a space in it, and splitting on whitespace would take half. + // **The REST of the line, not its second word.** A line of guidance is a + // sentence, and `input.split_whitespace().nth(1)` would remember one word + // of it and drop the rest without saying so — the same trap `/config`'s + // value arm documents, and worse here, because what is lost is prose + // rather than a token somebody would notice missing. + // + // Empty when nothing followed the word. Carried rather than refused, + // because the sentence that says what to type instead belongs to the + // driver — see [`Action::Remember`]. + "remember" => Action::Remember( + input + .split_once(char::is_whitespace) + .map(|(_, rest)| rest.trim()) + .unwrap_or("") + .to_string(), + ), + // **One spelling.** `/remembered`, `/notes` and `/recall` are all words + // somebody might reach for and none of them has been typed at this + // prompt yet; a second name is a name to keep working forever in + // exchange for nothing, which is the rule `/status` and `/compact` + // already follow. + "memory" => Action::Memory, "mcp" | "servers" => Action::Mcp, "provider" | "providers" => Action::Provider, "profile" | "profiles" => Action::Profile, @@ -1005,8 +1525,8 @@ pub fn help(keys: &Keys, theme: &Theme, newline: Newline) -> Vec> "Commands".to_string(), theme.style(Tone::Accent), ))); - // **Grouped, and the groups are the palette's own.** Twenty commands in one - // column is a list nobody reads, and two surfaces disagreeing about how they + // **Grouped, and the groups are the palette's own.** The whole inventory in + // one column is a list nobody reads, and two surfaces disagreeing about how they // are organised is worse than either arrangement — so both render // [`grouped`] and neither holds an order of its own. for (group, rows) in grouped() { diff --git a/src/composer.rs b/src/composer.rs index 2196fcd..09a0895 100644 --- a/src/composer.rs +++ b/src/composer.rs @@ -1,6 +1,6 @@ //! The composer: the one part of the viewport the user types into. //! -//! `tui-textarea` does the editing. What is added here is the small set of +//! [`crate::editor`] does the editing. What is added here is the small set of //! decisions a prompt has that a text editor does not: when `Enter` submits and //! when it inserts a newline, what the arrow keys mean at the edges of the text, //! and how a paste of a whole file behaves. @@ -9,8 +9,8 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::layout::{Position, Rect}; use ratatui::text::Line; use ratatui::Frame; -use tui_textarea::{CursorMove, TextArea}; +use crate::editor::Editor; use crate::theme::{Theme, Tone}; /// The prompt marker. Two cells, so wrapped continuation lines line up under the @@ -239,7 +239,7 @@ fn quoted(path: &str) -> String { } pub struct Composer { - area: TextArea<'static>, + area: Editor, /// Prompts already submitted, oldest first. history: Vec, /// Where the arrow keys currently are in `history`, or `None` when the user @@ -275,12 +275,8 @@ impl Default for Composer { impl Composer { pub fn new() -> Self { - let mut area = TextArea::default(); - // The composer draws its own prompt marker, so the widget must not also - // draw a border, a line number or a cursor-line highlight. - area.set_cursor_line_style(ratatui::style::Style::default()); Self { - area, + area: Editor::new(), history: Vec::new(), recalled: None, draft: String::new(), @@ -348,7 +344,7 @@ impl Composer { /// exits, `Esc` arms the rewind and `/` opens the palette on an empty /// composer, so this decides what three keystrokes mean. /// - /// Asked of [`Composer::text`] rather than of the widget's lines, because + /// Asked of [`Composer::text`] rather than of the editor's lines, because /// those two disagree the moment a paste is not what it looks like. Paste a /// blank region of a file — a run of indentation, a column of empty lines — /// and it is over [`PASTE_THRESHOLD`], so it collapses to a placeholder that @@ -386,12 +382,13 @@ impl Composer { /// /// **One function, because there used to be three answers and they /// disagreed.** `height`, `rows_wanted` and `cursor` each did this - /// arithmetic, and the widget underneath did something else again: a - /// `TextArea` does not wrap, it scrolls sideways. So a prompt long enough to - /// wrap was drawn clipped at the left while io-cli had grown the viewport for - /// rows the widget was never going to use, and the cursor io-cli placed and - /// the block the widget drew sat in two different places — the two cursors - /// 0.13.1 was reported with. Everything visual now comes from here. + /// arithmetic, and the text-editing widget this was built on until 0.18.0 did + /// something else again: it did not wrap, it scrolled sideways. So a prompt + /// long enough to wrap was drawn clipped at the left while io-cli had grown + /// the viewport for rows the widget was never going to use, and the cursor + /// io-cli placed and the block the widget drew sat in two different places — + /// the two cursors 0.13.1 was reported with. Everything visual now comes from + /// here, and [`crate::editor`] deliberately draws nothing at all. /// /// A logical line that is exactly `room` wide takes one row, and the cursor /// past its last character is on the next one. That is what an editor does, @@ -506,7 +503,7 @@ impl Composer { // standing for the block it named. // **Every backwards deletion, not just the plain one.** This used to // exclude `Alt`, so `Option+Backspace` — the delete-word every macOS - // reader has in their fingers — fell through to the widget and ate + // reader has in their fingers — fell through to the editor and ate // `[pasted text #8, 464 chara` one word at a time, leaving a // placeholder that had silently stopped standing for anything. // `Ctrl+W` is the same key by another name. @@ -525,7 +522,7 @@ impl Composer { } _ => { self.editing(); - self.area.input(key); + self.area.key(key); Reply::Idle } } @@ -756,13 +753,14 @@ impl Composer { )), marker, ); - // **The prompt's own rows, wrapped here rather than by the widget.** - // `tui-textarea` scrolls sideways instead of wrapping, and every other - // measurement in this product assumes a wrap — so drawing the widget - // meant a long prompt clipped at the left, a viewport grown for rows - // nothing used, and the widget's own block cursor sitting somewhere other - // than the terminal cursor io-cli had placed. The editing is still the - // widget's; the picture is this crate's. + // **The prompt's own rows, wrapped here rather than by a widget.** + // The text-editing widget this was built on until 0.18.0 scrolled + // sideways instead of wrapping, and every other measurement in this + // product assumes a wrap — so drawing it meant a long prompt clipped at + // the left, a viewport grown for rows nothing used, and the widget's own + // block cursor sitting somewhere other than the terminal cursor io-cli + // had placed. The editing is [`crate::editor`]'s; the picture is this + // function's, and there is no third opinion left to disagree with it. let room = usize::from(text.width).max(1); let (rows, (at_row, _)) = self.wrapped(room); let top = Self::scroll(rows.len(), at_row, usize::from(text.height)); @@ -781,7 +779,7 @@ impl Composer { // is done here rather than left to a caller. ratatui hides the cursor on // any frame that does not set a position, and a hidden cursor removes the // only focus indicator a screen reader has — the criticism the whole - // category is unusable for. Owning it in the widget that owns the + // category is unusable for. Owning it in the type that owns the // insertion point means no frame can forget. // // It is also now the *only* cursor on screen. The widget drew a second @@ -830,7 +828,7 @@ impl Composer { /// named. Which deletion key was pressed does not change that — a word-wise /// delete leaves the same broken half a line as a character-wise one. /// - /// Anything not sitting at the end of a placeholder is the widget's own + /// Anything not sitting at the end of a placeholder is the editor's own /// deletion, whichever one the key means. fn delete_backwards(&mut self, key: KeyEvent) -> Reply { self.editing(); @@ -845,7 +843,7 @@ impl Composer { self.markers.retain(|(held, _)| held != &placeholder); } None => { - self.area.input(key); + self.area.key(key); } } Reply::Idle @@ -965,6 +963,6 @@ impl Composer { self.pastes = pastes; self.markers = markers; self.area.insert_str(text); - self.area.move_cursor(CursorMove::End); + self.area.move_to_end(); } } diff --git a/src/edit.rs b/src/edit.rs index 4908d0b..712e2e5 100644 --- a/src/edit.rs +++ b/src/edit.rs @@ -401,6 +401,29 @@ pub fn value_at(text: &str, path: &str) -> Option { Some(found.to_string()) } +/// Spell a list of strings as a TOML array literal. +/// +/// The escaping is why this lives here and not at the call site. An absolute +/// Windows path is full of backslashes and `\U` opens an escape in a basic +/// string, so `format!("[\"{}\"]", path)` is either a parse error or a +/// different path — and a path that parses to something else is skipped in +/// silence by `config.rs:1886`, which is the quietest failure this crate can +/// ship. `toml`'s own serializer knows the rules; a format string does not. +/// +/// This module is the crate's only TOML speller by rule +/// (`tests/dependencies.rs`), and that rule is about *meaning*, not syntax: +/// building a value is the same kind of act as locating one with +/// [`value_at`] — it is about how a file is spelled, never about what a +/// setting means. So the exception stays exactly as wide as it already was. +#[must_use] +pub fn array(items: &[&str]) -> String { + let values = items + .iter() + .map(|item| toml::Value::String((*item).to_string())) + .collect(); + toml::Value::Array(values).to_string() +} + /// Read `path`, apply every edit, and put it back atomically. /// /// The new bytes go to a temporary file in the same directory and are renamed diff --git a/src/editor.rs b/src/editor.rs new file mode 100644 index 0000000..c43a12e --- /dev/null +++ b/src/editor.rs @@ -0,0 +1,876 @@ +//! The editing model the prompt and the wizard's fields are built on. +//! +//! A buffer of lines, a cursor, a selection anchor and a keymap — and nothing +//! else. It is not a text editor: it is exactly what [`crate::composer`] and +//! [`crate::wizard`] ask for, which is why there is no viewport in it, no +//! syntax, no search and no widget. **Both callers draw their own rows**, the +//! composer because it wraps and the widget it used to hold scrolled sideways +//! instead, the wizard because its field is one row of masked characters. +//! +//! Positions are `(row, column)` in **characters**, never bytes. A pasted path +//! carries a narrow no-break space, a pasted prompt carries whatever the +//! operator copied, and a column that counted bytes would put the caret inside a +//! character and panic on the next edit. +//! +//! The keymap is the readline-shaped one this product shipped with, kept binding +//! for binding so that nothing an operator has in their fingers changed when the +//! buffer underneath did. One group of those bindings is deliberately gone — the +//! four that scrolled a viewport this type does not have — and it says so where +//! they used to be. + +use std::cmp::Ordering; + +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; + +/// What a character counts as when a word-wise key asks where the word ends. +/// +/// Three kinds rather than two, so `fn foo(a)` is the five words `fn`, `foo`, +/// `(`, `a`, `)` — a boundary falls wherever the kind changes, which is what +/// makes `Option+Backspace` in a path stop at the separator. +#[derive(PartialEq, Eq, Clone, Copy)] +enum Kind { + Space, + Punct, + Other, +} + +impl Kind { + fn of(c: char) -> Self { + if c.is_whitespace() { + Self::Space + } else if c.is_ascii_punctuation() { + Self::Punct + } else { + Self::Other + } + } +} + +/// The column the next word starts at, at or after `from`. +fn word_start_forward(line: &str, from: usize) -> Option { + let mut characters = line.chars().enumerate().skip(from); + let mut previous = Kind::of(characters.next()?.1); + for (column, character) in characters { + let kind = Kind::of(character); + if kind != Kind::Space && previous != kind { + return Some(column); + } + previous = kind; + } + None +} + +/// The column one past the end of the word `from` is in. +fn word_end_forward(line: &str, from: usize) -> Option { + let mut characters = line.chars().enumerate().skip(from); + let mut previous = Kind::of(characters.next()?.1); + for (column, character) in characters { + let kind = Kind::of(character); + if previous != Kind::Space && previous != kind { + return Some(column); + } + previous = kind; + } + None +} + +/// The column the word before `from` starts at. +fn word_start_backward(line: &str, from: usize) -> Option { + let end = byte_of(line, from); + let mut characters = line[..end].chars().rev().enumerate(); + let mut kind = Kind::of(characters.next()?.1); + for (back, character) in characters { + let next = Kind::of(character); + if kind != Kind::Space && next != kind { + return Some(from - back); + } + kind = next; + } + (kind != Kind::Space).then_some(0) +} + +/// Where character `column` starts in `line`, or the line's length when the +/// column is past its end — which is where the cursor rests after the last +/// character, and is a position rather than a character. +fn byte_of(line: &str, column: usize) -> usize { + line.char_indices() + .nth(column) + .map(|(at, _)| at) + .unwrap_or(line.len()) +} + +fn width_of(line: &str) -> usize { + line.chars().count() +} + +/// `column`, or the end of `line` when the line is too short to have one there. +/// +/// What `Up` and `Down` do at the edge of a ragged block of text: the column is +/// not remembered between moves, which is what the buffer this replaced did and +/// what every terminal editor with no "goal column" does. +fn fit(column: usize, line: &str) -> usize { + column.min(width_of(line)) +} + +/// Where a movement key wants the cursor. +#[derive(Clone, Copy)] +enum Move { + Forward, + Back, + Up, + Down, + Head, + End, + Top, + Bottom, + WordForward, + WordBack, + ParagraphForward, + ParagraphBack, +} + +/// How many steps `Ctrl+U` can walk back through. +/// +/// The number `TextArea` shipped with (`tui-textarea-0.7.0/src/textarea.rs:219`), +/// and it means the same thing here that it meant there: one step per mutating +/// operation, one character at a time while typing, so fifty presses of `Ctrl+U` +/// reach back over fifty typed characters. +// ponytail: a step is a copy of the whole buffer, so the ceiling is fifty times +// the prompt rather than fifty small edits — a megabyte of pasted text edited +// fifty times is fifty megabytes held. That is the right trade for a prompt of a +// few lines and a one-row credential field, and it is the *only* thing that +// makes this small enough to be obviously correct. The upgrade path, if a prompt +// ever gets big enough for it to matter, is the operation-based history +// tui-textarea had: `tui-textarea-0.7.0/src/history.rs:5-90`, an enum of +// invertible edits, roughly ten times this code. +// +// ponytail: coalescing a run of typed characters into one step is the obvious +// other upgrade — it would make `Ctrl+U` take back a word rather than a letter, +// and it would cut what the fifty hold. It is declined on purpose. `TextArea` +// pushed one `Edit` per character with no merging at all +// (`tui-textarea-0.7.0/src/history.rs:141`), so per-character undo is the +// behaviour an operator already has, and this release is not the place to change +// it under them. When somebody actually asks: a `bool` on the editor, one +// condition on the `push_undo` in `edit`, and a line clearing it everywhere a run +// ends — `step`, `copy`, `select_all`, `restore`, and every edit that is not a +// typed character. +const HISTORY: usize = 50; + +/// One state the buffer was in, and everything `Ctrl+U` puts back. +/// +/// The cursor rides with the text because text alone is not the state an +/// operator left: a buffer that comes back with the caret somewhere else is its +/// own defect, and the next keystroke would then land in the wrong place. +struct Step { + lines: Vec, + cursor: (usize, usize), +} + +/// A buffer of lines with one cursor in it. +/// +/// There is always at least one line, and the cursor is always a valid position +/// in it — every method below restores both before it returns, which is what +/// lets the callers index with `cursor()` without checking. +pub struct Editor { + lines: Vec, + /// `(row, column)`, in characters. The column may be one past the last + /// character of its row: that is where the next one goes. + cursor: (usize, usize), + /// Where a selection started, or `None` when nothing is selected. The other + /// end is always the cursor. + anchor: Option<(usize, usize)>, + /// What the last cut or word-wise delete took, as the lines it spanned. One + /// element is a piece of a line; more than one is a run that crossed a + /// newline, and it goes back the same shape it came out. + yank: Vec, + /// The character every character is drawn as, for a field that must not echo + /// what is typed into it. Masking is a property of the *drawing* — the + /// buffer still holds what was typed, and [`Editor::lines`] still hands it + /// back, because the wizard has to read the credential it is not allowed to + /// show. + /// + /// **It is not part of a [`Step`]**, which is why undo cannot lose it: a + /// masked field that came back unmasked one press of `Ctrl+U` after an API + /// key was typed into it would put the credential on the operator's screen. + mask: Option, + /// States to go back to, oldest first, newest last. Capped at [`HISTORY`]. + undone: Vec, + /// States [`Editor::undo`] stepped out of, newest last. Cleared by any new + /// edit: once the text has moved somewhere else, there is no forward left to + /// go to, and offering one would put back text the operator has since + /// replaced. + redone: Vec, + /// Whether an edit is already being recorded. + /// + /// The mutating methods compose — `delete_char` reaches `delete_newline`, + /// `delete_line_by_end` reaches `delete_next_char` reaches `delete_char` — + /// and each of them is wrapped. Without this, one keystroke would leave + /// three steps behind and cost three presses of `Ctrl+U` to take back. + recording: bool, +} + +impl Default for Editor { + fn default() -> Self { + Self::new() + } +} + +impl Editor { + pub fn new() -> Self { + Self { + lines: vec![String::new()], + cursor: (0, 0), + anchor: None, + yank: Vec::new(), + mask: None, + undone: Vec::new(), + redone: Vec::new(), + recording: false, + } + } + + /// An editor whose characters are drawn as `mask` rather than as themselves. + pub fn masked(mask: char) -> Self { + Self { + mask: Some(mask), + ..Self::new() + } + } + + /// The text, line by line, exactly as it was typed. Never empty. + pub fn lines(&self) -> &[String] { + &self.lines + } + + /// The insertion point as `(row, column)`, in characters. + pub fn cursor(&self) -> (usize, usize) { + self.cursor + } + + /// The cursor's row as it should be **drawn** — masked, when a mask is set. + /// + /// One mask character per character of text, so a caller placing a caret at + /// `cursor().1` lands on the same cell either way. + pub fn shown(&self) -> String { + let line = self + .lines + .get(self.cursor.0) + .map(String::as_str) + .unwrap_or(""); + match self.mask { + Some(mask) => std::iter::repeat_n(mask, width_of(line)).collect(), + None => line.to_string(), + } + } + + /// Whether a selection is in progress. + pub fn selecting(&self) -> bool { + self.selection().is_some() + } + + /// The selection as `(start, end)`, ordered, or `None` when nothing is + /// selected or the two ends are the same position. + fn selection(&self) -> Option<((usize, usize), (usize, usize))> { + let anchor = self.anchor?; + match anchor.cmp(&self.cursor) { + Ordering::Less => Some((anchor, self.cursor)), + Ordering::Equal => None, + Ordering::Greater => Some((self.cursor, anchor)), + } + } + + /// Select everything, cursor at the end of the text. + pub fn select_all(&mut self) { + let row = self.lines.len() - 1; + self.cursor = (row, width_of(&self.lines[row])); + self.anchor = Some((0, 0)); + } + + /// Delete the selection and keep it, so it can be put back. + pub fn cut(&mut self) -> bool { + self.edit(|editor| editor.delete_selection(true)) + } + + // --- undo and redo --- + + /// Go back one step. `false` when there is nothing to go back to. + /// + /// `Ctrl+U` in this product's keymap. It is bound here rather than in + /// [`crate::composer`] because that is where it has always been: io-cli + /// forwards raw crossterm events into the buffer, and the buffer this one + /// replaced bound the key itself at + /// `tui-textarea-0.7.0/src/textarea.rs:576-581`. + /// An operator has had this key working since the first release, and the + /// composer intercepts neither `u` nor `r` on the way here. + pub fn undo(&mut self) -> bool { + match self.undone.pop() { + Some(step) => { + let left = self.restore(step); + self.redone.push(left); + true + } + // Nothing to undo is a no-op, not a panic and not a cursor that + // jumps: an operator who presses `Ctrl+U` on an empty prompt keeps + // the caret they had. + None => false, + } + } + + /// Go forward one step, undoing an undo. `false` when there is no forward. + /// + /// `Ctrl+R` in this product's keymap, for the same reason [`Editor::undo`] + /// is `Ctrl+U` — `tui-textarea-0.7.0/src/textarea.rs:582-587`. + pub fn redo(&mut self) -> bool { + match self.redone.pop() { + Some(step) => { + let left = self.restore(step); + self.push_undo(left); + true + } + None => false, + } + } + + /// Put `step` back, handing out the state it displaced so the caller can + /// stack it on the other side. + fn restore(&mut self, step: Step) -> Step { + let left = Step { + lines: std::mem::replace(&mut self.lines, step.lines), + cursor: self.cursor, + }; + self.cursor = step.cursor; + // The selection is cancelled rather than restored, which is what + // `TextArea::undo` did (`tui-textarea-0.7.0/src/textarea.rs:1558`). A + // selection that came back over text that has just changed underneath it + // would delete the wrong span on the next keystroke. + self.anchor = None; + left + } + + fn push_undo(&mut self, step: Step) { + if self.undone.len() == HISTORY { + self.undone.remove(0); + } + self.undone.push(step); + } + + /// Run one mutating operation, recording the state it started from. + /// + /// One step per operation, and a typed character is an operation — which is + /// what `TextArea` did (`tui-textarea-0.7.0/src/history.rs:141`), so + /// `Ctrl+U` steps back one character at a time exactly as it always has. + /// + /// The snapshot is taken **before** `action`, because that is the state + /// `Ctrl+U` has to put back — after is too late, the text is already gone. + /// It is kept only if the text actually changed: `Backspace` at the head of + /// the buffer and `Delete` at its end do nothing, and a step for one of them + /// is a press of `Ctrl+U` that appears to have no effect at all. + /// + /// A nested call belongs to the outer one's step. See [`Editor::recording`]. + fn edit(&mut self, action: impl FnOnce(&mut Self) -> T) -> T { + if self.recording { + return action(self); + } + let before = Step { + lines: self.lines.clone(), + cursor: self.cursor, + }; + self.recording = true; + let out = action(self); + self.recording = false; + + if self.lines == before.lines { + return out; + } + self.push_undo(before); + self.redone.clear(); + out + } + + /// Keep the selection without deleting it. + fn copy(&mut self) { + if let Some((start, end)) = self.selection() { + let taken = self.spanned(start, end); + self.yank = taken; + } + self.anchor = None; + } + + /// Put back what the last cut or word-wise delete took. + fn put(&mut self) { + self.edit(|editor| { + editor.delete_selection(false); + match editor.yank.len() { + 0 => {} + 1 => { + let piece = editor.yank[0].clone(); + editor.insert_piece(&piece); + } + _ => { + let chunk = editor.yank.clone(); + editor.insert_chunk(&chunk); + } + } + }); + } + + /// Insert `text` at the cursor, newlines and all. + /// + /// Both `\n` and `\r\n` open a line; a bare `\r` does not, because a + /// terminal that sends one means a carriage return rather than a paragraph. + pub fn insert_str(&mut self, text: impl AsRef) { + self.edit(move |editor| { + editor.delete_selection(false); + let pieces: Vec<&str> = text + .as_ref() + .split('\n') + .map(|piece| piece.strip_suffix('\r').unwrap_or(piece)) + .collect(); + match pieces.len() { + 0 => {} + 1 => editor.insert_piece(pieces[0]), + _ => editor.insert_chunk(&pieces), + } + }); + } + + /// Open a line at the cursor, carrying the rest of the row down with it. + pub fn insert_newline(&mut self) { + self.edit(|editor| { + editor.delete_selection(false); + let (row, column) = editor.cursor; + let at = byte_of(&editor.lines[row], column); + let carried = editor.lines[row][at..].to_string(); + editor.lines[row].truncate(at); + editor.lines.insert(row + 1, carried); + editor.cursor = (row + 1, 0); + }); + } + + /// Move the cursor to the end of the row it is on. + pub fn move_to_end(&mut self) { + self.step(Move::End, false); + } + + /// Delete the character before the cursor, or the newline when the cursor is + /// at the head of a row. A selection is deleted instead. + pub fn delete_char(&mut self) -> bool { + self.edit(|editor| { + if editor.delete_selection(false) { + return true; + } + let (row, column) = editor.cursor; + if column == 0 { + return editor.delete_newline(); + } + let at = byte_of(&editor.lines[row], column - 1); + let to = byte_of(&editor.lines[row], column); + editor.lines[row].drain(at..to); + editor.cursor.1 -= 1; + true + }) + } + + // --- everything below is reached through `key` --- + + fn insert_char(&mut self, character: char) { + if character == '\n' || character == '\r' { + self.insert_newline(); + return; + } + self.edit(move |editor| { + editor.delete_selection(false); + let (row, column) = editor.cursor; + let at = byte_of(&editor.lines[row], column); + editor.lines[row].insert(at, character); + editor.cursor.1 += 1; + }); + } + + fn insert_piece(&mut self, piece: &str) { + if piece.is_empty() { + return; + } + let (row, column) = self.cursor; + let at = byte_of(&self.lines[row], column); + self.lines[row].insert_str(at, piece); + self.cursor.1 += width_of(piece); + } + + fn insert_chunk(&mut self, chunk: &[impl AsRef]) { + debug_assert!(chunk.len() > 1, "a chunk is more than one line"); + let (row, column) = self.cursor; + let at = byte_of(&self.lines[row], column); + let carried = self.lines[row][at..].to_string(); + self.lines[row].truncate(at); + self.lines[row].push_str(chunk[0].as_ref()); + + let last = chunk.len() - 1; + let mut opened: Vec = chunk[1..last] + .iter() + .map(|line| line.as_ref().to_string()) + .collect(); + opened.push(format!("{}{carried}", chunk[last].as_ref())); + self.lines.splice(row + 1..row + 1, opened); + self.cursor = (row + last, width_of(chunk[last].as_ref())); + } + + /// A tab is spaces to the next stop, never a tab character: the composer + /// measures its wrapped rows in characters, and one character that draws as + /// four columns is a row whose width nothing downstream agrees on. + fn insert_tab(&mut self) { + self.edit(|editor| { + editor.delete_selection(false); + const STOP: usize = 4; + let (row, column) = editor.cursor; + // ponytail: the stop is counted in characters, not display columns, + // so a line of double-width characters indents to the wrong stop. + // Count `unicode-width` here if the prompt ever grows a real indent. + let taken = column.min(width_of(&editor.lines[row])); + editor.insert_piece(&" ".repeat(STOP - taken % STOP)); + }); + } + + /// Join this row onto the one above it. + fn delete_newline(&mut self) -> bool { + self.edit(|editor| { + if editor.delete_selection(false) { + return true; + } + let (row, _) = editor.cursor; + if row == 0 { + return false; + } + let joined = editor.lines.remove(row); + editor.cursor = (row - 1, width_of(&editor.lines[row - 1])); + editor.lines[row - 1].push_str(&joined); + true + }) + } + + fn delete_next_char(&mut self) -> bool { + self.edit(|editor| { + if editor.delete_selection(false) { + return true; + } + let before = editor.cursor; + editor.step(Move::Forward, false); + if before == editor.cursor { + return false; + } + editor.delete_char() + }) + } + + fn delete_line_by_end(&mut self) { + self.edit(|editor| { + // A selection is deleted rather than yanked, which is what the + // buffer this replaced did: the keep-and-put-back is for the word or + // the run of line the key names, never for whatever happened to be + // selected. + if editor.delete_selection(false) { + return; + } + let (row, column) = editor.cursor; + let end = width_of(&editor.lines[row]); + if column < end { + editor.delete_range((row, column), (row, end), true); + return; + } + editor.delete_next_char(); + }); + } + + fn delete_line_by_head(&mut self) { + self.edit(|editor| { + // A selection is deleted rather than yanked, which is what the + // buffer this replaced did: the keep-and-put-back is for the word or + // the run of line the key names, never for whatever happened to be + // selected. + if editor.delete_selection(false) { + return; + } + let (row, column) = editor.cursor; + if column > 0 { + editor.delete_range((row, 0), (row, column), true); + return; + } + editor.delete_newline(); + }); + } + + fn delete_word(&mut self) { + self.edit(|editor| { + // A selection is deleted rather than yanked, which is what the + // buffer this replaced did: the keep-and-put-back is for the word or + // the run of line the key names, never for whatever happened to be + // selected. + if editor.delete_selection(false) { + return; + } + let (row, column) = editor.cursor; + match word_start_backward(&editor.lines[row], column) { + Some(start) if start < column => { + editor.delete_range((row, start), (row, column), true) + } + Some(_) => {} + None if column > 0 => editor.delete_range((row, 0), (row, column), true), + None => { + editor.delete_newline(); + } + } + }); + } + + fn delete_next_word(&mut self) { + self.edit(|editor| { + // A selection is deleted rather than yanked, which is what the + // buffer this replaced did: the keep-and-put-back is for the word or + // the run of line the key names, never for whatever happened to be + // selected. + if editor.delete_selection(false) { + return; + } + let (row, column) = editor.cursor; + let end = width_of(&editor.lines[row]); + match word_end_forward(&editor.lines[row], column) { + Some(stop) if stop > column => { + editor.delete_range((row, column), (row, stop), true) + } + Some(_) => {} + None if column < end => editor.delete_range((row, column), (row, end), true), + None if row + 1 < editor.lines.len() => { + editor.cursor = (row + 1, 0); + editor.delete_newline(); + } + None => {} + } + }); + } + + /// The text between two positions, as the lines it spans. + fn spanned(&self, start: (usize, usize), end: (usize, usize)) -> Vec { + let ((from_row, from_column), (to_row, to_column)) = (start, end); + let from = byte_of(&self.lines[from_row], from_column); + let to = byte_of(&self.lines[to_row], to_column); + if from_row == to_row { + return vec![self.lines[from_row][from..to].to_string()]; + } + let mut taken = vec![self.lines[from_row][from..].to_string()]; + taken.extend(self.lines[from_row + 1..to_row].iter().cloned()); + taken.push(self.lines[to_row][..to].to_string()); + taken + } + + /// Remove everything between two positions, cursor left at the start. + fn delete_range(&mut self, start: (usize, usize), end: (usize, usize), should_yank: bool) { + if should_yank { + let taken = self.spanned(start, end); + self.yank = taken; + } + let ((from_row, from_column), (to_row, to_column)) = (start, end); + let from = byte_of(&self.lines[from_row], from_column); + let to = byte_of(&self.lines[to_row], to_column); + self.cursor = start; + self.anchor = None; + if from_row == to_row { + self.lines[from_row].drain(from..to); + return; + } + let carried = self.lines[to_row][to..].to_string(); + self.lines[from_row].truncate(from); + self.lines[from_row].push_str(&carried); + self.lines.drain(from_row + 1..=to_row); + } + + /// Delete the selection, if there is one. Either way the selection ends. + fn delete_selection(&mut self, should_yank: bool) -> bool { + let range = self.selection(); + self.anchor = None; + match range { + Some((start, end)) => { + self.delete_range(start, end, should_yank); + true + } + None => false, + } + } + + /// Where `movement` wants the cursor, or `None` when there is nowhere to go. + /// + /// `None` is not the same as "stayed put": a movement that cannot happen + /// leaves a selection alone, which is what keeps `Shift+Left` at the head of + /// the text from silently dropping what is already selected. + fn next_cursor(&self, movement: Move) -> Option<(usize, usize)> { + let (row, column) = self.cursor; + match movement { + Move::Forward if column >= width_of(&self.lines[row]) => { + (row + 1 < self.lines.len()).then_some((row + 1, 0)) + } + Move::Forward => Some((row, column + 1)), + Move::Back if column == 0 => { + let row = row.checked_sub(1)?; + Some((row, width_of(&self.lines[row]))) + } + Move::Back => Some((row, column - 1)), + Move::Up => { + let row = row.checked_sub(1)?; + Some((row, fit(column, &self.lines[row]))) + } + Move::Down => Some((row + 1, fit(column, self.lines.get(row + 1)?))), + Move::Head => Some((row, 0)), + Move::End => Some((row, width_of(&self.lines[row]))), + Move::Top => Some((0, fit(column, &self.lines[0]))), + Move::Bottom => { + let row = self.lines.len() - 1; + Some((row, fit(column, &self.lines[row]))) + } + Move::WordForward => match word_start_forward(&self.lines[row], column) { + Some(start) => Some((row, start)), + None if row + 1 < self.lines.len() => Some((row + 1, 0)), + None => Some((row, width_of(&self.lines[row]))), + }, + Move::WordBack => match word_start_backward(&self.lines[row], column) { + Some(start) => Some((row, start)), + None if row > 0 => Some((row - 1, width_of(&self.lines[row - 1]))), + None => Some((row, 0)), + }, + Move::ParagraphForward => { + let mut blank = self.lines[row].is_empty(); + for next in row + 1..self.lines.len() { + let now = self.lines[next].is_empty(); + if !now && blank { + return Some((next, fit(column, &self.lines[next]))); + } + blank = now; + } + let last = self.lines.len() - 1; + Some((last, fit(column, &self.lines[last]))) + } + Move::ParagraphBack => { + let from = row.checked_sub(1)?; + let mut blank = self.lines[from].is_empty(); + for next in (0..from).rev() { + let now = self.lines[next].is_empty(); + if now && !blank { + return Some((next + 1, fit(column, &self.lines[next + 1]))); + } + blank = now; + } + Some((0, fit(column, &self.lines[0]))) + } + } + } + + /// Take a movement, extending the selection when `shift` is held and ending + /// it when it is not. + fn step(&mut self, movement: Move, shift: bool) { + let Some(next) = self.next_cursor(movement) else { + return; + }; + if shift { + if self.anchor.is_none() { + self.anchor = Some(self.cursor); + } + } else { + self.anchor = None; + } + self.cursor = next; + } + + /// Feed a key. + /// + /// **The keymap is the whole of what an operator can press here**, so it is + /// written out rather than derived: every arm below was a binding this + /// product already had, and the order between them is load-bearing — + /// `Ctrl+Left` is a word-wise move only because the arm that reads `Left` + /// bare has already declined it. + pub fn key(&mut self, key: KeyEvent) { + // A terminal with the Kitty keyboard protocol on — and Windows, always — + // reports the release of every key it reported the press of. Acting on + // both is every character typed twice. + if key.kind == KeyEventKind::Release { + return; + } + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + let alt = key.modifiers.contains(KeyModifiers::ALT); + let shift = key.modifiers.contains(KeyModifiers::SHIFT); + match key.code { + KeyCode::Enter => self.insert_newline(), + KeyCode::Char('m') if ctrl && !alt => self.insert_newline(), + // Shift is not consulted: a capital letter arrives as the capital + // with the modifier still set, and reading it here would refuse to + // type one. + KeyCode::Char(character) if !ctrl && !alt => self.insert_char(character), + KeyCode::Tab if !ctrl && !alt => self.insert_tab(), + KeyCode::Backspace if !ctrl && !alt => { + self.delete_char(); + } + KeyCode::Char('h') if ctrl && !alt => { + self.delete_char(); + } + KeyCode::Delete if !ctrl && !alt => { + self.delete_next_char(); + } + KeyCode::Char('d') if ctrl && !alt => { + self.delete_next_char(); + } + KeyCode::Char('k') if ctrl && !alt => self.delete_line_by_end(), + KeyCode::Char('j') if ctrl && !alt => self.delete_line_by_head(), + KeyCode::Char('w') if ctrl && !alt => self.delete_word(), + KeyCode::Char('h') if !ctrl && alt => self.delete_word(), + KeyCode::Backspace if !ctrl && alt => self.delete_word(), + KeyCode::Delete if !ctrl && alt => self.delete_next_word(), + KeyCode::Char('d') if !ctrl && alt => self.delete_next_word(), + KeyCode::Down if !ctrl && !alt => self.step(Move::Down, shift), + KeyCode::Char('n') if ctrl && !alt => self.step(Move::Down, shift), + KeyCode::Up if !ctrl && !alt => self.step(Move::Up, shift), + KeyCode::Char('p') if ctrl && !alt => self.step(Move::Up, shift), + KeyCode::Right if !ctrl && !alt => self.step(Move::Forward, shift), + KeyCode::Char('f') if ctrl && !alt => self.step(Move::Forward, shift), + KeyCode::Left if !ctrl && !alt => self.step(Move::Back, shift), + KeyCode::Char('b') if ctrl && !alt => self.step(Move::Back, shift), + // `Home` and `End` mean what they say whatever is held with them. + KeyCode::Char('a') if ctrl && !alt => self.step(Move::Head, shift), + KeyCode::Home => self.step(Move::Head, shift), + KeyCode::Left | KeyCode::Char('b') if ctrl && alt => self.step(Move::Head, shift), + KeyCode::Char('e') if ctrl && !alt => self.step(Move::End, shift), + KeyCode::End => self.step(Move::End, shift), + KeyCode::Right | KeyCode::Char('f') if ctrl && alt => self.step(Move::End, shift), + KeyCode::Char('<') if !ctrl && alt => self.step(Move::Top, shift), + KeyCode::Up | KeyCode::Char('p') if ctrl && alt => self.step(Move::Top, shift), + KeyCode::Char('>') if !ctrl && alt => self.step(Move::Bottom, shift), + KeyCode::Down | KeyCode::Char('n') if ctrl && alt => self.step(Move::Bottom, shift), + KeyCode::Char('f') if !ctrl && alt => self.step(Move::WordForward, shift), + KeyCode::Right if ctrl && !alt => self.step(Move::WordForward, shift), + KeyCode::Char('b') if !ctrl && alt => self.step(Move::WordBack, shift), + KeyCode::Left if ctrl && !alt => self.step(Move::WordBack, shift), + KeyCode::Char(']' | 'n') if !ctrl && alt => self.step(Move::ParagraphForward, shift), + KeyCode::Down if ctrl && !alt => self.step(Move::ParagraphForward, shift), + KeyCode::Char('[' | 'p') if !ctrl && alt => self.step(Move::ParagraphBack, shift), + KeyCode::Up if ctrl && !alt => self.step(Move::ParagraphBack, shift), + KeyCode::Char('y') if ctrl && !alt => self.put(), + KeyCode::Char('x') if ctrl && !alt => { + self.cut(); + } + KeyCode::Char('c') if ctrl && !alt => self.copy(), + // `Ctrl+U` and `Ctrl+R` are not this crate's invention and are not + // optional: the buffer this replaced bound them itself + // (`tui-textarea-0.7.0/src/textarea.rs:576-587`), io-cli forwards raw + // crossterm events straight into it, and neither `crate::composer` + // nor `crate::wizard` takes `u` or `r` on the way. An operator has + // had undo in the prompt and in the credential field since the first + // release; dropping the keys here would take it away silently. + KeyCode::Char('u') if ctrl && !alt => { + self.undo(); + } + KeyCode::Char('r') if ctrl && !alt => { + self.redo(); + } + // ponytail: `Ctrl+V`, `Alt+V`, `PageUp` and `PageDown` scrolled a + // viewport this type does not have. Both callers draw their own rows + // and scroll them themselves, so these were already inert. + _ => {} + } + } +} diff --git a/src/home.rs b/src/home.rs index d1314db..79fecf2 100644 --- a/src/home.rs +++ b/src/home.rs @@ -39,6 +39,16 @@ const FILE: &str = "io.toml"; /// only way an operator finds out where to put a skill without reading a document. const SKILLS: &str = "skills"; +/// The operator's own guidance file, beside the configuration file it belongs to. +/// +/// Named here rather than in [`crate::memory`] because the name is a fact about +/// this directory: it is what sits next to [`FILE`], it is io-cli's own and not +/// io-harness's, and an operator backing the home up gets it with everything +/// else. Deliberately **not** created with the home — an empty guidance file is +/// a file the operator has to wonder about, and [`crate::memory::remember`] +/// makes it with a header the moment there is a first line to put in it. +pub(crate) const MEMORY: &str = "IO.md"; + /// The store and the two files SQLite keeps beside it. /// /// The siblings are not decoration. A `runs.db` moved without its `-wal` is a @@ -325,7 +335,13 @@ pub fn adopt() -> Option { /// The configuration file inside it already carries a credential and is written /// `0600` by [`crate::settings::write`]; a world-readable directory around it is /// the same mistake one level up. -fn create(home: &Path) -> std::io::Result<()> { +/// +/// Reachable from [`crate::memory`] as well, which writes `IO.md` into this +/// directory and would otherwise have to make it with a bare `create_dir_all` — +/// two answers to what mode io-cli's home has, one of them from a module that +/// has no business deciding. The mode applies only to directories this call +/// actually creates, so passing an existing one changes nothing. +pub(crate) fn create(home: &Path) -> std::io::Result<()> { #[cfg(unix)] { use std::os::unix::fs::DirBuilderExt; diff --git a/src/keys.rs b/src/keys.rs index 3cb70bb..34f5710 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -348,9 +348,12 @@ impl Action { Self::Clear => "ctrl+l", Self::Transcript => "ctrl+t", Self::Rewind => "esc esc", - // It displaces `tui-textarea`'s forward-char, which the right arrow + // It displaces the composer's forward-char, which the right arrow // already does — the same trade `Ctrl+T` already makes against its - // transpose-chars, and the reason both are rebindable. + // transpose-chars, and the reason both are rebindable. The keymap it + // displaces is `crate::editor`'s own since 0.18.0; it was + // `tui-textarea`'s when this note was written, and the trade is + // identical because that keymap was reproduced arm for arm. Self::Fleet => "ctrl+f", } } diff --git a/src/lib.rs b/src/lib.rs index 679a0f2..e828f4f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -43,6 +43,7 @@ pub mod context; pub mod contract; pub mod diff; pub mod edit; +pub mod editor; pub mod events; pub mod exec; pub mod failure; @@ -53,12 +54,15 @@ pub mod home; pub mod intent; pub mod keys; pub mod markdown; +pub mod memory; pub mod picker; pub mod picture; pub mod plan; pub mod provider; pub mod providers; pub mod queue; +pub mod recall; +pub mod reload; pub mod rewind; pub mod servers; pub mod sessions; diff --git a/src/main.rs b/src/main.rs index c1b9900..9c784b4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -216,6 +216,12 @@ async fn run(report: &mut Vec) -> Result { config, theme, cli.model, + // **The NAME, not just the overlay it produced.** The configuration above + // already carries `--profile`, and from 0.18.0 the session re-reads the + // file at every turn boundary — a fresh `Config::discover` with no + // overlay on it. Without the name to re-apply, a flag that says *for this + // run* would quietly stop meaning anything after the first prompt. + cli.profile, plain, // Taken, not borrowed: from here the session owns the report and `main` has // nothing left to say on its behalf. @@ -239,6 +245,11 @@ async fn drive( config: Config, theme: Theme, model_override: Option, + // The `--profile` this run was started with, by name. `config` already + // carries its overlay; the name is what re-applies it after the reload at + // each turn boundary, which goes back to the file and knows nothing about a + // flag. `/profile` replaces it for the rest of the session. + profile: Option, // Threaded down from `run` rather than read out of `config` again here, the // way `diff_style` below is. `--plain` is a flag, the flag outranks the file, // and a second read of the file at this depth would be a second answer to a @@ -353,6 +364,7 @@ async fn drive( containment, capabilities, skills, + profile, }, ) .await? @@ -404,6 +416,10 @@ struct Interactive<'a, 'b> { /// at startup. Empty when nothing is configured and empty when the walk /// failed — the notice above is what tells those two apart. skills: io_harness::Skills, + /// The named profile in force, or `None`. Carried as a name because the + /// turn-boundary reload discovers the file afresh and would otherwise drop + /// the overlay it produced. + profile: Option, } impl provider::WithProvider for Interactive<'_, '_> { @@ -433,6 +449,7 @@ impl provider::WithProvider for Interactive<'_, '_> { self.containment, self.capabilities, self.skills, + self.profile, model, ) .await @@ -469,6 +486,10 @@ async fn loop_over Result>( // the session moved on. mut capabilities: io_cli::contract::Capabilities, skills: io_harness::Skills, + // The named profile in force. `--profile` seeds it and `/profile` replaces + // it; it is re-applied after every turn-boundary reload, which goes back to + // the file and knows nothing about either. + mut profile: Option, model: String, ) -> Result<(), String> { // Every request the session makes goes past this, and it is the only way @@ -565,6 +586,30 @@ async fn loop_over Result>( let mut planning = false; // One `/compact` typed at an idle prompt, spent by the next turn that starts. let mut fold_next = false; + // **The file, held so it can be read again — which through 0.17.0 it never + // was.** io-harness composes the instruction files inside `Config::discover` + // and stores the result privately; there is no `Config::reload`, so a + // `Config` is exactly as old as the call that made it. A repository whose + // `AGENTS.md` changed mid-session therefore reached no turn at all, and + // 0.18.0 adds `/remember`, which writes those very files — turning a + // papered-over annoyance into a surface that would lie about its own effect. + // + // Built from the `Config` the driver already holds rather than by + // discovering again here, which is [`io_cli::reload::Configuration::new`]'s + // own rule: `run` applies `--profile` before anything reads the + // configuration, and a fresh discovery inside the constructor would silently + // drop that overlay. + // + // `settings::stored` is a pure function of that same value, so asking it a + // second time is the same answer rather than a second one — and the notice + // it can carry has already been disclosed by `drive`, which is where that + // read is documented as *the read that discloses*. + let (settings_in_force, _) = settings::stored(&config); + let mut configuration = io_cli::reload::Configuration::new( + session.root().to_path_buf(), + config.clone(), + settings_in_force, + ); if let Some(caps) = &containment { let notice = settings::contained_notice(caps, app.theme.glyphs.dash); app.say(Tone::Muted, notice); @@ -827,6 +872,15 @@ async fn loop_over Result>( capabilities = io_cli::contract::Capabilities::stored(stored.as_ref()); config = overlaid; + // **The name, kept.** The overlay above + // is in force until the next turn + // boundary re-reads the file, which + // discovers it afresh and knows nothing + // about a profile. Without this the + // sentence below would be true for + // exactly as long as nobody pressed + // Enter. + profile = Some(name.clone()); app.record( Tone::Success, format!( @@ -889,6 +943,139 @@ async fn loop_over Result>( } } } + // The line goes into the file the row names, and nothing + // was written until now. An index past the end writes + // nothing, which is the answer every other picker arm + // gives a row it cannot resolve. + Pick::RememberScope { line, paths } => { + if let Some((scope, _)) = paths.get(index) { + let root = session.root().to_path_buf(); + match io_cli::memory::remember(&root, *scope, line) { + Ok(at) => { + app.record( + Tone::Success, + format!("remembered in {}", at.display()), + ); + // **Writing the file is not enough, and + // this is where that is settled.** + // `read_instructions` joins each name in + // `[instructions] files` to the discovery + // root and the default list is exactly + // `["AGENTS.md"]`, so `AGENTS.local.md` + // is read only where a file names it and + // `IO.md` — which lives in io-cli's home, + // not the workspace — is not reachable by + // a bare name at all. A `/remember` that + // wrote one of those two and stopped + // would be a surface reporting a change + // no run will ever see. + match io_cli::memory::install(&root) { + // It changed the configuration, so it + // is said. A write into `io.toml` on + // the operator's behalf is not + // something to do silently. + Ok(true) => app.record( + Tone::Muted, + "`[instructions] files` now names all three, so \ + the next turn reads them", + ), + // Already exactly right. Not one byte + // written and nothing to report — this + // is reached from a command an + // operator types repeatedly. + Ok(false) => {} + Err(error) => app.record(Tone::Error, error), + } + } + // The line is the operator's own and the + // refusal explains a boundary, so it outlives + // the keystroke that earned it. + Err(refusal) => app.record(Tone::Refused, refusal), + } + } + } + // The memory page. Every row stands for something the + // parallel `held` vector names — see `commands::Held` — + // rather than for a label read back, which the picker's + // own fitter may have shortened. + Pick::Memory { held } => match held.get(index) { + // An instruction file says what it is. There is + // nothing to open: the file is markdown an operator + // edits in their own editor, and `/remember` is the + // one writer this product gives it. + Some(io_cli::commands::Held::File(file)) => app.record( + Tone::Muted, + io_cli::commands::instruction_said(file, &app.theme.glyphs), + ), + // A note has two verbs on it and a picker has one + // Enter, so the row descends into them — the same + // replace-in-place `Pick::Complete` uses, and the + // same two-step `/config` takes to reach a scope. + Some(io_cli::commands::Held::Note { scope, key, pinned }) => { + descended = Some(( + Picker::new( + format!("{key}?"), + io_cli::commands::verb_rows(*pinned), + ), + Pick::Remembered { + scope: *scope, + key: key.clone(), + verbs: io_cli::commands::Verb::of(*pinned).to_vec(), + }, + )); + } + // A heading, or an index past the end. A heading + // cannot be under the marker — `Picker::refilter` + // admits one only while nothing is typed and nothing + // can be chosen while one is there — so this is the + // structural answer rather than a case an operator + // reaches. + Some(io_cli::commands::Held::Nothing) | None => {} + }, + // The verb, applied. Both wrappers report through + // `io_cli::commands`, so what an outcome is called is a + // decision a test can stand on rather than a string in a + // file nothing links. + Pick::Remembered { scope, key, verbs } => { + if let Some(verb) = verbs.get(index) { + let root = session.root().to_path_buf(); + match verb { + io_cli::commands::Verb::Pin | io_cli::commands::Verb::Unpin => { + let want = matches!(verb, io_cli::commands::Verb::Pin); + match io_cli::recall::pin(&store, &root, *scope, key, want) + { + Ok(outcome) => { + let (tone, said) = io_cli::commands::pinned_said( + key, *scope, want, outcome, + ); + app.record(tone, said); + } + Err(error) => app.record( + Tone::Error, + format!("{key} was not changed: {error}"), + ), + } + } + io_cli::commands::Verb::Forget => { + match io_cli::recall::forget(&store, &root, *scope, key) { + Ok(outcome) => { + let (tone, said) = io_cli::commands::forgotten_said( + key, + *scope, + outcome, + &app.theme.glyphs, + ); + app.record(tone, said); + } + Err(error) => app.record( + Tone::Error, + format!("{key} was not withdrawn: {error}"), + ), + } + } + } + } + } Pick::Palette => match commands::palette_pick(&templates, &skills, index) { Some(commands::Chosen::Command(command)) => app.composer.set(command), // The rendered template, in the prompt and not on the @@ -1174,6 +1361,85 @@ async fn loop_over Result>( Pick::ConfigScope { key, value, paths }, )); } + // A line typed with nothing after the word. Answered with what to + // type rather than by opening a picker over three files it would + // write nothing into — asking which file a blank line goes in is + // a question with no useful answer. + Action::Remember(line) if line.is_empty() => app.say( + Tone::Muted, + "say what to remember: pick the row, then type the line after it", + ), + // **Which scope is half the decision, and this product has three + // of them** — the same sentence `/config`'s write arm carries, and + // a sharper one here: the three files differ only in who else + // reads them, so a guess is a guess about whether a private note + // is committed. So the same shape: the line waits in the `Pick` + // and nothing is written until a row is chosen. + Action::Remember(line) => { + let root = session.root().to_path_buf(); + // Every scope that has a path on this machine. The user scope + // has none where there is no home to speak of, which is + // `memory::path`'s own answer and not something to invent one + // around. + let paths: Vec<(io_harness::config::Scope, std::path::PathBuf)> = [ + io_harness::config::Scope::Project, + io_harness::config::Scope::Local, + io_harness::config::Scope::User, + ] + .into_iter() + .filter_map(|scope| io_cli::memory::path(&root, scope).map(|p| (scope, p))) + .collect(); + let rows = io_cli::commands::scope_rows(&paths, &app.theme.glyphs); + picker = Some(( + Picker::new("Remember it where?", rows), + Pick::RememberScope { line, paths }, + )); + } + // **One page, two lists, and they are two different memories.** + // The instruction files a person writes, and the notes the agent + // wrote for itself. Every row is built by `io_cli::commands` + // rather than here, because nothing under `tests/` links this + // file — a mark or a sentence written in the driver is one no + // sabotage can reach. + Action::Memory => { + let root = session.root().to_path_buf(); + // Read off the configuration the session is running on, so a + // `[instructions] files` that replaced the list shows here as + // a file that is not read rather than being argued away. + let files = io_cli::memory::view(&root, &config); + // The caps come off `opening` — the contract built at startup + // from the same builder every turn uses — rather than from a + // fifth `contract::session` call, which `tests/contract.rs` + // counts and fails at five. It is the same reason `/compact` + // reads `opening.compaction`. + let remembered = io_cli::recall::view(&store, &root, &opening, None); + // A store that will not answer loses the second list and + // keeps the first. The two halves have nothing to do with + // each other — one is markdown on disk — and dropping the + // page over the half that failed would hide the instruction + // files from the operator who came to check them. + let (entries, cut) = match &remembered { + Ok(view) => { + for note in io_cli::commands::memory_notes(view, &app.theme.glyphs) { + app.record(Tone::Muted, note); + } + (view.entries.clone(), view.draws_cut) + } + Err(error) => { + app.record( + Tone::Error, + format!("the agent's own memory could not be read: {error}"), + ); + (Vec::new(), false) + } + }; + let (rows, held) = + io_cli::commands::memory_page(&files, &entries, cut, &app.theme.glyphs); + picker = Some(( + Picker::new("What io remembers", rows), + Pick::Memory { held }, + )); + } Action::Theme => { picker = Some(( Picker::new( @@ -1612,6 +1878,59 @@ async fn loop_over Result>( // Inside the loop for the same reason it was ever rebuilt: a // queued turn runs under what the operator has allowed by the // time it starts, not by the time they typed it. + // + // **And this is the turn boundary, which is where the file is + // read again.** Here rather than at the keystroke that opened + // a surface, because a turn is the unit a configuration is in + // force *for*: the contract is built inside `turn` from + // `config`, and anything read after that call is read by a + // turn already running under the old one. Inside the queue + // loop for exactly the reason `effective` is — three queued + // prompts are three turns, and each runs under the file as it + // is when it starts. + // + // **A refusal is recorded and not said.** The 0.13.1 rule: + // `App::say` answers a keystroke and is gone at the next one, + // and this answers no keystroke. It says the configuration on + // disk is unreadable and this turn is running on the last one + // that was — which outlives the keystroke and belongs to the + // conversation. `Configuration::refresh` compares against the + // text it last handed back, so a file left broken for six + // prompts says it once rather than six times. + if let Some(refusal) = configuration.refresh() { + app.record(Tone::Refused, refusal); + } + // ponytail: the fresh pair is copied out rather than every + // reader below being moved onto `configuration.config()`. + // One `Config` clone per turn against a dozen call sites and + // two picker arms that assign `config` themselves; move them + // if a turn ever starts often enough for the clone to show. + config = configuration.config().clone(); + // **The overlay, put back.** `configure::reload` discovers the + // file and nothing else, so whatever `--profile` or `/profile` + // chose is not in what came back — and a flag that says *for + // this run* would have quietly stopped meaning anything after + // the first prompt. Re-applied by name, with io-harness's own + // refusal if the file no longer declares it; the name is + // dropped at that point, because a profile edited away is a + // thing to say once rather than to fail on every turn. + if let Some(name) = profile.clone() { + match io_cli::configure::with_profile(&config, &name) { + Ok(overlaid) => config = overlaid, + Err(refusal) => { + profile = None; + app.record(Tone::Refused, refusal); + } + } + } + // Both halves, or the turn below runs on every `[app.io-cli]` + // answer as it was at session start while the rest of the + // session has moved on — the asymmetry `configure::reload`'s + // own doc comment exists to stop. Derived from the overlaid + // `config` rather than from `Configuration::settings`, because + // a profile body can carry `[app.io-cli]` keys too. + let (in_force, _) = settings::stored(&config); + capabilities = io_cli::contract::Capabilities::stored(in_force.as_ref()); let effective = approval::session_policy(&policy, app.posture(), app.remembered()); let stopped = turn( @@ -2780,6 +3099,37 @@ enum Pick { value: String, paths: Vec<(io_harness::config::Scope, std::path::PathBuf)>, }, + /// Which memory file a remembered line goes into, and the line waiting on it. + /// + /// Two steps for the reason [`Pick::ConfigScope`] has two, and a sharper one: + /// the three files differ *only* in who else reads them — a repository, a + /// checkout, this machine — so a write that guessed would be a guess about + /// whether a private note is committed. + RememberScope { + line: String, + paths: Vec<(io_harness::config::Scope, std::path::PathBuf)>, + }, + /// The memory page, in the order `commands::memory_page` drew it. + /// + /// The rows are headings, instruction files and notes interleaved, so a + /// position in either underlying list addresses neither. `held` is the + /// parallel vector that function returns beside the rows — same order, same + /// length, built in the same pass — and it is what a chosen index reads back + /// against. + Memory { + held: Vec, + }, + /// One note, and the verbs offered on it. + /// + /// The bucket is carried rather than re-derived, because a key can exist in + /// both and `recall::pin` and `recall::forget` each take one: a verb applied + /// to the wrong bucket either does nothing or acts on a same-named note the + /// operator was not looking at. + Remembered { + scope: io_cli::recall::Scope, + key: String, + verbs: Vec, + }, } /// The completion picker over one directory of the workspace, or `None` when the diff --git a/src/memory.rs b/src/memory.rs new file mode 100644 index 0000000..8f11256 --- /dev/null +++ b/src/memory.rs @@ -0,0 +1,418 @@ +//! What the operator asks io to remember, written where the next session reads it. +//! +//! There is already a memory in this product and this is not it. io-harness keys +//! a store per run — `EventKind::MemoryWrote`, `[memory] max_entries` — and that +//! one belongs to the agent: it writes it, a rewind takes it back +//! ([`crate::rewind`]), and nobody types into it. This module is the other half, +//! the one a person writes by hand: a line of guidance appended to a markdown +//! file that io-harness reads as an instruction at the start of every run. +//! +//! # One word, one meaning +//! +//! The scope is [`io_harness::config::Scope`], the same three the configuration +//! surface already uses, and deliberately not a second enum that happens to have +//! the same variant names. [`crate::configure::scope_path`] maps them to +//! `io.toml` / `io.toml` / `io.local.toml`; this maps them to `IO.md` / +//! `AGENTS.md` / `AGENTS.local.md`. An operator who has learnt what "local" +//! means once has learnt it for both surfaces, and a second enum is how the two +//! drift until "project" means one thing in `/config` and another in `/memory`. +//! +//! The user scope anchors on [`crate::home::in_force`] rather than on +//! [`crate::home::path`], for the reason that function exists: under `$IO_CONFIG` +//! the configuration file is somewhere io-cli did not choose, and `IO.md` belongs +//! beside the `io.toml` actually in force, not beside the one this crate would +//! have picked. +//! +//! # What io-harness will read back, which is not all three +//! +//! Worth knowing before a surface promises otherwise. `read_instructions` joins +//! each name in `[instructions] files` to the **discovery root** +//! (`io-harness-0.69.0/src/config.rs:1888`), and the default list is exactly +//! `["AGENTS.md"]` (`config.rs:158`). So `AGENTS.md` is read by every project +//! with no configuration at all; `AGENTS.local.md` is read only where a file +//! names it; and `IO.md` is not reachable by a bare name at all, because a +//! relative name is resolved against the workspace and the home is not the +//! workspace. **This module writes the file. Making the harness read it is +//! `[instructions]`' business and the caller's.** [`install`] is where that +//! business is settled, and [`view`] is how a surface reports the outcome +//! honestly rather than optimistically. +//! +//! # Appending, and why it is spelled out +//! +//! Everything here is an append. These files are the operator's own prose — a +//! person wrote them, another agent may have written into them, and a release of +//! this crate rewriting one is a release that eats somebody's notes. So the file +//! is opened for append rather than read-modify-written, and the only byte this +//! module ever adds ahead of its own bullet is a newline the previous author left +//! off. + +use std::io::Write as _; +use std::path::{Path, PathBuf}; + +use io_harness::config::{Config, Scope}; + +/// The committed one. io-harness discovers this name with no configuration at +/// all, which is why it is the scope a `/memory` with no argument should mean. +const PROJECT: &str = "AGENTS.md"; + +/// The uncommitted sibling, named the way `io.local.toml` is named — the same +/// stem with `.local` before the extension, so an operator who has seen one +/// spelling can guess the other. +const LOCAL: &str = "AGENTS.local.md"; + +/// The file each scope keeps its guidance in. +/// +/// `&'static str` and not a `PathBuf`: a name is not a location, and the two +/// project scopes are the same name under different roots. +#[must_use] +pub fn file_name(scope: Scope) -> &'static str { + match scope { + Scope::User => crate::home::MEMORY, + Scope::Project => PROJECT, + Scope::Local => LOCAL, + } +} + +/// Where that file is, for a session rooted at `root`. +/// +/// `None` for [`Scope::User`] where there is no home to speak of — the same +/// answer [`crate::home::in_force`] gives, for the same reason: a program that +/// invents a path when it has no home writes into somebody else's. The two +/// project scopes always have one, because the root is the directory the session +/// is already running in. +#[must_use] +pub fn path(root: &Path, scope: Scope) -> Option { + match scope { + Scope::User => Some(crate::home::in_force()?.0.join(file_name(scope))), + Scope::Project | Scope::Local => Some(root.join(file_name(scope))), + } +} + +/// What a file this module creates says about itself. +/// +/// Three headers rather than one, and the difference between them is the only +/// thing an operator has to know before typing into any of them: **who else +/// reads this**. A `AGENTS.md` line goes to everyone who clones the repository; +/// an `AGENTS.local.md` line goes nowhere; an `IO.md` line follows the operator +/// into every project on the machine. Getting that wrong is how a private note +/// about a colleague ends up in a pull request, so it is written into the file +/// at the moment the file is made rather than into a document nobody opens. +fn header(scope: Scope) -> &'static str { + match scope { + Scope::User => { + "# IO.md\n\n\ + What io remembers for this operator. It sits beside `io.toml` in io's \ + own home, applies to every project on this machine, and is part of no \ + repository.\n\n" + } + Scope::Project => { + "# AGENTS.md\n\n\ + Instructions for agents working in this repository. This file is \ + committed, so everything written here is shared with everyone who \ + clones it.\n\n" + } + Scope::Local => { + "# AGENTS.local.md\n\n\ + Instructions for agents working in this checkout alone. This file is \ + not committed and nobody else sees it.\n\n" + } + } +} + +/// Append one line of guidance, and answer with the file it went into. +/// +/// The line is written as a markdown bullet, because the files are markdown and +/// the next thing to read them is a model that has been trained on markdown. +/// +/// Refuses a line that is empty or nothing but whitespace. That refusal is the +/// point rather than a nicety: remembering a blank line succeeds silently, tells +/// the operator their instruction was recorded, and records nothing — which is +/// the one failure they cannot see from the surface. +pub fn remember(root: &Path, scope: Scope, line: &str) -> Result { + let text = line.trim(); + if text.is_empty() { + return Err("there is nothing to remember".to_string()); + } + + let path = path(root, scope) + .ok_or_else(|| "there is no path for that scope on this machine".to_string())?; + + // The user scope's parent is io-cli's own home, which is `0700` — so the + // directory is made the way `home::adopt` makes it rather than with a bare + // `create_dir_all`, and `IO.md` is protected by the directory around it the + // same way `io.toml` beside it is. For the project scopes this is a no-op: + // the root is the directory the session is running in, and an existing + // directory's mode is not touched. The filter is the relative-root case: + // `Path::new("x").parent()` is `Some("")`, and creating *that* fails rather + // than doing nothing. + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + crate::home::create(parent).map_err(|error| format!("{}: {error}", parent.display()))?; + } + + let prelude = match std::fs::read(&path) { + // ponytail: the whole file is read to look at its last byte. These are + // guidance files a person maintains by hand — kilobytes — and the + // alternative is a `SeekFrom::End(-1)` that has to special-case an empty + // file anyway. Upgrade to the seek if this ever appends to something + // machine-generated and large. + // + // **Without this the previous author's last line and the new bullet + // become one line.** A file that ends `remember to run the linter` with + // no newline, appended to, reads `remember to run the linter- and the + // formatter` — one instruction turned into a different one, in a file + // that goes to the model on every run. + Ok(bytes) if bytes.last().is_some_and(|byte| *byte != b'\n') => "\n", + Ok(_) => "", + // The file is created below, and this is the only chance to say what it + // is and who else will read it. + Err(error) if error.kind() == std::io::ErrorKind::NotFound => header(scope), + Err(error) => return Err(format!("{}: {error}", path.display())), + }; + + // `append`, not `write` and not `truncate`: every byte already in the file + // stays where it is, and the position is resolved by the kernel at each + // write rather than from what was read above. + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .map_err(|error| format!("{}: {error}", path.display()))?; + writeln!(file, "{prelude}- {text}").map_err(|error| format!("{}: {error}", path.display()))?; + + Ok(path) +} + +// --------------------------------------------------------------------------- +// Making io-harness read all three +// --------------------------------------------------------------------------- + +/// The order the three files are named in, and it is the order they reach the +/// model in: `read_instructions` pushes one constraint per name in list order +/// (`io-harness-0.69.0/src/config.rs:1884-1899`). +/// +/// Project, then local, then user — widest audience first, so the operator's own +/// standing note is the last thing said. It also matches the order the three +/// headers in [`header`] are worth reading in. +const ORDER: [Scope; 3] = [Scope::Project, Scope::Local, Scope::User]; + +/// How a scope's file has to be spelled inside `[instructions] files`. +/// +/// **Not the same thing as [`path`], and the difference is the whole reason +/// `IO.md` needs its own case.** io-harness resolves every name it is given with +/// `root.join(&name)` against the discovery root (`config.rs:1885`), so a bare +/// name means "in the workspace". That is exactly right for the two project +/// files and unreachable for `IO.md`, which lives in io-cli's home — a directory +/// that is not the workspace and has no relative spelling from it. So the user +/// entry is the **absolute** path. +/// +/// Absolute and not `${env:HOME}/.io-cli/IO.md`, which would read as the tidier +/// answer and is a trap: the harness does substitute `${env:…}`, and an unset +/// variable is a **hard error** that fails the whole parse (`config.rs:1983-1989`). +/// On Windows `HOME` is routinely unset. A configuration file that refuses to +/// parse is a session that does not start, and it would fail on the machine of +/// whoever copied the file rather than on the machine that wrote it. `~` is not +/// an option either: the harness expands it nowhere. +fn entry(root: &Path, scope: Scope) -> Option { + match scope { + Scope::Project | Scope::Local => Some(PathBuf::from(file_name(scope))), + Scope::User => path(root, scope), + } +} + +/// Every name `[instructions] files` has to hold for all three files to be read. +/// +/// **`AGENTS.md` is always in it, and that is a correctness requirement rather +/// than a courtesy.** `files` REPLACES the default list, it does not add to it: +/// `read_instructions` falls back to `DEFAULT_INSTRUCTIONS` — exactly +/// `["AGENTS.md"]` (`config.rs:158`) — only when the table is absent or names +/// nothing, and otherwise takes `Some(files) => files.clone()` verbatim +/// (`config.rs:1879-1882`). So a list written to reach `AGENTS.local.md` and +/// `IO.md` and no more would **silently stop the repository's own `AGENTS.md` +/// being read** — no error, no warning, just a model that no longer knows the +/// project's rules. Nothing on the surface would show it, because a missing +/// instruction file is skipped without a word (`config.rs:1886`). +/// +/// Two entries rather than three where there is no home to put `IO.md` in, which +/// is the same answer [`path`] gives for the same reason. The project pair never +/// goes missing: the root is the directory the session is already running in. +#[must_use] +pub fn files(root: &Path) -> Vec { + ORDER + .into_iter() + .filter_map(|scope| entry(root, scope)) + .collect() +} + +/// What `[instructions] files` currently says in one file, as that file spells it. +/// +/// Read from the file's own bytes rather than from a merged [`Config`], because +/// [`install`] needs to know whether **this scope** already holds the list — a +/// merged view cannot tell "the user file says it" from "some other file says +/// it", and writing on the strength of the second would rewrite the user file on +/// every call. +/// +/// It goes through [`crate::edit::value_at`] rather than parsing here, and that +/// is a boundary rather than a convenience: `tests/dependencies.rs` permits TOML +/// in `src/edit.rs` alone, because a second module that parses a configuration +/// file is a second opinion about what one means. This module decides *which +/// files* belong in the list; `edit` decides how a list is spelled. The value +/// comes back as its own source text, so the comparison in [`install`] is +/// between the bytes that are there and the bytes that would be written. +fn declared(path: &Path) -> Option { + let text = std::fs::read_to_string(path).ok()?; + crate::edit::value_at(&text, "instructions.files") +} + +/// Name all three files in the **user** scope, and report whether that changed +/// anything. +/// +/// # Why the user scope, and never a project one +/// +/// The list contains an absolute path into this operator's home. A project +/// `io.toml` is committed, so writing it there would put one person's +/// `/Users/somebody/.io-cli/IO.md` into everybody else's checkout: harmless to +/// them only because the file does not exist and is skipped in silence +/// (`config.rs:1886`), and a leak of the operator's account name to every reader +/// of the repository regardless. `~/.io-cli/io.toml` is the one file in this +/// product that is never committed, so it is the only honest place for a path +/// that is true on exactly one machine. +/// +/// The cost is stated rather than hidden: `["instructions","files"]` is not in +/// io-harness's `APPENDING` set (`config.rs:2052`), so a project or local file +/// that names `files` itself replaces this list wholesale rather than adding to +/// it — the scopes are merged in the order they are listed at +/// `config.rs:688-693`, and a later one's value simply overwrites +/// (`config.rs:2142-2144`), so Local > Project > User. That is the +/// harness's rule and this module does not fight it — [`view`] reports the +/// result instead, so an operator whose project overrode the list sees that it +/// did rather than being told all three are read. +/// +/// # Idempotence +/// +/// `Ok(false)` and not one byte written when the list is already exactly right. +/// This is reached from a command an operator types repeatedly, and a write per +/// invocation would churn `io.toml`'s bytes, its mtime and any backup watching +/// it for a change that never happened. +/// +/// The comparison is on the value's **source text**, so a list an operator wrote +/// by hand with different spacing is rewritten once into this crate's spelling +/// and is stable from then on. That is the honest trade for not parsing here: one +/// write that changes no meaning, rather than a second TOML reader in a module +/// that has no business being one. +pub fn install(root: &Path) -> Result { + let want = files(root); + let at = crate::configure::scope_path(root, Scope::User) + .ok_or_else(|| "there is no path for that scope on this machine".to_string())?; + + let mut names = Vec::with_capacity(want.len()); + for name in &want { + // A TOML file is text, so a path that is not text cannot go in one. Said + // out loud rather than papered over with `to_string_lossy`, which would + // write a path with U+FFFD in it that resolves to nothing and is skipped + // in silence — the failure this module keeps refusing to ship. + names.push(name.to_str().ok_or_else(|| { + format!( + "{} is not valid UTF-8, and a TOML file cannot name it", + name.display() + ) + })?); + } + + let value = crate::edit::array(&names); + if declared(&at).is_some_and(|found| found == value) { + return Ok(false); + } + + let edits = [crate::edit::Edit::set("instructions.files", value)]; + crate::configure::write(root, Scope::User, &edits)?; + Ok(true) +} + +/// Which files io-harness **actually read**, for a configuration it already +/// resolved. +/// +/// Recovered from [`Config::instructions`] rather than by re-deriving the +/// precedence rules, and that choice is the point: every entry is wrapped +/// `"Project instructions from `{name}`:\n{text}"` (`config.rs:1895-1898`), so +/// the list of names is right there in the strings the harness built. Reading it +/// back says what **was read**; re-implementing the merge would say what io-cli +/// believes it asked for, and the two differ in exactly the case a surface +/// exists to show — a project file that replaced the list. +/// +/// `name` is the name as it was written in `files`, not the resolved location, +/// so it is joined to the discovery root here the same way `config.rs:1885` +/// joins it. `Path::join` returns an absolute argument unchanged, so the one +/// expression covers both the two relative names and `IO.md`'s absolute one. +fn reading(root: &Path, config: &Config) -> Vec { + config + .instructions() + .iter() + .filter_map(|text| { + let rest = text.strip_prefix("Project instructions from `")?; + // ponytail: an exact match on the name the harness recorded. A + // configuration naming `./AGENTS.md` reads as a different file here + // and would be reported unread while being read. Nothing this crate + // writes spells a name that way; normalise if an operator's own + // `files` ever turns up in a bug report. + let (name, _) = rest.split_once("`:\n")?; + Some(root.join(name)) + }) + .collect() +} + +/// One guidance file, as a surface has to show it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Instruction { + /// Which of the three this is, and therefore who else reads it. + pub scope: Scope, + /// Where the file is. Absolute for [`Scope::User`], under the session root + /// for the other two. + pub path: PathBuf, + /// Whether there is a file there at all. + pub exists: bool, + /// How many lines it holds. `0` for a file that is not there, which `exists` + /// is what distinguishes from a file that is there and empty. + pub lines: usize, + /// **Whether io-harness is reading it**, recovered from what it actually + /// read rather than from what was configured — so a project file that + /// replaced the list shows up here as `false` instead of being argued away. + pub read: bool, +} + +/// All three files, what each holds, and which of them the session is really +/// reading. +/// +/// The two right-hand columns are deliberately independent. A file that exists +/// and is not being read is the whole reason this exists, and it happens for +/// three ordinary causes: a project `[instructions] files` replaced the user +/// list (`config.rs:2052` — this key does not append), [`install`] was never +/// run, or the file holds nothing but whitespace and was skipped +/// (`config.rs:1892`). A view that inferred `read` from `exists` would report +/// all three green in every one of those cases, which is worse than no view: it +/// would be the surface an operator trusts while their `AGENTS.md` goes unread. +#[must_use] +pub fn view(root: &Path, config: &Config) -> Vec { + let reading = reading(root, config); + + ORDER + .into_iter() + .filter_map(|scope| { + let at = path(root, scope)?; + let text = std::fs::read_to_string(&at).ok(); + Some(Instruction { + scope, + // `is_file`, the same question io-harness asks at + // `config.rs:1886`, and not "did the read succeed": a file this + // process cannot read is still there, and reporting it absent + // would send the operator to create one that already exists. + exists: at.is_file(), + lines: text.as_deref().map_or(0, |text| text.lines().count()), + read: reading.contains(&at), + path: at, + }) + }) + .collect() +} diff --git a/src/recall.rs b/src/recall.rs new file mode 100644 index 0000000..e046a52 --- /dev/null +++ b/src/recall.rs @@ -0,0 +1,608 @@ +//! What the agent remembers between runs, read back. +//! +//! io-harness has kept durable memory since 0.10.0 — notes an agent writes for +//! itself, keyed to a workspace, carried into the prompt of every later run over +//! that workspace. The whole API is `pub`, and until this release **io-cli had +//! never called a line of it**. An operator running this product could not see +//! what their agent believed, could not tell a note it had leaned on from one it +//! had never used, and had no way to know that the harness had quietly dropped +//! one to hold a cap. +//! +//! The module has two halves, and the split is deliberate. +//! +//! **The read** — [`view`], [`trace`] — writes nothing at all: no `memory_put`, +//! no `memory_write`, no `memory_pin`, no `memory_forget`. A reader that could +//! also mutate is a reader somebody eventually calls from a render pass. +//! +//! **The operator's two levers** — [`pin`], [`forget`], [`unforget`] — are the +//! only writes here, and every one of them is an *act*, reached from a keystroke +//! and never from a draw. io-harness says so itself: `memory_pin` is documented +//! as a caller's act and never a run's (`src/state.rs:1764-1767`), because the +//! whole point of a pin is that it survives what the agent does next. Pinning is +//! also what makes the caps survivable — an entry an operator pinned is exempt +//! from being overwritten by a run (`src/state/memory.rs:430`) and from being +//! dropped to hold a cap (`src/state/memory.rs:736-739`), while still counting +//! towards both, so pinning everything makes writes fail loudly rather than +//! quietly raising the ceiling. +//! +//! It follows the shape of [`crate::sessions`]: take a `&Store`, return owned +//! data, let the caller draw it. Nothing here touches a terminal, and nothing +//! here reads a clock — every time it reports is the `created_at` string the +//! store wrote, passed through untouched, which is the rule `tests/timing.rs` +//! enforces across this crate. +//! +//! # The four things that make a naive reading wrong +//! +//! **1. The bucket is a canonicalised path, and io-cli has to canonicalise it +//! itself.** io-harness computes the key in `memory_key()` at +//! `src/run/memory.rs:14-19` — `std::fs::canonicalize(root)`, falling back to the +//! path as given when that fails — and the function is `pub(super)`, so it cannot +//! be called from here. [`workspace_key`] reproduces it exactly, fallback +//! included. This matters more than it looks: a checkout reached through a +//! symlink (`/var` on macOS resolves to `/private/var`, a home-directory link to +//! a volume, a container bind mount) has its notes written under the *resolved* +//! path, so a lookup keyed on the path the operator typed finds an empty bucket +//! beside an agent writing a note every turn. It is correct on the developer's +//! own machine and wrong on somebody else's, which is the defect that ships +//! green. +//! +//! **2. There are two buckets.** The workspace's own, and the literal +//! [`GLOBAL_MEMORY_WORKSPACE`] — `""`, `src/state.rs:2833` — which holds +//! what the agent believes everywhere. Both are listed, and every row carries the +//! [`Scope`] it came from, because "is this true here, or true everywhere" is the +//! only question the two scopes exist to answer and a merged list destroys it. +//! +//! **3. The caps are per scope, not per run.** `src/contract.rs:376-379` states +//! it: each scope holds its own, so a run drawing on both may carry up to twice +//! `max_entries` and twice `max_chars`. [`View::caps`] is therefore a row per +//! scope rather than a number, and [`View::entries_ceiling`] is the sum — a +//! single `max_entries` presented as *the* cap is half the real ceiling, and the +//! half that makes a legitimate eviction look like a bug. +//! +//! **4. Eviction, pin-refusal and recall emit no `EventKind` at all.** io-harness +//! records all three as [`io_harness::ContextEvent`] rows deliberately — +//! `src/state.rs:2996-3002` spells out why: the question they answer +//! (*did my pin hold?*) is asked +//! afterwards by somebody reading the store, not during the run by an observer. +//! A *write* does emit `EventKind::MemoryWrote`, which is exactly what makes the +//! mistake easy: the observer stream looks like the right place because it +//! carries the neighbouring fact. An implementation reaching for it reports that +//! nothing has ever been evicted, refused or recalled, and a store where none of +//! that has happened looks identical. So [`trace`] reads +//! [`Store::context_events`] and `tests/recall.rs` asserts the stream's silence. +//! +//! # The one thing that makes a naive *write* wrong +//! +//! **There are two removals in io-harness and only one of them can be undone.** +//! [`Store::memory_delete`] (`src/state/memory.rs:863`) is a bare +//! `DELETE FROM memory` — the embedder's blunt removal, with no restore point +//! and no tidying of the evidence the entry accrued. [`Store::memory_forget`] +//! (`src/state/memory.rs:821`) writes a `memory_snapshots` row *before* it +//! removes anything and then clears the key's `memory_recalls` +//! (`src/state/memory.rs:838-855`). +//! +//! [`forget`] uses the second, and the difference is not stylistic. io-cli +//! already reports `memory_restored` / `memory_removed` on its undo path +//! (`src/rewind.rs:89-92`, `:219-220`, `:294-301`), and that path is +//! `io_harness::rewind_run`, which puts entries back **only** from +//! `memory_snapshots` (`src/run.rs:749`, `:761-777`). An entry removed through +//! `memory_delete` has no row there, so nothing can put it back — and the +//! operator who forgot the wrong key finds that out at the one moment they +//! wanted it. The two implementations are indistinguishable from the store's +//! entry list, which is why `tests/recall.rs` asserts the restore point and the +//! cleared recall rows rather than the entry's absence. +//! +//! The second half of that trap is which `run_id` the restore point is filed +//! under — see [`forget`], which explains why it starts a run of its own rather +//! than borrowing one. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +use io_harness::tools::Workspace; +use io_harness::{ + rewind_run, Error, MemoryForget, MemoryKind, MemoryLimits, Store, TaskContract, + GLOBAL_MEMORY_WORKSPACE, +}; + +/// How many runs the draw-count scan will look at before giving up. +/// +/// The same ceiling, for the same reason, as [`crate::sessions::MAX_RUNS_SCANNED`]: +/// io-harness exposes no public per-workspace draw count — `Store::memory_draws` +/// exists (`src/state/memory.rs:682`) and is `pub(crate)` — so the only public +/// path is [`Store::memory_recalls`], which is keyed by run. Counting how many +/// distinct runs drew on an entry therefore costs one indexed query per run, and +/// this bounds that cost. +/// +/// A scan that stopped early makes every draw count a **lower bound**, which is a +/// different claim from the one an unqualified number makes. [`View::draws_cut`] +/// carries that so a caller can say so rather than letting a short count read as +/// a complete one. +pub const MAX_RUNS_SCANNED: usize = 500; + +/// Which of the agent's two memories a note lives in. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Scope { + /// This workspace only, keyed on the canonicalised root. + Workspace, + /// Every workspace — io-harness's [`GLOBAL_MEMORY_WORKSPACE`]. + Global, +} + +impl Scope { + /// What to call it in a line of output. + pub fn label(self) -> &'static str { + match self { + Scope::Workspace => "workspace", + Scope::Global => "global", + } + } +} + +/// The key a workspace's durable memory is stored under. +/// +/// **A reproduction of io-harness's own `memory_key`** (`src/run/memory.rs:14-19`), +/// which is `pub(super)` and so unreachable from here. Both halves are +/// load-bearing and both are copied on purpose: +/// +/// - `canonicalize`, so the same directory reached by two different paths is one +/// bucket rather than two. This is the whole of trap 1 — see the module note. +/// - **the fallback to the path as given**, so a root that cannot be resolved +/// *yet* — deleted, unmounted, not created — still has memory rather than none. +/// Returning an error instead would make the notes about a workspace that has +/// moved unreadable precisely when somebody wants to read them. +/// +/// If io-harness ever changes its keying, this diverges silently and the panel +/// goes empty. That is why `tests/recall.rs` drives a real turn through the +/// harness's own run loop and asserts the recall rows land under this key. +pub fn workspace_key(root: &Path) -> String { + std::fs::canonicalize(root) + .unwrap_or_else(|_| root.to_path_buf()) + .to_string_lossy() + .into_owned() +} + +/// The stored spelling of a memory kind. +/// +/// `MemoryKind::as_str` is **private** in io-harness (`src/state.rs:1812`), so the +/// two words are spelled here. The enum is `#[non_exhaustive]` +/// (`src/state.rs:1800`) — the crate documents a third kind it intends to add — +/// so the wildcard arm is required rather than defensive, and it says *unknown* +/// rather than guessing `"fact"`: a kind this build cannot name is not a fact, it +/// is a row written by a newer harness than the one this binary was compiled +/// against. +pub fn kind_label(kind: MemoryKind) -> &'static str { + match kind { + MemoryKind::Fact => "fact", + MemoryKind::Decision => "decision", + _ => "unknown", + } +} + +/// One note, as a panel shows it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Remembered { + /// Which of the two memories holds it. + pub scope: Scope, + /// The name it is recalled by, unique within its bucket. + pub key: String, + /// The remembered text. + pub value: String, + /// [`kind_label`] of the entry's kind. + pub kind: &'static str, + /// Whether an operator pinned it, which is what stops a run overwriting it. + pub pinned: bool, + /// The run that wrote it. + pub run_id: i64, + /// The step of that run which wrote it. + pub step: u32, + /// The store's own UTC write time, **as stored**. Never computed here, never + /// turned into an age: this crate reads no clock outside `src/main.rs`. + pub created_at: String, + /// How many **distinct runs** have drawn on it. + /// + /// Distinct runs and not recall rows, matching the evidence io-harness itself + /// evicts by (`src/state/memory.rs:639-644`): a recall row is written once per + /// carried key per *step*, so counting rows would let one two-hundred-step run + /// outvote fifty runs that each leaned on the note once, and would make the + /// number monotone in age rather than in usefulness. + /// + /// Zero is a fact — nothing has used it — not a gap. A lower bound when + /// [`View::draws_cut`] is set. + pub draws: usize, +} + +/// The caps in force for one scope. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Caps { + /// The scope these bound. + pub scope: Scope, + /// What the contract carries. Taken from a [`TaskContract`] the caller passes + /// in and never re-derived from configuration here — the setting in force is + /// the one the turn carries, and a second answer assembled in a view would be + /// io-cli holding an opinion about a value it does not own. + pub limits: MemoryLimits, +} + +/// What a trace row says happened to memory. +/// +/// Exactly the three that are invisible anywhere else. `memory_write` and +/// `memory_forget` are trace rows too, but a write is already on the observer +/// stream as `EventKind::MemoryWrote` and a forget is the operator's own action +/// on a surface that owns it; these three have no other witness at all. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Happened { + /// A note was dropped to hold the caps (`"memory_evict"`). + Evicted, + /// A run tried to overwrite a pinned note and was refused + /// (`"memory_refused"`). + Refused, + /// Notes from earlier runs were carried into a turn's prompt + /// (`"memory_recall"`). + Recalled, +} + +impl Happened { + /// The kind string io-harness stores, `src/state.rs:2982-3009`. + fn of(kind: &str) -> Option { + match kind { + "memory_evict" => Some(Happened::Evicted), + "memory_refused" => Some(Happened::Refused), + "memory_recall" => Some(Happened::Recalled), + _ => None, + } + } +} + +/// One thing the trace says happened to memory during a run. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Noted { + /// The step it belongs to. + pub step: u32, + /// Which of the three it is. + pub happened: Happened, + /// io-harness's own sentence — the key and why, for an eviction or a refusal; + /// how many notes of how many were carried, for a recall. + pub detail: Option, +} + +/// Everything a memory panel needs, and nothing it has to compute. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct View { + /// The bucket the workspace rows were read from: the **canonicalised** root. + /// + /// Reported rather than assumed, so a reader looking at an empty panel can + /// see which path answered — which is the difference between "the agent has + /// learnt nothing" and "you are looking at the wrong bucket". + pub workspace: String, + /// Every note in both buckets: the workspace's, then the global one, each in + /// the order io-harness returns it (`created_at ASC, key ASC`, + /// `src/state/memory.rs:764`). + pub entries: Vec, + /// The caps, one row per scope. See the module note, trap 3. + pub caps: Vec, + /// The evictions, refusals and recalls the trace holds for the run the caller + /// named. Empty when it named none. + pub trace: Vec, + /// The draw scan hit [`MAX_RUNS_SCANNED`], so every [`Remembered::draws`] is a + /// lower bound rather than a count. + pub draws_cut: bool, +} + +impl View { + /// How many notes a run may carry in total. + /// + /// The sum across scopes, because each scope holds its own cap + /// (`src/contract.rs:376-379`). This exists so the honest number is the easy + /// one to reach for: quoting `max_entries` at an operator tells them half the + /// real ceiling, and half a ceiling makes an ordinary eviction read as a + /// defect. + pub fn entries_ceiling(&self) -> usize { + self.caps.iter().map(|c| c.limits.max_entries).sum() + } + + /// How many characters of memory a run may carry in total, summed across + /// scopes for the same reason as [`View::entries_ceiling`]. + /// + /// `max_entry_chars` is deliberately not summed anywhere: it bounds one + /// entry, and an entry lives in exactly one scope. + pub fn chars_ceiling(&self) -> usize { + self.caps.iter().map(|c| c.limits.max_chars).sum() + } +} + +/// Read the agent's durable memory for `root`. +/// +/// `contract` supplies the caps — the one the session is about to run, or the one +/// it just ran. `run` names the run whose trace to read, and `None` is a view with +/// no trace rather than a view of the newest run: guessing which run an operator +/// meant would put somebody else's evictions in front of them. +/// +/// Every row is a read. This function cannot write to the store and cannot reach +/// the filesystem except through [`workspace_key`], which only resolves a path. +pub fn view( + store: &Store, + root: &Path, + contract: &TaskContract, + run: Option, +) -> Result { + let workspace = workspace_key(root); + let (drawn, draws_cut) = draws(store)?; + + let mut entries = Vec::new(); + for (scope, bucket) in [ + (Scope::Workspace, workspace.as_str()), + (Scope::Global, GLOBAL_MEMORY_WORKSPACE), + ] { + for entry in store.memory_list(bucket)? { + let draws = drawn + .get(&(bucket.to_string(), entry.key.clone())) + .map_or(0, |runs| runs.len()); + entries.push(Remembered { + scope, + kind: kind_label(entry.kind), + key: entry.key, + value: entry.value, + pinned: entry.pinned, + run_id: entry.run_id, + step: entry.step, + created_at: entry.created_at, + draws, + }); + } + } + + Ok(View { + workspace, + entries, + // One row per scope rather than one number. The contract carries a single + // `MemoryLimits`, and that is precisely the trap: the same numbers apply + // to each scope *separately*, so the pair is what is true and a lone copy + // of it is what is misread. + caps: [Scope::Workspace, Scope::Global] + .map(|scope| Caps { + scope, + limits: contract.memory, + }) + .to_vec(), + trace: match run { + Some(id) => trace(store, id)?, + None => Vec::new(), + }, + draws_cut, + }) +} + +/// The evictions, refusals and recalls recorded against `run_id`. +/// +/// **From [`Store::context_events`], which is the only place they exist.** See the +/// module note, trap 4: none of the three emits an `EventKind`, so an +/// implementation built on the observer stream reports an empty history of a +/// store that has been evicting notes for months. +/// +/// Rows of every other kind — `"assembled"`, `"reread"`, `"memory_write"`, +/// `"memory_forget"` — are dropped rather than surfaced as an unknown variant: +/// this is a memory panel, and a context-assembly row in it is noise a reader has +/// to learn to skip. +pub fn trace(store: &Store, run_id: i64) -> Result, Error> { + Ok(store + .context_events(run_id)? + .into_iter() + .filter_map(|event| { + Happened::of(&event.kind).map(|happened| Noted { + step: event.step, + happened, + detail: event.detail, + }) + }) + .collect()) +} + +/// Which runs have drawn on each `(bucket, key)`, and whether the scan was cut. +/// +/// io-harness's own per-workspace answer, `Store::memory_draws` +/// (`src/state/memory.rs:682`), is `pub(crate)`. The public surface is +/// [`Store::memory_recalls`], which is keyed by run — so the count has to be +/// assembled from the other side, one indexed query per run, bounded by +/// [`MAX_RUNS_SCANNED`]. +/// +/// A `BTreeSet` of run ids rather than a counter because a run that carried the +/// same note on six steps writes six rows, and the number worth showing is one. +#[allow(clippy::type_complexity)] +fn draws(store: &Store) -> Result<(BTreeMap<(String, String), BTreeSet>, bool), Error> { + let runs = store.runs()?; + let cut = runs.len() > MAX_RUNS_SCANNED; + let mut drawn: BTreeMap<(String, String), BTreeSet> = BTreeMap::new(); + for run in runs.into_iter().take(MAX_RUNS_SCANNED) { + for recall in store.memory_recalls(run)? { + drawn + .entry((recall.workspace, recall.key)) + .or_default() + .insert(recall.run_id); + } + } + Ok((drawn, cut)) +} + +// --------------------------------------------------------------------------- +// The operator's two levers +// --------------------------------------------------------------------------- + +/// The bucket one [`Scope`] names for `root`. +/// +/// The same pair [`view`] reads, reached by scope rather than by position, so an +/// operator acting on a row acts on the bucket that row was listed from. Without +/// it the obvious call site — "pin the selected entry" — silently means "pin the +/// workspace entry of that key", which for a global note either does nothing or, +/// worse, pins a same-named workspace note the operator was not looking at. +fn bucket(root: &Path, scope: Scope) -> String { + match scope { + Scope::Workspace => workspace_key(root), + Scope::Global => GLOBAL_MEMORY_WORKSPACE.to_string(), + } +} + +/// The goal recorded against the bookkeeping run a [`forget`] files its restore +/// point under. See [`forget`] for why that run exists at all. +const FORGET_GOAL: &str = "an operator withdrew one memory entry"; + +/// The outcome that run is finished with. +/// +/// Deliberately **not** [`io_harness::SUCCESS_OUTCOME`]: `Store::finish_run` +/// (`src/state/runs.rs:145`) hands the string to `write_summary`, which stores +/// `success = (outcome == "success")` (`src/state/memory.rs:151-180`) — so a +/// bookkeeping row named `"success"` would quietly inflate every success rate +/// computed off this store. Any other string still marks the run `completed`, +/// which is the half that matters: a run left `running` is a *resumable* run, +/// and io-harness would happily offer to continue a "run" that is one UPDATE. +const FORGET_OUTCOME: &str = "memory_forget"; + +/// The step the restore point is attributed to. +/// +/// Zero because there were no steps: this act happened between runs, not inside +/// one. The number is only ever read back by `Store::memory_restore` +/// (`src/state/memory.rs:64-71`) to attribute the row it re-inserts, so an +/// invented step would put a fictional position into the restored entry. +const FORGET_STEP: u32 = 0; + +/// What pinning or unpinning one entry did. +/// +/// Two variants and not a `bool`, because `Store::memory_pin` returns +/// `n > 0` from an `UPDATE` (`src/state/memory.rs:562-569`) and `false` there +/// means *there was no such entry* — not "the pin failed". io-harness is explicit +/// that it will not invent one to pin (`src/state/memory.rs:556-558`). A `bool` +/// at a call site reads as "did it work", and the surface that believes it worked +/// shows a pin the store does not hold. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Pinned { + /// The entry exists and now carries the pin state that was asked for. + Set, + /// There is no entry of that key in that scope, so nothing was changed. + NoEntry, +} + +/// What withdrawing one entry did. +/// +/// The three outcomes of [`Store::memory_forget`] (`src/state.rs:1972-1982`), +/// kept apart because **only one of them is success**. Collapsing them into a +/// `bool` — or worse, reporting the refusal as a removal — tells an operator +/// their note is gone while it sits in the store being carried into every later +/// prompt, which is the same failure the pin flag exists to prevent one level +/// down. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Forgotten { + /// Gone, and recoverable: `restore` is the run id whose rewind puts it back. + /// Hand it to [`unforget`]. + Removed { + /// The run the restore point was filed under. See [`forget`]. + restore: i64, + }, + /// **Refused.** The entry is pinned, so it is not a run's to withdraw — and + /// io-cli asks on a run's behalf. The entry stands, unchanged. The operator + /// unpins it first, with [`pin`], and asks again. + Refused, + /// There was no such key in that scope. Not an error, and not a removal + /// either. + Absent, +} + +/// Pin or unpin one entry — the operator's only lever over a store the agent +/// otherwise manages alone. +/// +/// A pin is what stops a run overwriting a correction a person made +/// (`src/state/memory.rs:430`) and what stops the caps dropping it +/// (`src/state/memory.rs:736-739`). It does **not** buy extra room: a pinned +/// entry still counts towards both caps, so an operator who pins everything gets +/// writes that fail loudly rather than a bigger memory. That is io-harness's +/// choice and this wrapper does not soften it. +/// +/// Unpinning is the same call with `pinned` false, and it is the prerequisite +/// for [`forget`] on a pinned entry — which is why [`Forgotten::Refused`] names +/// the reason instead of just failing. +pub fn pin( + store: &Store, + root: &Path, + scope: Scope, + key: &str, + pinned: bool, +) -> Result { + Ok(match store.memory_pin(&bucket(root, scope), key, pinned)? { + true => Pinned::Set, + false => Pinned::NoEntry, + }) +} + +/// Withdraw one entry, leaving a way back. +/// +/// **Through [`Store::memory_forget`] and never [`Store::memory_delete`]** — the +/// module note gives the whole argument. In short: `memory_forget` takes the +/// restore point and clears the key's recall rows, `memory_delete` does neither, +/// and the two are indistinguishable from the entry list right up to the moment +/// somebody wants the entry back. +/// +/// # Why this starts a run of its own +/// +/// `memory_forget` files the restore point under a `run_id`, and this caller is +/// not inside a run: it is a keystroke. Three ids were available and two of them +/// are wrong. +/// +/// - **The entry's own `run_id`** — the run that wrote it, which +/// [`Remembered::run_id`] already carries — is the tempting one and is the +/// trap. `memory_snapshots` is unique on `(run_id, workspace, key)` +/// (`src/state/schema.rs:763-764`) and the insert is `INSERT OR IGNORE` +/// (`src/state/memory.rs:839`), so the restore point that run took when it +/// *wrote* the entry already owns that slot and this one is silently dropped. +/// The row that stays says the entry was **absent** before that run, so a +/// rewind would delete it rather than put it back — the exact opposite of what +/// was asked for, arrived at without a single error. +/// - **The session's last turn**, which is what `src/rewind.rs:181` rewinds, +/// collides in the same way whenever that turn touched the same key, and +/// otherwise welds the operator's withdrawal onto an undo of the agent's last +/// turn: pressing undo once would put back a note the operator deliberately +/// removed several minutes earlier. +/// - **A run of its own**, which is what this does. It cannot collide, because +/// the run is new; a rewind of it restores exactly the one entry the operator +/// withdrew and nothing else; and the act is in the trace as an act, which is +/// what it was. It costs one `runs` row per withdrawal — visible to +/// [`MAX_RUNS_SCANNED`] and to [`crate::sessions::MAX_RUNS_SCANNED`], and to +/// neither's output: `crate::sessions::recent` lists sessions by way of +/// `turn_for_run`, and this run has no turn. +/// +/// The run is finished immediately, with an outcome of `"memory_forget"`, so it +/// is `completed` rather than left `running` and resumable — and, not being +/// `"success"`, it is not counted among the agent's successful runs. +pub fn forget(store: &Store, root: &Path, scope: Scope, key: &str) -> Result { + let bucket = bucket(root, scope); + // Started before the outcome is known, because the call needs the id. A + // refusal therefore leaves an empty completed run behind, and that is the + // honest record: the operator asked, and the store said no. + let restore = store.start_run(FORGET_GOAL, &bucket)?; + let outcome = store.memory_forget(&bucket, key, restore, FORGET_STEP)?; + store.finish_run(restore, FORGET_OUTCOME)?; + // Exhaustive on purpose. `MemoryForget` is not `#[non_exhaustive]` + // (`src/state.rs:1971`), so a fourth outcome in a later io-harness stops this + // build rather than being folded into one of the three by a wildcard — and + // the one direction that must never be guessed is "unknown, so probably + // removed". + Ok(match outcome { + MemoryForget::Removed => Forgotten::Removed { restore }, + MemoryForget::Pinned => Forgotten::Refused, + MemoryForget::Absent => Forgotten::Absent, + }) +} + +/// Put back what [`forget`] withdrew. Returns the keys restored. +/// +/// `restore` is the id out of [`Forgotten::Removed`] and nothing else. This is +/// `io_harness::rewind_run` — the same call `src/rewind.rs:181` makes to undo a +/// turn — pointed at a run that has one restore point and no file snapshots, so +/// the file and queue halves of the rewind find nothing to do and the workspace +/// is untouched. +/// +/// An empty `Vec` means the run held no restore point. Against a +/// [`Forgotten::Removed`] that cannot happen, and if it ever does it is the +/// symptom of the whole module note: something removed the entry without leaving +/// a way back. +pub fn unforget(store: &Store, root: &Path, restore: i64) -> Result, Error> { + Ok(rewind_run(&Workspace::new(root), store, restore)?.memory_restored) +} diff --git a/src/reload.rs b/src/reload.rs new file mode 100644 index 0000000..ba07b1f --- /dev/null +++ b/src/reload.rs @@ -0,0 +1,143 @@ +//! The configuration a turn is built from, re-read once per turn. +//! +//! **io-harness takes the instruction files as a snapshot and never takes +//! another.** `read_instructions` runs inside `Config::discover` +//! (`io-harness-0.69.0/src/config.rs:1872-1898`), composes each named file into a +//! constraint, and stores the result in a private `Config.instructions` field. +//! There is no `Config::reload`, and `Config::with_profile` clones the field +//! rather than going back to disk — so a `Config` is exactly as old as the call +//! that made it. +//! +//! io-cli made that call twice, both before the first prompt: `src/main.rs:81` +//! for every arm, and `src/main.rs:188` after the wizard has written a file. A +//! repository whose `AGENTS.md` changed during a session therefore reached no +//! turn at all, and the only way to pick the change up was to leave the session +//! and start another one. 0.18.0 adds a command that writes those very files, +//! which turns a papered-over annoyance into a surface that would lie about its +//! own effect: the operator writes an instruction, io-cli says it wrote it, and +//! the next turn is composed from the text that was on disk when the session +//! started. +//! +//! # Why the last good configuration is held rather than the error propagated +//! +//! Re-reading once per turn means the session now depends, every turn, on a file +//! an operator may be halfway through editing. An editor that writes in two steps +//! — or a `/config` write that has cut a table header and not yet the body — puts +//! a file on disk that `Config::discover` refuses, and a refusal that propagated +//! would end the session over a state that exists for a fraction of a second and +//! that the operator is already fixing. +//! +//! So [`Configuration::refresh`] never surrenders what it holds. A discovery +//! that fails leaves the previous pair in place, the turn runs on the last +//! configuration that discovered cleanly, and the operator is handed +//! io-harness's own sentence +//! — which names the file and says what it objected to, and is more than anything +//! written here could say. When the file parses again the new configuration is +//! adopted with nothing further asked of anybody. +//! +//! # Why the error is reported once and not every turn +//! +//! The refresh happens at the top of every turn, and a turn is one thing an +//! operator typed. A file that stays broken for six prompts would otherwise +//! produce the same sentence six times, which is how a product teaches people to +//! stop reading its notices — the same reason +//! [`crate::settings::deprecated_max_steps`] speaks only to a file that actually +//! carries the key. +//! +//! "Once" is decided by comparing the error text against the last text this type +//! reported and nothing else. No clock is read and no turn is counted: N1 forbids +//! both outside the driver, and neither would be a better answer anyway. A +//! *different* refusal is a different thing to say, so it is said; and a success +//! clears the memory, so a file that breaks, is fixed, and breaks again reports +//! twice, because the second break is news. +//! +//! # Both halves, or neither +//! +//! The re-read goes through [`crate::configure::reload`], which returns the +//! `Config` and io-cli's own `CliSettings` as a pair for the reason its own doc +//! comment gives: refreshing one and forgetting the other leaves the theme, the +//! glyph set and every other `[app.io-cli]` answer as it was at session start, +//! reporting a value no turn is using. This type stores them as one field pair +//! and replaces them in one assignment, so there is no arrangement of calls that +//! updates half of it. + +use std::path::PathBuf; + +use io_harness::Config; + +use crate::settings::CliSettings; + +/// The configuration in force, and the last refusal the operator was told about. +/// +/// Constructed from what the driver already discovered at startup rather than +/// discovering again — `src/main.rs` applies `--profile` to its `Config` before +/// anything reads it, and a second discovery here would silently drop that +/// overlay. +pub struct Configuration { + /// The directory discovery resolves the project and local scopes against. + root: PathBuf, + /// The last configuration that discovered cleanly. Never replaced by a + /// failure. + config: Config, + /// io-cli's own section of that same configuration, replaced with it. + settings: Option, + /// The text of the last refusal handed to the caller, or `None` when the last + /// refresh succeeded. Compared against, never displayed from. + reported: Option, +} + +impl Configuration { + /// Hold what the driver has already read. + /// + /// `settings` is the `Option` half of + /// [`crate::settings::stored`], which is `None` both when no file wrote the + /// section and when it could not be read — the notice for the second case is + /// the driver's to disclose, and is deliberately not re-derived here. + pub fn new(root: PathBuf, config: Config, settings: Option) -> Self { + Self { + root, + config, + settings, + reported: None, + } + } + + /// The configuration this turn is built from. + pub fn config(&self) -> &Config { + &self.config + } + + /// io-cli's own settings, from the same read as [`Configuration::config`]. + pub fn settings(&self) -> Option<&CliSettings> { + self.settings.as_ref() + } + + /// Re-read before a turn is built. Returns the sentence the operator has not + /// been told yet, where there is one. + /// + /// `Some(text)` means the configuration on disk is unreadable, this turn will + /// run on the last one that was readable, and the caller should show `text`. + /// `None` means either that the read succeeded — in which case + /// [`Configuration::config`] now answers from the file as it is now — or + /// that the same refusal has already been shown and saying it again would + /// teach the operator to stop looking. + pub fn refresh(&mut self) -> Option { + match crate::configure::reload(&self.root) { + Ok((config, settings)) => { + self.config = config; + self.settings = settings; + // Cleared, not left: the next failure after a clean read is a new + // fact even when its text matches one reported before the fix. + self.reported = None; + None + } + Err(refusal) => { + if self.reported.as_deref() == Some(refusal.as_str()) { + return None; + } + self.reported = Some(refusal.clone()); + Some(refusal) + } + } + } +} diff --git a/src/stdin.rs b/src/stdin.rs index 0b67ef7..78e86bc 100644 --- a/src/stdin.rs +++ b/src/stdin.rs @@ -9,10 +9,13 @@ //! - The keyboard reader holds it around every poll, because crossterm's `poll` //! consumes bytes into its own parser: a reader that only locked around `read` //! would still swallow whatever arrived while it was polling. -//! - A viewport being placed holds it for the placement, because placing an -//! inline viewport asks the terminal where its cursor is (`ESC[6n`) and reads -//! the answer off stdin. A reader still running takes that answer first, the -//! query times out, and the program appears to hang. +//! - The renderer holds it around anything that asks the terminal where its +//! cursor is (`ESC[6n`), because the answer arrives on stdin. A reader still +//! running takes that answer first, the query times out after two seconds, and +//! the program appears to hang — or, on the path where the query is not +//! optional, refuses to start. [`crate::term::Screen`] takes this itself at +//! every one of those sites rather than asking its callers to; a caller may +//! still hold one around a larger operation, which is why a placement nests. //! //! **The lock alone is not enough, and 0.13.1 is what that cost.** A reader that //! releases the lock at the bottom of its loop and takes it again at the top is @@ -30,6 +33,7 @@ //! That is the whole mechanism — one flag, two stores and a load — and it is //! here, in the library, because no integration test links a binary. +use std::cell::Cell; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Mutex, MutexGuard}; use std::time::Duration; @@ -67,27 +71,61 @@ static READING: Mutex<()> = Mutex::new(()); /// placement let go, and the next placement would be behind it. static WANTED: AtomicBool = AtomicBool::new(false); +thread_local! { + /// Whether *this* thread is the one holding [`READING`] for a placement. + /// + /// `std::sync::Mutex` is not reentrant, so a thread that took the lock and + /// then took it again would wait for itself forever — and the whole session + /// with it, because the thread that places viewports is also the thread that + /// draws them. Since 0.18.0 that nesting is ordinary rather than exotic: + /// `Screen` takes the lock at every site that queries the cursor, and `main` + /// still takes one around the larger operations — re-placing the viewport, + /// rewinding a turn — that are built out of several of those sites. + /// + /// A thread-local rather than a stored `ThreadId`, because that is the whole + /// question being asked and it needs no comparison to answer it. It cannot be + /// read for another thread, which is also correct: [`Placing`] holds a + /// [`MutexGuard`] and so cannot leave the thread that made it. + /// + /// A depth count would be the other spelling and it is the worse one: it is + /// only right if guards are dropped in the order they were taken, and nothing + /// here can promise that. What is recorded instead is which guard *is* the + /// holder, so the bookkeeping is order-independent. + static HOLDING: Cell = const { Cell::new(false) }; +} + /// The reader could not be asked. Its thread has nothing left to do. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Broken; -/// Take the terminal for a viewport placement, waiting for the reader's current +/// Take the terminal for a placement — anything that writes a query to the +/// terminal and reads the answer off stdin — waiting for the reader's current /// poll to end. /// /// A caller must hold this for the whole placement. Ignores a previous holder's /// panic: a poisoned lock here means the reader thread died mid-poll, and there /// is no state behind this mutex to be left inconsistent — only the terminal, and /// a dead reader must not also cost the session its viewport. +/// +/// **Reentrant.** A thread that already holds it gets a token that does nothing, +/// so the terminal stays taken for as long as the outermost caller wants it and +/// nothing waits for itself. The `HOLDING` thread-local below carries why that +/// is the ordinary case rather than a defensive one — it is named in prose +/// rather than linked because it is private and this is not. pub fn placing() -> Placing { + if HOLDING.get() { + return Placing(None); + } WANTED.store(true, Ordering::SeqCst); - Placing( - READING - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()), - ) + let held = READING + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + HOLDING.set(true); + Placing(Some(held)) } -/// The terminal, held for a placement. +/// The terminal, held for a placement — or, when this thread was already holding +/// it, a token that says so and does nothing. /// /// The flag is cleared when this is dropped rather than when the lock is taken, /// so the reader stays out of the way for the whole placement — the escape @@ -95,11 +133,18 @@ pub fn placing() -> Placing { /// mutex behind it. A reader queued there is a reader holding the terminal again /// the moment the placement finishes, which is the thing this module exists to /// stop. -pub struct Placing(#[allow(dead_code)] MutexGuard<'static, ()>); +pub struct Placing(Option>); impl Drop for Placing { fn drop(&mut self) { - WANTED.store(false, Ordering::SeqCst); + // Only the guard that actually took the lock gives it back. An inner one + // dropping must not tell the reader the terminal is free while the outer + // one is still using it, and that stays true whichever order the two are + // dropped in. + if self.0.is_some() { + HOLDING.set(false); + WANTED.store(false, Ordering::SeqCst); + } } } @@ -129,6 +174,11 @@ pub fn placement_waiting() -> bool { /// /// This is the seam a test drives: `work` needs no terminal, and the property /// under test is who gets the lock rather than what is typed. +/// +/// Not reentrant, and it does not need to be: the stand-aside above is checked +/// before the lock is taken, and a thread that is holding a [`Placing`] has +/// already said so through [`placement_waiting`] — so it declines here rather +/// than waiting for itself. pub fn reading(work: impl FnOnce() -> T) -> Option { if placement_waiting() { // A short pause rather than a spin: the reader has nothing to do until diff --git a/src/term.rs b/src/term.rs index 31c270f..4fc03a2 100644 --- a/src/term.rs +++ b/src/term.rs @@ -8,7 +8,7 @@ //! few lines at the bottom holding the composer and the status line, and it is //! the only region that repaints. //! -//! Five properties are structural rather than conventional, and each has a test +//! Six properties are structural rather than conventional, and each has a test //! that fails if it is lost: //! //! - **No alternate screen and no mouse capture.** There is no code path here @@ -25,6 +25,18 @@ //! orderly exit, a [`Drop`], a panic — pops it again. `tests/keyboard.rs` //! asserts the two balance in the byte stream, panic included: a protocol left //! pushed outlives the process, and what inherits it is the user's shell. +//! - **Nothing here asks the terminal a question without taking stdin first.** +//! Three operations put a query on the wire and read the answer back off +//! stdin: placing a viewport ([`Screen::attach_with`], and so +//! [`Screen::replace`] and [`Screen::rewind`]), recomputing one +//! ([`Screen::resize`], and [`Screen::draw`] on the frame where the size +//! moved), and — new in ratatui 0.30 — committing, because `insert_before` +//! ends by clearing the viewport and `Terminal::clear` now snapshots the +//! cursor to put it back. Each of them takes [`crate::stdin::placing`] itself, +//! so the keyboard reader stands aside for the answer; that lock is reentrant, +//! so a caller holding one around a larger operation stays correct. A site +//! that forgets is a session that dies two seconds later saying the cursor +//! position could not be read, which is what 0.18.0's first build did. //! - **A frame whose content did not change is not drawn.** Not drawn cheaply: //! not drawn at all, no bytes. [`Screen::draw`] lays every frame out where //! nothing can see it first, and only presents it if it differs from what the @@ -111,7 +123,7 @@ type Restore = Box; /// writing into a recorder rather than into a tty. The bound includes [`Write`] /// because the synchronized-output sequences are written to the backend directly: /// they wrap the frame ratatui draws, so they cannot be a widget. -pub struct Screen { +pub struct Screen + Write> { terminal: Terminal, /// Where a frame is laid out before anyone can see it. See [`Screen::draw`]. probe: Terminal, @@ -120,8 +132,14 @@ pub struct Screen { /// whatever it contains, because something outside `draw` — a commit, a /// resize — has since erased the viewport. last: Option<(Buffer, Option)>, - /// The terminal size the last frame was laid out against, kept only to - /// notice that it changed. See [`Screen::draw`]. + /// The terminal size ratatui has recorded, mirrored here. + /// + /// Two things read it and both need it to be ratatui's record rather than + /// this renderer's own: a frame laid out against a size that has since moved + /// cannot be skipped, and — since 0.18.0 — a frame that will make ratatui + /// re-place the inline viewport has to take stdin first, because re-placing + /// it asks the terminal where its cursor is. `None` until the first frame. + /// See [`Screen::draw`] and [`Screen::resize`]. size: Option, /// What the last frame drew, kept because ratatui's rendered buffer is not /// reachable once `draw` has returned. See [`Screen::viewport_text`]. @@ -170,10 +188,13 @@ impl Screen> { /// above is the terminal's and survives; what is replaced is the viewport and /// the buffers behind it. /// - /// A caller must park whatever is reading stdin first. Placing an inline - /// viewport asks the terminal where its cursor is and reads the answer off - /// stdin, so a reader still running would take the answer and this would hang - /// on a terminal that had in fact replied. + /// Placing an inline viewport asks the terminal where its cursor is and reads + /// the answer off stdin, so a reader still running would take the answer and + /// this would hang on a terminal that had in fact replied. The query itself + /// is taken under [`crate::stdin::placing`] by [`Screen::attach_with`]; a + /// caller that wants the whole operation — the erase, the re-attach and the + /// frame after it — to be one turn at the terminal holds a placement of its + /// own around this call, which nests. /// /// If the new height cannot be placed, the session's own is placed instead /// and the error returned: an operator who asked for a taller list and cannot @@ -229,9 +250,10 @@ impl Screen> { /// above moved, so the viewport has to move, and re-placing it is the only /// way an inline viewport moves at all. /// - /// A caller must park whatever is reading stdin first, for the reason - /// [`Screen::replace`] documents: placing a viewport asks the terminal where - /// its cursor is and reads the answer off stdin. + /// The cursor query is taken under [`crate::stdin::placing`], for the reason + /// [`Screen::replace`] documents; a caller wanting the erase and the + /// re-placement to be one turn at the terminal holds a placement of its own + /// around this call, which nests. pub fn rewind(&mut self, rows: u16) -> io::Result<()> { let top = self.terminal.get_frame().area().y; let up = rows.min(top); @@ -257,6 +279,12 @@ impl Screen> { } fn attach_raw(height: u16) -> io::Result { + // Placing an inline viewport asks the terminal where its cursor is, and + // negotiating the keyboard protocol asks it what it speaks; both answers + // arrive on stdin. See the module docs — every site in this file that + // asks the terminal anything takes this itself, and a caller that already + // holds one gets a token rather than a second lock. + let _placing = crate::stdin::placing(); let mut out = io::stdout(); crossterm::execute!(out, crossterm::event::EnableBracketedPaste)?; @@ -310,7 +338,7 @@ impl Screen> { } } -impl Screen { +impl + Write> Screen { /// Wrap a terminal that has already been built. The tests' way in. pub fn from_terminal(terminal: Terminal) -> Self { Self { @@ -348,6 +376,12 @@ impl Screen { return Ok(()); } + // **A commit asks the terminal where its cursor is, and that is new in + // ratatui 0.30.** `insert_before` ends by clearing the viewport off the + // screen, and `Terminal::clear` now snapshots the backend cursor so it + // can put it back afterwards — `ESC[6n`, answered on stdin, exactly like + // a placement. In 0.29 the same clear wrote only the escape. + let _placing = crate::stdin::placing(); self.terminal .insert_before(height, |buf| paragraph.render(buf.area, buf)) } @@ -374,6 +408,9 @@ impl Screen { // Same reason as `commit`: `insert_before` ends by clearing the viewport, // so the next frame is a repaint of an erased region. self.last = None; + // And the same cursor query `Screen::commit` documents: `insert_before` + // clears the viewport at the end, and clearing it reads stdin. + let _placing = crate::stdin::placing(); self.terminal.insert_before(rows, |buf| { for cell in &mut buf.content { cell.set_symbol(""); @@ -440,10 +477,22 @@ impl Screen { // whether it fired, and a resize *clears* the viewport: the frame after // one can never be skipped, whatever it contains. let size = self.terminal.size()?; - if self.size.replace(size) != Some(size) { + let moved = self.size.replace(size) != Some(size); + if moved { self.last = None; } - self.terminal.autoresize()?; + { + // **Only the frame that can actually resize pays for the terminal.** + // `autoresize` re-places the inline viewport — which asks the + // terminal where its cursor is — exactly when the size it recorded + // has moved, and `self.size` is kept in step with that record by + // every path that changes it. So this is the frame where the query + // happens and the only one that costs a hand-over; the still screen + // that repaints on every keystroke and every token does not take the + // terminal away from the keyboard to do it. + let _placing = moved.then(crate::stdin::placing); + self.terminal.autoresize()?; + } // The probe's viewport is pinned to the real one rather than computed, so // `frame.area()` — which every widget lays out against — is the same @@ -510,6 +559,16 @@ impl Screen { // Recomputing it clears it, so the next frame repaints an erased region // rather than a screen that already says what it is about to say. self.last = None; + // Recorded because this is what ratatui will now call the terminal's + // size, and `Screen::draw` decides whether `autoresize` can fire by + // comparing the two. Without this the record and ratatui's disagree + // after every resize, and a frame drawn against that disagreement + // queries the cursor with nothing standing aside for the answer. + self.size = Some(Size { width, height }); + // Recomputing an inline viewport starts by asking the terminal where its + // cursor is — the original placement hazard, at the one site that has + // always had it. + let _placing = crate::stdin::placing(); self.terminal.resize(Rect::new(0, 0, width, height)) } @@ -568,7 +627,7 @@ impl Screen { } } -impl Drop for Screen { +impl + Write> Drop for Screen { fn drop(&mut self) { self.restore(); } @@ -606,6 +665,15 @@ struct Probe { } impl Backend for Probe { + /// ratatui 0.30 let a backend name its own failure type. This one writes + /// nowhere and cannot fail, but it is handed to the same [`Screen`] as the + /// real terminal — and `Screen` requires `Backend`, so + /// that every `?` in it keeps meaning what it meant when the trait had no + /// associated type. A probe with a failure type of its own would make the + /// two backends stop being interchangeable, which is the whole reason it + /// exists. + type Error = io::Error; + fn draw<'a, I>(&mut self, _content: I) -> io::Result<()> where I: Iterator, diff --git a/src/wizard.rs b/src/wizard.rs index e1422d3..eb29d54 100644 --- a/src/wizard.rs +++ b/src/wizard.rs @@ -22,11 +22,12 @@ use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use io_harness::ProviderSpec; use ratatui::layout::Rect; +use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; use ratatui::Frame; -use tui_textarea::TextArea; +use crate::editor::Editor; use crate::keys::Newline; use crate::picker::{fit, fit_left, Outcome, Picker, Row}; use crate::settings::{self, Posture}; @@ -175,7 +176,14 @@ pub struct Wizard { posture: Posture, picker: Option, /// The masked field the credential and the base URL are typed into. - input: TextArea<'static>, + /// + /// **The mask comes off the chosen glyph set** rather than being written + /// here: a terminal that cannot draw a bullet shows a row of replacement + /// boxes, and a credential field whose masking is itself unreadable is the + /// one field where a reader cannot tell a rendering fault from having typed + /// the wrong thing. The base URL and the typed model id are the same field + /// unmasked — see [`Editor::masked`] against [`Editor::new`]. + input: Editor, /// The provider's own message from a failed verification, shown above the /// credential field. rejection: Option, @@ -212,7 +220,7 @@ impl Wizard { .to_string(), posture: Posture::Workspace, picker: None, - input: masked(theme.glyphs.mask), + input: Editor::masked(theme.glyphs.mask), rejection: None, env_key_present: false, no_color: !theme.coloured, @@ -335,7 +343,7 @@ impl Wizard { /// confirmation screen, which this never reaches. pub fn rejected(&mut self, message: impl Into) -> Progress { self.rejection = Some(message.into()); - self.input = masked(self.theme.glyphs.mask); + self.input = Editor::masked(self.theme.glyphs.mask); self.step = Step::Credential; Progress::Idle } @@ -350,7 +358,7 @@ impl Wizard { if rows.is_empty() && default.is_empty() { // Nothing to pick from and nothing to suggest. Asking the user to // type it is the honest answer; offering an empty row is not. - self.input = plain(); + self.input = Editor::new(); self.picker = None; self.step = Step::ModelText; return; @@ -415,9 +423,9 @@ impl Wizard { .map(|var| std::env::var_os(var).is_some_and(|value| !value.is_empty())) .unwrap_or(false); self.picker = None; - self.input = masked(self.theme.glyphs.mask); + self.input = Editor::masked(self.theme.glyphs.mask); self.step = if kind == Kind::Compatible { - self.input = plain(); + self.input = Editor::new(); Step::BaseUrl } else { Step::Credential @@ -439,11 +447,11 @@ impl Wizard { return Progress::Idle; } self.base_url = Some(typed); - self.input = masked(self.theme.glyphs.mask); + self.input = Editor::masked(self.theme.glyphs.mask); self.step = Step::Credential; return Progress::Idle; } - self.input.input(key); + self.input.key(key); Progress::Idle } @@ -466,12 +474,12 @@ impl Wizard { } else { self.api_key = Some(typed); } - self.input = masked(self.theme.glyphs.mask); + self.input = Editor::masked(self.theme.glyphs.mask); self.step = Step::Verifying; self.spec().map(Progress::Verify).unwrap_or(Progress::Idle) } _ => { - self.input.input(key); + self.input.key(key); Progress::Idle } } @@ -505,7 +513,7 @@ impl Wizard { self.enter_theme_step(); return Progress::Idle; } - self.input.input(key); + self.input.key(key); Progress::Idle } @@ -857,8 +865,17 @@ impl Wizard { lines, ); if area.height > used { + // **The field scrolls sideways rather than wrapping**, because it is + // one row and a credential is one line however long it is. The window + // is computed from the caret alone rather than carried between + // frames: a stateless rule that always keeps the insertion point on + // screen, which is the only thing a remembered scroll position was + // ever for here. Both the row and the caret below are placed from the + // same two numbers, so they cannot land in different cells. + let at = self.input.cursor().1; + let from = at.saturating_sub((area.width as usize).saturating_sub(1)); frame.render_widget( - &self.input, + Paragraph::new(field(&self.input.shown(), at, from)), Rect { y: area.y + used, height: 1, @@ -866,7 +883,7 @@ impl Wizard { }, ); frame.set_cursor_position(ratatui::layout::Position { - x: area.x + self.input.cursor().1 as u16, + x: area.x + (at - from) as u16, y: area.y + used, }); } @@ -975,22 +992,37 @@ fn paragraph(frame: &mut Frame, area: Rect, lines: Vec>) { frame.render_widget(Paragraph::new(lines), area); } -/// A one-line field whose characters are never shown. +/// The field's one row as it is drawn: `shown` from character `from`, with the +/// character at `at` painted as an inverted cell. /// -/// The mask comes off the chosen glyph set rather than being written here: a -/// terminal that cannot draw a bullet shows a row of replacement boxes, and a -/// credential field whose masking is itself unreadable is the one field where a -/// reader cannot tell a rendering fault from having typed the wrong thing. -fn masked(mask: char) -> TextArea<'static> { - let mut area = plain(); - area.set_mask_char(mask); - area -} - -fn plain() -> TextArea<'static> { - let mut area = TextArea::default(); - area.set_cursor_line_style(ratatui::style::Style::default()); - area +/// `shown` is already masked where the field is masked, and a mask is one +/// character per character — so `at` indexes the drawn row exactly as it indexes +/// the typed one, and no part of the credential can reach a `Span` from here. +/// +/// The inverted cell is the field's block cursor. It is drawn even though the +/// caller also places the terminal's own caret on the same cell: that is what +/// this field has always looked like, and the two agree because both are placed +/// from `at` and `from`. +fn field(shown: &str, at: usize, from: usize) -> Line<'static> { + let mut characters = shown.chars().skip(from); + let before: String = characters.by_ref().take(at - from).collect(); + let under: String = characters.by_ref().take(1).collect(); + let after: String = characters.collect(); + Line::from(vec![ + Span::raw(before), + // A space, when the caret is past the last character: a block cursor is a + // cell rather than a character, and it has to be somewhere even on an + // empty field. + Span::styled( + if under.is_empty() { + " ".to_string() + } else { + under + }, + Style::default().add_modifier(Modifier::REVERSED), + ), + Span::raw(after), + ]) } /// Whether a key event is one the wizard treats as "leave without writing". diff --git a/tests/commands.rs b/tests/commands.rs index 035453b..9c6efb4 100644 --- a/tests/commands.rs +++ b/tests/commands.rs @@ -54,6 +54,11 @@ fn the_commands_are_the_commands() { "/copy", "/copy diff", "/config", + // 0.18.0 — the other two surfaces that write a file the operator + // keeps, and they ask the same scope question `/config` does: three + // files, and which one is half of every decision made here. + "/remember", + "/memory", "/mcp", "/provider", "/profile", @@ -534,6 +539,423 @@ fn f15_help_is_committed_rather_than_drawn_as_an_overlay() { } } +// --- 0.18.0: the two commands that reach the memory --------------------------- + +/// `/remember` takes the REST of the line, and `/memory` takes nothing. +/// +/// The first half is the one with a defect behind it. A line of guidance is a +/// *sentence*, and `split_whitespace().nth(1)` would remember its first word and +/// drop the rest with nothing said — the reader would see a confirmation naming a +/// real file and find one word in it. `/config`'s value arm already documents the +/// same trap for arrays with spaces in them; here what is lost is prose, which +/// nobody notices missing until a later run behaves as though it had never been +/// told. +#[test] +fn remember_keeps_the_whole_line_and_memory_takes_no_argument() { + assert_eq!( + commands::parse("remember run the linter before pushing", &defaults(), &DARK), + Action::Remember("run the linter before pushing".to_string()), + ); + // Interior spacing is the operator's; only the ends are trimmed. + assert_eq!( + commands::parse("remember two spaces ", &defaults(), &DARK), + Action::Remember("two spaces".to_string()), + ); + // The word alone is empty rather than a refusal here: the sentence saying + // what to type belongs to the driver, which would otherwise open a picker + // over three files it is about to write nothing into. + assert_eq!( + commands::parse("remember", &defaults(), &DARK), + Action::Remember(String::new()), + ); + assert_eq!( + commands::parse("remember ", &defaults(), &DARK), + Action::Remember(String::new()), + ); + + assert_eq!( + commands::parse("memory", &defaults(), &DARK), + Action::Memory + ); + // Arguments are tolerated and the first word decides, as everywhere else. + assert_eq!( + commands::parse("memory now", &defaults(), &DARK), + Action::Memory, + ); + + // Neither falls through to the print an unknown command lands on, which is + // the failure a listed-but-unwired command has: advertised and inert. + assert!(!matches!( + commands::parse("memory", &defaults(), &DARK), + Action::Print(_) + )); + assert!(!matches!( + commands::parse("remember x", &defaults(), &DARK), + Action::Print(_) + )); +} + +/// Both are listed, both are in the `Configure` group, and both have a palette +/// row that resolves back to the command whole. +/// +/// The sabotage arm is listing a command without wiring its row — the one way a +/// listed command must not fail. `palette_pick` is what the driver resolves a +/// chosen row through, so asking it is asking the driver. +#[test] +fn the_memory_commands_are_configure_commands_with_working_palette_rows() { + use io_cli::commands::{group_of, palette, palette_pick, Chosen, Group}; + use io_harness::{Skills, Templates}; + + for name in ["/remember", "/memory"] { + assert!( + COMMANDS.iter().any(|(command, _)| *command == name), + "{name} is not in the inventory, so nobody is told it exists", + ); + // Configure, and not Inspect: both of these write, and Inspect already + // holds ten, which is the bound `f13_no_group_is_longer_than_ten` asserts. + assert_eq!( + group_of(name), + Some(Group::Configure), + "{name} writes a file the operator keeps, so it belongs with the group that writes", + ); + + let rows = palette(&Templates::none(), &Skills::none()); + let bare = name.strip_prefix('/').expect("a command carries its slash"); + let index = rows + .iter() + .position(|row| row.label == bare) + .unwrap_or_else(|| panic!("{name} has no palette row")); + assert_eq!( + palette_pick(&Templates::none(), &Skills::none(), index), + Some(Chosen::Command(name)), + "{name}'s row is advertised and inert", + ); + } +} + +/// `Forgotten::Refused` is reported as a refusal that names why, never as a +/// removal. +/// +/// The one outcome of `Store::memory_forget` that must not be collapsed into the +/// others. The note is pinned, so it is not a run's to withdraw and io-cli asks on +/// a run's behalf: it stands, unchanged, and goes on being carried into every +/// later prompt. A surface reporting that as success tells the operator their note +/// is gone while the model keeps reading it — the same failure the pin flag exists +/// to prevent one level down. +#[test] +fn a_refused_withdrawal_is_a_refusal_and_names_the_pin() { + use io_cli::commands::forgotten_said; + use io_cli::glyphs::ASCII; + use io_cli::recall::{Forgotten, Scope}; + use io_cli::theme::Tone; + + let (tone, said) = forgotten_said( + "build-command", + Scope::Workspace, + Forgotten::Refused, + &ASCII, + ); + assert_eq!(tone, Tone::Refused, "a refusal is not a success: {said}"); + assert!( + said.contains("pinned"), + "the reason is what makes it actionable: {said}", + ); + assert!(said.contains("Unpin"), "and the way out is named: {said}",); + assert!( + !said.contains("withdrawn") && !said.contains("removed"), + "nothing was withdrawn, so nothing may say it was: {said}", + ); + + // The other two are three different things and stay three. + let (removed, text) = forgotten_said( + "k", + Scope::Global, + Forgotten::Removed { restore: 9 }, + &ASCII, + ); + assert_eq!(removed, Tone::Success); + assert!(text.contains('9'), "the restore point is named: {text}"); + let (absent, text) = forgotten_said("k", Scope::Global, Forgotten::Absent, &ASCII); + assert_ne!( + absent, + Tone::Success, + "an absent key was not removed: {text}" + ); + assert_ne!( + absent, + Tone::Refused, + "and it was not refused either: {text}" + ); +} + +/// A note that exists and is not pinned reports "no entry" rather than success. +/// +/// `Store::memory_pin` answers `false` for *there was no such entry*, which is not +/// "the pin failed" — and a `bool` at a call site reads as "did it work". A surface +/// that believed it shows a pin the store does not hold. +#[test] +fn pinning_a_key_that_is_not_there_is_not_reported_as_a_pin() { + use io_cli::commands::pinned_said; + use io_cli::recall::{Pinned, Scope}; + use io_cli::theme::Tone; + + let (tone, said) = pinned_said("gone", Scope::Workspace, true, Pinned::NoEntry); + assert_ne!(tone, Tone::Success, "{said}"); + assert!(said.contains("nothing was changed"), "{said}"); + + let (tone, said) = pinned_said("k", Scope::Workspace, true, Pinned::Set); + assert_eq!(tone, Tone::Success); + assert!(said.contains("pinned"), "{said}"); + let (_, unpinned) = pinned_said("k", Scope::Workspace, false, Pinned::Set); + assert!(unpinned.contains("unpinned"), "{unpinned}"); +} + +/// The scope rows say what committing each file MEANS, not only its name. +/// +/// That is the entire difference between the three: `IO.md`, `AGENTS.md` and +/// `AGENTS.local.md` are three filenames, and which of them goes to everybody who +/// clones the repository is not knowable from any of them. A picker offering three +/// filenames would be asking a question whose answer lives somewhere else. +#[test] +fn the_remember_scope_rows_name_the_file_and_what_committing_it_means() { + use io_cli::commands::scope_rows; + use io_cli::glyphs::ASCII; + use io_harness::config::Scope; + + let paths: Vec<(Scope, std::path::PathBuf)> = [Scope::Project, Scope::Local, Scope::User] + .into_iter() + .map(|scope| { + ( + scope, + std::path::PathBuf::from("/w").join(io_cli::memory::file_name(scope)), + ) + }) + .collect(); + let rows = scope_rows(&paths, &ASCII); + assert_eq!(rows.len(), 3); + + for (row, (scope, path)) in rows.iter().zip(&paths) { + assert_eq!( + row.label, + io_cli::memory::file_name(*scope), + "the label is the file, which is what the operator knows it by", + ); + let detail = row.detail.clone().unwrap_or_default(); + assert!( + detail.contains(&path.display().to_string()), + "the row names the file it writes: {detail}", + ); + } + + let said = |n: usize| rows[n].detail.clone().unwrap_or_default(); + assert!( + said(0).contains("committed"), + "AGENTS.md is the one that goes to everybody who clones: {}", + said(0), + ); + assert!( + said(1).contains("never committed"), + "AGENTS.local.md is the one that goes nowhere: {}", + said(1), + ); + assert!( + said(2).contains("every project on this machine"), + "IO.md is the one that follows the operator: {}", + said(2), + ); + // The consequence leads and the path follows, because the picker fits a + // detail from the head — so on a narrow terminal the sentence that decides + // the answer is what survives and the path is what goes. + for row in &rows { + let detail = row.detail.clone().unwrap_or_default(); + assert!( + !detail.starts_with('/'), + "the path leads, so the consequence is what a narrow terminal drops: {detail}", + ); + } +} + +/// The memory page keeps its two lists distinguishable, and never presents a cut +/// draw scan as an exact count. +#[test] +fn the_memory_page_separates_the_two_memories_and_qualifies_a_cut_draw_count() { + use io_cli::commands::{ + memory_notes, memory_page, Held, LOOSE_MARK, PINNED_MARK, READ_MARK, UNREAD_MARK, + }; + use io_cli::glyphs::ASCII; + use io_cli::memory::Instruction; + use io_cli::recall::{Caps, Remembered, Scope, View}; + use io_harness::config::Scope as FileScope; + + let files = vec![ + Instruction { + scope: FileScope::Project, + path: "/w/AGENTS.md".into(), + exists: true, + lines: 12, + read: true, + }, + // The case the page exists for: there, and not being read. + Instruction { + scope: FileScope::Local, + path: "/w/AGENTS.local.md".into(), + exists: true, + lines: 3, + read: false, + }, + Instruction { + scope: FileScope::User, + path: "/home/someone/.io-cli/IO.md".into(), + exists: false, + lines: 0, + read: false, + }, + ]; + let entries = vec![ + Remembered { + scope: Scope::Workspace, + key: "build-command".into(), + value: "cargo test".into(), + kind: "fact", + pinned: true, + run_id: 4, + step: 2, + created_at: "2026-08-26 09:00".into(), + draws: 6, + }, + Remembered { + scope: Scope::Global, + key: "prefers-terse".into(), + value: "keep answers short".into(), + kind: "decision", + pinned: false, + run_id: 9, + step: 1, + created_at: "2026-08-26 09:01".into(), + draws: 0, + }, + ]; + + let (rows, held) = memory_page(&files, &entries, false, &ASCII); + assert_eq!( + rows.len(), + held.len(), + "the rows and what they stand for are built in one pass and cannot differ", + ); + // Two headings, so the two memories are never one list. + let headings: Vec<&str> = rows + .iter() + .filter(|row| row.heading) + .map(|row| row.label.as_str()) + .collect(); + assert_eq!(headings.len(), 2, "got {headings:?}"); + for (row, held) in rows.iter().zip(&held) { + assert_eq!(row.heading, *held == Held::Nothing); + } + + let mark = |label: &str| { + rows.iter() + .find(|row| row.label == label) + .unwrap_or_else(|| panic!("no row for {label}")) + .mark + .expect("every row but a heading carries a mark") + }; + assert_eq!(mark("AGENTS.md"), READ_MARK); + assert_eq!(mark("AGENTS.local.md"), UNREAD_MARK); + assert_eq!(mark("build-command"), PINNED_MARK); + assert_eq!(mark("prefers-terse"), LOOSE_MARK); + + // A file that exists and is not read SAYS so; nothing else in this product + // does, because io-harness skips one without a word. + let detail = |label: &str| { + rows.iter() + .find(|row| row.label == label) + .and_then(|row| row.detail.clone()) + .unwrap_or_default() + }; + assert!( + detail("AGENTS.local.md").contains("NOT read"), + "{}", + detail("AGENTS.local.md") + ); + assert!( + detail("IO.md").contains("not written yet"), + "{}", + detail("IO.md") + ); + assert!( + detail("AGENTS.md").starts_with("read"), + "{}", + detail("AGENTS.md") + ); + // The bucket a note came from leads its detail: "is this true here, or true + // everywhere" is the only question the two scopes exist to answer. + assert!(detail("build-command").starts_with("workspace")); + assert!(detail("prefers-terse").starts_with("global")); + + // An uncut scan states a count. + assert!( + detail("build-command").contains("6 draws"), + "{}", + detail("build-command") + ); + assert!( + !detail("build-command").contains("or more"), + "an uncut scan is a count, not a floor: {}", + detail("build-command"), + ); + // A cut one states a floor, in words, on every row. + let (cut_rows, _) = memory_page(&files, &entries, true, &ASCII); + for label in ["build-command", "prefers-terse"] { + let detail = cut_rows + .iter() + .find(|row| row.label == label) + .and_then(|row| row.detail.clone()) + .unwrap_or_default(); + assert!( + detail.contains("draws or more"), + "a cut scan makes every count a lower bound: {detail}", + ); + } + + // And the caps are stated PER SCOPE, with the real ceiling named. One number + // is half the ceiling for a run drawing on both, and half a ceiling makes an + // ordinary eviction read as a defect. + let view = View { + workspace: "/w".into(), + entries, + caps: [Scope::Workspace, Scope::Global] + .map(|scope| Caps { + scope, + limits: io_harness::MemoryLimits { + max_entries: 32, + max_chars: 8_000, + max_entry_chars: 500, + }, + }) + .to_vec(), + trace: Vec::new(), + draws_cut: true, + }; + let notes = memory_notes(&view, &ASCII).join("\n"); + assert!(notes.contains("per scope"), "{notes}"); + assert!(notes.contains("workspace 32 entries"), "{notes}"); + assert!(notes.contains("global 32 entries"), "{notes}"); + assert!( + notes.contains("64 entries"), + "the ceiling across both scopes is the honest number: {notes}", + ); + assert!( + notes.contains("lower bound"), + "a cut scan is disclosed on the page, not only on a row: {notes}", + ); + assert!( + notes.contains("/w"), + "the bucket that answered is named, so an empty list can be told from the \ + wrong bucket: {notes}", + ); +} + #[test] fn f17_usage_answers_and_is_never_listed() { use io_cli::commands::{group_of, palette, GROUPS}; diff --git a/tests/dependencies.rs b/tests/dependencies.rs index 8a03fc3..a4b29e8 100644 --- a/tests/dependencies.rs +++ b/tests/dependencies.rs @@ -22,7 +22,17 @@ const ALLOWED: &[&str] = &[ "io-harness", "ratatui", "crossterm", - "tui-textarea", + // `tui-textarea` stood here from 0.1.0 and was **removed** in 0.18.0 — the + // first name this crate has ever given back. It pinned `ratatui ^0.29.0` + // through every feature path it had, 0.29 pinned `lru ^0.12.0`, and `lru` + // carried an advisory whose fix landed in 0.16.3 and was never backported to + // the 0.12 line. Upstream published nothing after 2024-10-22, so there was no + // version to wait for: the composer's editing model became this crate's own + // (`src/editor.rs`) and the name went. + // `n1_the_composer_is_free_and_no_lru_is_in_the_advisorys_range` below asserts + // it is gone from the lockfile — a count on its own would be satisfied by any + // replacement — and asserts the `lru` still in the tree is outside the + // advisory's range, which is the property that actually mattered. "clap", "tokio", "serde", @@ -182,6 +192,87 @@ fn f7_n1_the_dependency_set_is_the_one_the_contract_names() { ); } +/// N1: the two crates 0.18.0 gave back are gone from the whole tree, not just +/// from the manifest. +/// +/// **The absence of these two names is the property, and the count is not.** A +/// dependency count stays constant under a substitution, so `ALLOWED` shrinking +/// by one proves only that something left — it would be equally satisfied by +/// swapping `tui-textarea` for another editing widget, which is the one outcome +/// this release exists to avoid. +/// +/// `lru` is the whole reason, and **it is still in the tree** — which is the +/// correction this test exists in its present form to record. The advisory was +/// never about the crate being present; it was about the *version*. +/// GHSA-rhfx-m35p-ff5j covers `>= 0.9.0, < 0.16.3`, `lru` under `ratatui` 0.29 +/// was 0.12.5, and the 0.12 line ends there with no backport. Under the 0.30 +/// line the layout cache moved down into `ratatui-core`, which takes a current +/// `lru` — so the name reappears at a version the advisory does not cover. See +/// `US-IO-CLI-0.18.0-I02`. +/// +/// So this asserts the property that actually matters: no `lru` in the +/// vulnerable range. It reads `Cargo.lock` rather than the manifest, because +/// `lru` is transitive at every point in this story and an advisory does not +/// care which level of the tree carries the crate. +/// +/// `ratatui` must still be there, which is what stops this passing on a tree that +/// has lost the terminal library altogether. +#[test] +fn n1_the_composer_is_free_and_no_lru_is_in_the_advisorys_range() { + let lock = std::fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.lock")) + .expect("Cargo.lock exists; this crate is a binary and commits its lockfile"); + + let mut names: Vec<(&str, &str)> = Vec::new(); + let mut name: Option<&str> = None; + for line in lock.lines() { + if let Some(found) = line + .strip_prefix("name = \"") + .and_then(|r| r.strip_suffix('"')) + { + name = Some(found); + } else if let Some(version) = line + .strip_prefix("version = \"") + .and_then(|r| r.strip_suffix('"')) + { + if let Some(found) = name.take() { + names.push((found, version)); + } + } + } + + assert!( + !names.iter().any(|(name, _)| *name == "tui-textarea"), + "`tui-textarea` is back in Cargo.lock. The editing model it provided is \ + `src/editor.rs` now, and its `ratatui ^0.29.0` pin is what held this \ + crate on the vulnerable `lru` line for two years.", + ); + + for (name, version) in names.iter().filter(|(name, _)| *name == "lru") { + // The range is `>= 0.9.0, < 0.16.3`, and every version in it is `0.x`, + // so comparing the minor is enough to place it: 0.16 is the first minor + // that can be outside, and inside it only 0.16.3 and later are. + let mut parts = version.split('.').map(|p| p.parse::().unwrap_or(0)); + let (major, minor, patch) = ( + parts.next().unwrap_or(0), + parts.next().unwrap_or(0), + parts.next().unwrap_or(0), + ); + let vulnerable = major == 0 && (minor < 16 || (minor == 16 && patch < 3)) && minor >= 9; + assert!( + !vulnerable, + "`{name} {version}` is inside GHSA-rhfx-m35p-ff5j (>= 0.9.0, < 0.16.3). \ + The 0.12 line was never patched, so there is no in-range update — \ + whatever pulled this in has to move instead.", + ); + } + + assert!( + names.iter().any(|(name, _)| *name == "ratatui"), + "ratatui is not in the lockfile at all, so this gate is passing \ + vacuously rather than because the tree is actually clean", + ); +} + #[test] fn f7_there_is_no_http_client_no_tls_no_database_and_no_sandbox() { let declared = direct_dependencies(); diff --git a/tests/editor.rs b/tests/editor.rs new file mode 100644 index 0000000..5fde3b9 --- /dev/null +++ b/tests/editor.rs @@ -0,0 +1,760 @@ +//! The editing model, on its own. +//! +//! `tests/composer.rs` and `tests/wizard.rs` own what an operator sees; this file +//! owns the buffer underneath them — the cursor arithmetic, the boundaries where +//! an edit either joins two rows or does nothing at all, and the masking the +//! credential field's whole promise rests on. +//! +//! **Columns are characters here, never bytes**, and that is most of what these +//! tests are for. io-cli's prompt takes pasted paths with a narrow no-break space +//! in them, prose in scripts that are three bytes to the character, and emoji; a +//! column that counted bytes would put the caret inside a character and panic on +//! the next keystroke. + +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; +use io_cli::editor::Editor; + +fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) +} + +fn chord(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent { + KeyEvent::new(code, modifiers) +} + +fn typed(editor: &mut Editor, text: &str) { + for character in text.chars() { + editor.key(key(KeyCode::Char(character))); + } +} + +/// Everything in the buffer, rows joined the way [`io_cli::composer`] joins them. +fn text(editor: &Editor) -> String { + editor.lines().join("\n") +} + +fn holding(text: &str) -> Editor { + let mut editor = Editor::new(); + typed(&mut editor, text); + editor +} + +/// `Ctrl+U`, pressed the way an operator presses it. +/// +/// Every undo test goes through the **key** rather than through `Editor::undo`, +/// because the key is what regressed: io-cli forwards raw crossterm events into +/// the buffer, the buffer this replaced bound `Ctrl+U` itself +/// (`tui-textarea-0.7.0/src/textarea.rs:576-587`), and a working `undo()` that +/// nothing was bound to would be the same silent loss. +fn undo(editor: &mut Editor) { + editor.key(chord(KeyCode::Char('u'), KeyModifiers::CONTROL)); +} + +/// `Ctrl+R`, for the same reason. +fn redo(editor: &mut Editor) { + editor.key(chord(KeyCode::Char('r'), KeyModifiers::CONTROL)); +} + +// --- typing, and where the cursor ends up --- + +#[test] +fn typing_puts_the_characters_in_and_the_cursor_after_them() { + let editor = holding("fix the failing test"); + assert_eq!(text(&editor), "fix the failing test"); + assert_eq!(editor.cursor(), (0, 20)); + assert_eq!(editor.lines().len(), 1, "nothing opened a second row"); +} + +#[test] +fn a_capital_arrives_as_itself_rather_than_as_a_modifier() { + // crossterm reports a capital as the capital *with* `SHIFT` still set. An + // editor that read the modifier before the character would refuse to type + // one, which is the whole alphabet gone. + let mut editor = Editor::new(); + editor.key(chord(KeyCode::Char('A'), KeyModifiers::SHIFT)); + assert_eq!(text(&editor), "A"); +} + +#[test] +fn a_key_release_is_not_a_second_keystroke() { + // Windows, and any terminal with the Kitty keyboard protocol on, reports the + // release of every key it reported the press of. Acting on both is every + // character typed twice. + let mut editor = Editor::new(); + editor.key(KeyEvent::new_with_kind( + KeyCode::Char('x'), + KeyModifiers::NONE, + KeyEventKind::Press, + )); + editor.key(KeyEvent { + code: KeyCode::Char('x'), + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Release, + state: KeyEventState::NONE, + }); + assert_eq!(text(&editor), "x"); +} + +#[test] +fn a_tab_is_spaces_to_the_next_stop() { + let mut editor = holding("ab"); + editor.key(key(KeyCode::Tab)); + assert_eq!(text(&editor), "ab "); + editor.key(key(KeyCode::Tab)); + assert_eq!(text(&editor), "ab ", "a full stop from a full column"); +} + +// --- multi-byte characters --- + +#[test] +fn the_cursor_counts_characters_and_not_bytes() { + // Five characters, six bytes. A column that counted bytes would report six + // here, and the composer's wrap would put the caret a cell to the right of + // where the text is. + let editor = holding("héllo"); + assert_eq!(editor.cursor(), (0, 5)); + assert_eq!(text(&editor).len(), 6, "the fixture really is multi-byte"); +} + +#[test] +fn the_arrows_walk_a_multi_byte_line_one_character_at_a_time() { + let mut editor = holding("héllo"); + for expected in (0..5).rev() { + editor.key(key(KeyCode::Left)); + assert_eq!(editor.cursor(), (0, expected)); + } + editor.key(key(KeyCode::Left)); + assert_eq!( + editor.cursor(), + (0, 0), + "there is nowhere further back to go" + ); + for expected in 1..=5 { + editor.key(key(KeyCode::Right)); + assert_eq!(editor.cursor(), (0, expected)); + } +} + +#[test] +fn deleting_takes_one_whole_character_however_many_bytes_it_is() { + // Four bytes to the character, and it is the pasted-screenshot case: an + // editor that removed a byte would leave the buffer holding something that + // is not text at all. + let mut editor = holding("🙂🙂🙂"); + assert_eq!(editor.cursor(), (0, 3)); + editor.key(key(KeyCode::Backspace)); + assert_eq!(text(&editor), "🙂🙂"); + assert_eq!(editor.cursor(), (0, 2)); +} + +#[test] +fn inserting_in_the_middle_of_a_multi_byte_line_lands_on_the_boundary() { + let mut editor = holding("héllo"); + editor.key(key(KeyCode::Left)); + editor.key(key(KeyCode::Left)); + typed(&mut editor, "X"); + assert_eq!(text(&editor), "hélXlo"); +} + +#[test] +fn the_narrow_no_break_space_a_screenshot_carries_survives_a_round_trip() { + // The exact character macOS puts between the time and the `AM` in every + // screenshot's name — whitespace, three bytes, and the one that has already + // cost this product a release. + let name = "Screenshot 2026-08-24 at 8.00.01\u{202f}AM.png"; + let mut editor = Editor::new(); + editor.insert_str(name); + assert_eq!(text(&editor), name); + assert_eq!(editor.cursor(), (0, name.chars().count())); +} + +// --- boundaries: where an edit joins two rows, or does nothing --- + +#[test] +fn insert_str_opens_a_row_for_every_newline_and_keeps_the_tail() { + let mut editor = holding("start end"); + for _ in 0..3 { + editor.key(key(KeyCode::Left)); + } + editor.insert_str("one\ntwo\n"); + + assert_eq!(text(&editor), "start one\ntwo\nend"); + assert_eq!( + editor.cursor(), + (2, 0), + "the caret is where the text ran out" + ); +} + +#[test] +fn a_carriage_return_before_a_newline_is_not_a_row_of_its_own() { + let mut editor = Editor::new(); + editor.insert_str("one\r\ntwo"); + assert_eq!(editor.lines(), ["one", "two"]); +} + +#[test] +fn backspace_at_the_head_of_a_row_joins_it_to_the_one_above() { + let mut editor = Editor::new(); + editor.insert_str("abc\nd"); + editor.key(key(KeyCode::Backspace)); + editor.key(key(KeyCode::Backspace)); + + assert_eq!(text(&editor), "abc"); + assert_eq!(editor.lines().len(), 1); + assert_eq!( + editor.cursor(), + (0, 3), + "the caret comes back up with the text", + ); +} + +#[test] +fn backspace_at_the_very_start_does_nothing_at_all() { + let mut editor = Editor::new(); + editor.key(key(KeyCode::Backspace)); + assert_eq!(text(&editor), ""); + assert_eq!(editor.cursor(), (0, 0)); + assert_eq!(editor.lines().len(), 1, "there is always one row"); +} + +#[test] +fn delete_at_the_very_end_does_nothing_at_all() { + let mut editor = holding("abc"); + editor.key(key(KeyCode::Delete)); + assert_eq!(text(&editor), "abc"); + assert_eq!(editor.cursor(), (0, 3)); +} + +#[test] +fn delete_at_the_end_of_a_row_pulls_the_next_one_up() { + let mut editor = Editor::new(); + editor.insert_str("abc\ndef"); + editor.key(key(KeyCode::Up)); + editor.key(chord(KeyCode::Char('e'), KeyModifiers::CONTROL)); + assert_eq!(editor.cursor(), (0, 3)); + + editor.key(key(KeyCode::Delete)); + assert_eq!(text(&editor), "abcdef"); + assert_eq!( + editor.cursor(), + (0, 3), + "the caret stayed where the join is" + ); +} + +#[test] +fn an_up_arrow_on_the_first_row_leaves_the_cursor_where_it_is() { + // The composer reads `cursor().0 == 0` to decide whether `Up` recalls a + // prompt or moves the caret, so an `Up` that wrapped around would take the + // history key away. + let mut editor = holding("only one row"); + editor.key(key(KeyCode::Up)); + assert_eq!(editor.cursor(), (0, 12)); +} + +#[test] +fn the_arrows_keep_the_column_they_can_and_clamp_the_column_they_cannot() { + let mut editor = Editor::new(); + editor.insert_str("a longer row\nshort\nanother longer row"); + editor.key(chord(KeyCode::Char('e'), KeyModifiers::CONTROL)); + assert_eq!(editor.cursor(), (2, 18)); + + editor.key(key(KeyCode::Up)); + assert_eq!(editor.cursor(), (1, 5), "clamped to the short row"); + editor.key(key(KeyCode::Up)); + assert_eq!( + editor.cursor(), + (0, 5), + "and it does not remember the column it wanted", + ); +} + +// --- what the composer reads off it --- + +#[test] +fn the_rows_are_what_the_composer_wraps_and_the_last_one_is_the_last() { + let mut editor = Editor::new(); + editor.insert_str("one\ntwo\nthree"); + assert_eq!(editor.lines(), ["one", "two", "three"]); + assert_eq!(editor.cursor(), (2, 5)); + assert_eq!( + editor.cursor().0 + 1, + editor.lines().len(), + "the composer asks this to decide whether `Down` walks history", + ); +} + +#[test] +fn move_to_end_goes_to_the_end_of_the_row_the_cursor_is_on() { + let mut editor = Editor::new(); + editor.insert_str("alpha\nbeta"); + editor.key(key(KeyCode::Up)); + editor.key(chord(KeyCode::Char('a'), KeyModifiers::CONTROL)); + assert_eq!(editor.cursor(), (0, 0)); + + editor.move_to_end(); + assert_eq!( + editor.cursor(), + (0, 5), + "the end of this row, not of the text" + ); +} + +#[test] +fn insert_newline_carries_the_rest_of_the_row_down() { + let mut editor = holding("abcd"); + editor.key(key(KeyCode::Left)); + editor.key(key(KeyCode::Left)); + editor.insert_newline(); + assert_eq!(editor.lines(), ["ab", "cd"]); + assert_eq!(editor.cursor(), (1, 0)); +} + +// --- selection, cut and put back --- + +#[test] +fn select_all_then_cut_empties_the_buffer_and_keeps_what_it_took() { + let mut editor = holding("hello"); + editor.select_all(); + assert!(editor.selecting()); + editor.cut(); + + assert_eq!(text(&editor), ""); + assert_eq!(editor.lines().len(), 1); + assert_eq!(editor.cursor(), (0, 0)); + assert!(!editor.selecting(), "the selection went with the text"); + + editor.key(chord(KeyCode::Char('y'), KeyModifiers::CONTROL)); + assert_eq!(text(&editor), "hello", "the cut text came back"); +} + +#[test] +fn a_cut_that_crossed_a_newline_comes_back_as_the_rows_it_was() { + let mut editor = Editor::new(); + editor.insert_str("one\ntwo\nthree"); + editor.select_all(); + editor.cut(); + assert_eq!(editor.lines(), [""]); + + editor.key(chord(KeyCode::Char('y'), KeyModifiers::CONTROL)); + assert_eq!( + editor.lines(), + ["one", "two", "three"], + "a multi-row cut put back as one row would be a different prompt", + ); + assert_eq!(editor.cursor(), (2, 5)); +} + +#[test] +fn select_all_on_an_empty_buffer_selects_nothing_and_cuts_nothing() { + let mut editor = Editor::new(); + editor.select_all(); + assert!( + !editor.selecting(), + "the two ends are the same position, so there is no selection", + ); + assert!(!editor.cut()); + assert_eq!(editor.lines(), [""]); +} + +#[test] +fn a_shifted_arrow_selects_and_the_next_character_replaces_the_selection() { + let mut editor = holding("hello"); + for _ in 0..3 { + editor.key(chord(KeyCode::Left, KeyModifiers::SHIFT)); + } + assert!(editor.selecting()); + + typed(&mut editor, "x"); + assert_eq!(text(&editor), "hex"); + assert!(!editor.selecting()); +} + +#[test] +fn a_bare_arrow_ends_a_selection_without_deleting_it() { + let mut editor = holding("hello"); + editor.key(chord(KeyCode::Left, KeyModifiers::SHIFT)); + editor.key(chord(KeyCode::Left, KeyModifiers::SHIFT)); + editor.key(key(KeyCode::Left)); + + assert!(!editor.selecting()); + assert_eq!(text(&editor), "hello", "moving away is not deleting"); +} + +#[test] +fn copy_keeps_the_selection_text_and_leaves_the_buffer_alone() { + let mut editor = holding("hello"); + for _ in 0..5 { + editor.key(chord(KeyCode::Left, KeyModifiers::SHIFT)); + } + editor.key(chord(KeyCode::Char('c'), KeyModifiers::CONTROL)); + assert_eq!(text(&editor), "hello"); + + editor.move_to_end(); + editor.key(chord(KeyCode::Char('y'), KeyModifiers::CONTROL)); + assert_eq!(text(&editor), "hellohello"); +} + +#[test] +fn a_selection_that_crosses_rows_deletes_the_newline_with_it() { + let mut editor = Editor::new(); + editor.insert_str("one\ntwo"); + editor.key(chord(KeyCode::Left, KeyModifiers::SHIFT)); + editor.key(chord(KeyCode::Left, KeyModifiers::SHIFT)); + editor.key(chord(KeyCode::Left, KeyModifiers::SHIFT)); + editor.key(chord(KeyCode::Left, KeyModifiers::SHIFT)); + editor.key(key(KeyCode::Backspace)); + + assert_eq!(editor.lines(), ["one"], "the two rows became one"); + assert_eq!(editor.cursor(), (0, 3)); +} + +// --- the word-wise and line-wise keys --- + +#[test] +fn a_word_delete_takes_one_word_and_puts_it_back() { + let mut editor = holding("one two three"); + editor.key(chord(KeyCode::Char('w'), KeyModifiers::CONTROL)); + assert_eq!(text(&editor), "one two "); + + editor.key(chord(KeyCode::Backspace, KeyModifiers::ALT)); + assert_eq!(text(&editor), "one "); + + editor.key(chord(KeyCode::Char('y'), KeyModifiers::CONTROL)); + assert_eq!(text(&editor), "one two "); +} + +#[test] +fn a_word_delete_stops_at_a_separator_rather_than_eating_a_whole_path() { + // Punctuation is its own kind, which is what makes `Option+Backspace` inside + // a pasted path stop at the slash instead of taking the path. + let mut editor = holding("/home/someone/notes.md"); + editor.key(chord(KeyCode::Backspace, KeyModifiers::ALT)); + assert_eq!(text(&editor), "/home/someone/notes."); +} + +#[test] +fn ctrl_k_and_ctrl_j_cut_to_the_two_ends_of_the_row() { + let mut editor = holding("abcde"); + editor.key(key(KeyCode::Left)); + editor.key(key(KeyCode::Left)); + editor.key(key(KeyCode::Left)); + editor.key(chord(KeyCode::Char('k'), KeyModifiers::CONTROL)); + assert_eq!(text(&editor), "ab"); + + let mut editor = holding("abcde"); + editor.key(key(KeyCode::Left)); + editor.key(key(KeyCode::Left)); + editor.key(key(KeyCode::Left)); + editor.key(chord(KeyCode::Char('j'), KeyModifiers::CONTROL)); + assert_eq!(text(&editor), "cde"); + assert_eq!(editor.cursor(), (0, 0)); +} + +#[test] +fn the_word_moves_walk_the_row_without_changing_it() { + let mut editor = holding("one two three"); + editor.key(chord(KeyCode::Char('b'), KeyModifiers::ALT)); + assert_eq!(editor.cursor(), (0, 8)); + editor.key(chord(KeyCode::Char('b'), KeyModifiers::ALT)); + assert_eq!(editor.cursor(), (0, 4)); + editor.key(chord(KeyCode::Char('f'), KeyModifiers::ALT)); + assert_eq!(editor.cursor(), (0, 8)); + assert_eq!(text(&editor), "one two three"); +} + +#[test] +fn ctrl_a_and_ctrl_e_go_to_the_ends_of_the_row() { + let mut editor = holding("a line of text"); + editor.key(chord(KeyCode::Char('a'), KeyModifiers::CONTROL)); + assert_eq!(editor.cursor(), (0, 0)); + editor.key(chord(KeyCode::Char('e'), KeyModifiers::CONTROL)); + assert_eq!(editor.cursor(), (0, 14)); +} + +// --- masking: F9's sabotage arm drops it, so it has to genuinely mask --- + +#[test] +fn a_masked_field_shows_the_mask_and_nothing_of_what_was_typed() { + let mut editor = Editor::masked('•'); + typed(&mut editor, "aa-bb-v1-secret"); + + assert_eq!(editor.shown(), "•".repeat(15)); + assert!( + !editor.shown().contains("sk-"), + "a fragment of the credential reached what is drawn: {:?}", + editor.shown(), + ); + assert_eq!( + editor.lines(), + ["aa-bb-v1-secret"], + "the buffer still holds it, because the wizard has to read it", + ); +} + +#[test] +fn the_mask_is_one_character_per_character_so_the_caret_lands_right() { + // The caret is placed at `cursor().1` in the *drawn* row, so a mask that was + // not one for one would put it somewhere the text is not. + let mut editor = Editor::masked('•'); + typed(&mut editor, "hé🙂o"); + + assert_eq!(editor.cursor(), (0, 4)); + assert_eq!(editor.shown().chars().count(), 4); + assert_eq!(editor.shown(), "••••"); +} + +#[test] +fn an_unmasked_field_shows_what_was_typed() { + let mut editor = Editor::new(); + typed(&mut editor, "http://localhost:11434/v1"); + assert_eq!(editor.shown(), "http://localhost:11434/v1"); +} + +#[test] +fn shown_follows_the_cursor_to_the_row_it_is_on() { + let mut editor = Editor::new(); + editor.insert_str("first\nsecond"); + assert_eq!(editor.shown(), "second"); + editor.key(key(KeyCode::Up)); + assert_eq!(editor.shown(), "first"); +} + +// --- undo and redo --- +// +// `Ctrl+U` and `Ctrl+R` are not new keys. The buffer this editor replaced bound +// them itself (`tui-textarea-0.7.0/src/textarea.rs:576-587`) and io-cli forwards +// raw crossterm events straight into it, so an operator has had undo in the +// prompt and in the wizard's credential field since the first release. These +// tests are here so that the next rewrite of this file cannot take it away +// quietly the way the last one did. + +#[test] +fn typing_then_undo_then_redo_walks_the_text_back_and_forward() { + let mut editor = holding("hi"); + assert_eq!(editor.cursor(), (0, 2)); + + undo(&mut editor); + assert_eq!(text(&editor), "h"); + assert_eq!(editor.cursor(), (0, 1)); + undo(&mut editor); + assert_eq!(text(&editor), ""); + assert_eq!(editor.cursor(), (0, 0)); + + redo(&mut editor); + redo(&mut editor); + assert_eq!(text(&editor), "hi"); + assert_eq!( + editor.cursor(), + (0, 2), + "and the caret came forward with it" + ); +} + +#[test] +fn a_press_takes_back_one_character_and_not_the_whole_word() { + // **Parity, on purpose.** `TextArea` pushed one edit per typed character and + // merged none of them (`tui-textarea-0.7.0/src/history.rs:141`), so stepping + // back one letter at a time is the undo an operator already has. Coalescing + // a typed run into a single step is a better key — and taking away their + // ability to drop one character would be the same silent change as dropping + // the key was. + let mut editor = holding("cat"); + + undo(&mut editor); + assert_eq!(text(&editor), "ca", "exactly one character, not the word"); + assert_eq!(editor.cursor(), (0, 2)); + + undo(&mut editor); + assert_eq!(text(&editor), "c"); + undo(&mut editor); + assert_eq!(text(&editor), ""); +} + +#[test] +fn undo_of_a_character_typed_mid_line_puts_the_caret_back_before_it() { + let mut editor = holding("hello"); + editor.key(key(KeyCode::Left)); + editor.key(key(KeyCode::Left)); + typed(&mut editor, "X"); + assert_eq!(text(&editor), "helXlo"); + assert_eq!(editor.cursor(), (0, 4)); + + undo(&mut editor); + assert_eq!(text(&editor), "hello"); + assert_eq!( + editor.cursor(), + (0, 3), + "where the caret was before the insert" + ); +} + +#[test] +fn undo_puts_back_the_newline_enter_opened() { + let mut editor = holding("one"); + editor.key(key(KeyCode::Enter)); + typed(&mut editor, "t"); + assert_eq!(editor.lines(), ["one", "t"]); + + undo(&mut editor); + assert_eq!(editor.lines(), ["one", ""], "the character, not the row"); + undo(&mut editor); + assert_eq!( + editor.lines(), + ["one"], + "and now the row the newline opened" + ); + assert_eq!(editor.cursor(), (0, 3)); + + redo(&mut editor); + redo(&mut editor); + assert_eq!(editor.lines(), ["one", "t"]); + assert_eq!(editor.cursor(), (1, 1)); +} + +#[test] +fn undo_puts_back_the_word_a_word_delete_took() { + let mut editor = holding("one two three"); + editor.key(chord(KeyCode::Char('w'), KeyModifiers::CONTROL)); + assert_eq!(text(&editor), "one two "); + + undo(&mut editor); + assert_eq!(text(&editor), "one two three"); + assert_eq!(editor.cursor(), (0, 13), "back where the delete started"); +} + +#[test] +fn undo_puts_back_what_a_cut_took_across_rows() { + let mut editor = Editor::new(); + editor.insert_str("one\ntwo\nthree"); + editor.select_all(); + editor.cut(); + assert_eq!(editor.lines(), [""]); + + undo(&mut editor); + assert_eq!(editor.lines(), ["one", "two", "three"]); + assert_eq!(editor.cursor(), (2, 5), "where `select_all` left the caret"); + assert!( + !editor.selecting(), + "undo cancels the selection rather than restoring it, so the next \ + keystroke cannot delete a span over text that has just changed", + ); +} + +#[test] +fn undo_puts_the_caret_back_where_the_edit_started() { + // A buffer that comes back with the caret somewhere else is its own defect: + // the text is right and the next keystroke still lands in the wrong place. + let mut editor = Editor::new(); + editor.insert_str("alpha\nbeta\ngamma"); + editor.key(key(KeyCode::Up)); + editor.key(chord(KeyCode::Char('a'), KeyModifiers::CONTROL)); + assert_eq!(editor.cursor(), (1, 0)); + + editor.key(chord(KeyCode::Char('k'), KeyModifiers::CONTROL)); + assert_eq!(editor.lines(), ["alpha", "", "gamma"]); + + undo(&mut editor); + assert_eq!(editor.lines(), ["alpha", "beta", "gamma"]); + assert_eq!(editor.cursor(), (1, 0)); +} + +#[test] +fn one_keystroke_is_one_press_however_many_methods_it_went_through() { + // `Ctrl+K` at the end of a row reaches the join through `delete_next_char`, + // then `delete_char`, then `delete_newline` — three recorded methods deep. + // A step from each would cost three presses of `Ctrl+U` to take back one + // keystroke, and the second and third would look like presses that did + // nothing. + let mut editor = Editor::new(); + editor.insert_str("abc\ndef"); + editor.key(key(KeyCode::Up)); + editor.key(chord(KeyCode::Char('e'), KeyModifiers::CONTROL)); + assert_eq!(editor.cursor(), (0, 3)); + + editor.key(chord(KeyCode::Char('k'), KeyModifiers::CONTROL)); + assert_eq!(editor.lines(), ["abcdef"]); + + undo(&mut editor); + assert_eq!(editor.lines(), ["abc", "def"], "one press put the row back"); + assert_eq!(editor.cursor(), (0, 3)); +} + +#[test] +fn an_edit_after_an_undo_throws_away_the_redo() { + let mut editor = holding("abc"); + undo(&mut editor); + assert_eq!(text(&editor), "ab"); + + typed(&mut editor, "z"); + redo(&mut editor); + assert_eq!( + text(&editor), + "abz", + "redoing onto text the operator has since replaced would put back a \ + prompt they never asked for", + ); +} + +#[test] +fn undo_and_redo_with_nothing_to_do_leave_the_buffer_and_the_caret_alone() { + let mut editor = Editor::new(); + undo(&mut editor); + redo(&mut editor); + assert_eq!(text(&editor), ""); + assert_eq!(editor.cursor(), (0, 0)); + assert_eq!(editor.lines().len(), 1, "there is always one row"); + + let mut editor = holding("abc"); + editor.key(key(KeyCode::Left)); + redo(&mut editor); + assert_eq!(text(&editor), "abc"); + assert_eq!( + editor.cursor(), + (0, 2), + "nothing to redo did not move the caret" + ); +} + +#[test] +fn the_history_drops_its_oldest_step_rather_than_growing_without_end() { + // A step is a copy of the whole buffer, so the depth is capped. Sixty steps + // into a fifty-deep history: the ten oldest are gone, the fifty newest come + // back, and the presses that run out of history are no-ops rather than a + // panic on an empty stack. + let mut editor = Editor::new(); + for _ in 0..60 { + editor.insert_str("x"); + } + assert_eq!(text(&editor), "x".repeat(60)); + + for _ in 0..60 { + undo(&mut editor); + } + assert_eq!( + text(&editor), + "x".repeat(10), + "fifty steps came back and the ten before them were forgotten", + ); +} + +#[test] +fn undo_in_a_masked_field_comes_back_masked() { + // The wizard's field holds an API key. A mask that came off one press of + // `Ctrl+U` after the key was typed would put the credential on the screen, + // which is the same defect F9's sabotage arm exists to catch. + let mut editor = Editor::masked('•'); + typed(&mut editor, "aa-bb-v1-secret"); + editor.key(chord(KeyCode::Char('w'), KeyModifiers::CONTROL)); + assert_eq!(editor.lines(), ["aa-bb-v1-"]); + + undo(&mut editor); + assert_eq!(editor.lines(), ["aa-bb-v1-secret"], "the key came back"); + assert_eq!(editor.shown(), "•".repeat(15), "and came back masked"); + assert!( + !editor.shown().contains("sk-"), + "a fragment of the credential reached what is drawn: {:?}", + editor.shown(), + ); +} diff --git a/tests/frames.rs b/tests/frames.rs index cd1d474..fa3b7fc 100644 --- a/tests/frames.rs +++ b/tests/frames.rs @@ -16,6 +16,10 @@ mod support; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::thread; + use ratatui::layout::Position; use ratatui::style::{Color, Style}; use ratatui::text::Line; @@ -169,6 +173,76 @@ fn a_commit_makes_the_next_identical_frame_a_real_repaint() { ); } +/// Enough spinning that a commit which did not wait for the terminal has +/// certainly finished. +/// +/// A count rather than a duration, because N1 forbids the clock in every test +/// here. A commit that does not wait is microseconds of buffer work; this is +/// three or four orders of magnitude more than that, and it is only ever a +/// margin — the assertion below can miss a regression on a badly descheduled +/// machine but cannot fail on a correct one. +const LONG_ENOUGH: u32 = 10_000_000; + +#[test] +fn a_commit_takes_the_terminal_before_it_asks_the_terminal_anything() { + // **This is what 0.18.0's first build died of.** ratatui 0.30 ends + // `insert_before` by clearing the viewport, and `Terminal::clear` now + // snapshots the backend cursor first so it can put it back — `ESC[6n`, whose + // answer arrives on stdin. In 0.29 that same clear wrote only the escape. So + // committing became a question put to the terminal, and the keyboard reader + // was still holding stdin when it was asked: the splash was drawn, two + // seconds passed, and the session gave up saying the cursor position could + // not be read. + // + // Asserted as a decision rather than a duration: a placement is held on + // another thread, and the commit must not have finished before that + // placement let go. + let holding = Arc::new(AtomicBool::new(false)); + let committing = Arc::new(AtomicBool::new(false)); + let released = Arc::new(AtomicBool::new(false)); + + let holder = { + let (held, started, freed) = ( + Arc::clone(&holding), + Arc::clone(&committing), + Arc::clone(&released), + ); + thread::spawn(move || { + let placement = io_cli::stdin::placing(); + held.store(true, Ordering::SeqCst); + while !started.load(Ordering::SeqCst) { + std::hint::spin_loop(); + } + for _ in 0..LONG_ENOUGH { + std::hint::spin_loop(); + } + freed.store(true, Ordering::SeqCst); + drop(placement); + }) + }; + while !holding.load(Ordering::SeqCst) { + std::hint::spin_loop(); + } + + // Built after the placement is held, and it must be: `from_terminal` wraps a + // terminal that already exists, so nothing here asks the terminal anything + // until the commit does. + let (mut screen, _recorder) = support::screen(80, 24); + committing.store(true, Ordering::SeqCst); + screen + .commit(&[Line::from("a finished reply")]) + .expect("commit"); + let waited = released.load(Ordering::SeqCst); + + holder.join().expect("the placement thread does not panic"); + assert!( + waited, + "a commit ran while another thread held the terminal — `insert_before` \ + asks the terminal where its cursor is, so the keyboard reader is free \ + to take the answer and the session dies two seconds later", + ); +} + /// 0.11.0 F5 — the activity line brings no repaint of its own. /// /// The row is drawn by the tick that already advances the spinner and the clock, diff --git a/tests/glyphs.rs b/tests/glyphs.rs index 9dfa35d..1735fce 100644 --- a/tests/glyphs.rs +++ b/tests/glyphs.rs @@ -816,6 +816,229 @@ async fn a_committed_transcript_draws_in_ascii() { } } +/// The three instruction files and the two notes this sweep renders. +/// +/// One of each state that has a mark of its own: a file read, a file that is +/// there and is **not** read, a file not written yet; a note pinned and a note +/// nothing has pinned. A fixture missing any of them would sweep four marks and +/// leave the fifth to reach a terminal that cannot draw it — which is exactly how +/// `TodoWrote` went three releases unswept. +fn memory_fixture() -> ( + Vec, + Vec, +) { + use io_cli::memory::Instruction; + use io_cli::recall::{Remembered, Scope}; + use io_harness::config::Scope as FileScope; + + ( + vec![ + Instruction { + scope: FileScope::Project, + path: "/Users/someone/work/io-cli/AGENTS.md".into(), + exists: true, + lines: 12, + read: true, + }, + Instruction { + scope: FileScope::Local, + path: "/Users/someone/work/io-cli/AGENTS.local.md".into(), + exists: true, + lines: 3, + read: false, + }, + Instruction { + scope: FileScope::User, + path: "/Users/someone/.io-cli/IO.md".into(), + exists: false, + lines: 0, + read: false, + }, + ], + vec![ + Remembered { + scope: Scope::Workspace, + key: "build-command".into(), + value: "cargo test --lib --tests".into(), + kind: "fact", + pinned: true, + run_id: 41, + step: 2, + created_at: "2026-08-26 09:00".into(), + draws: 6, + }, + Remembered { + scope: Scope::Global, + key: "an-key-far-longer-than-any-narrow-terminal-can-draw-in-full".into(), + value: "keep answers short".into(), + kind: "decision", + pinned: false, + run_id: 9, + step: 1, + created_at: "2026-08-26 09:01".into(), + draws: 0, + }, + ], + ) +} + +/// **N3 — the memory page draws in ASCII, at eighty columns and at thirty.** +/// +/// The sabotage arm is a pinned note marked with a glyph outside ASCII: the sweep +/// below is what fails, and nothing else does — the page still lists the same +/// rows, still resolves the same indices and still writes the same files. +/// +/// Both widths, because the narrow form is a claim rather than an accident. The +/// picker fits a label to what the marker and the mark leave and fits a detail to +/// what is left after that, dropping it entirely when there is no room — so the +/// mark, which is the whole of what says *pinned* and *not read*, is the one thing +/// that cannot be squeezed out. +#[test] +fn n3_the_memory_page_draws_in_ascii_and_fits_a_narrow_terminal() { + use io_cli::commands::{memory_page, LOOSE_MARK, PINNED_MARK, READ_MARK, UNREAD_MARK}; + + let (files, entries) = memory_fixture(); + let theme = ascii(); + + for width in [80u16, 30] { + // Twenty-four rows of terminal and the session's own eight-row viewport, + // which is the size every one of these surfaces is actually drawn at. + let (mut screen, _recorder) = support::screen_of(width, 24, io_cli::term::VIEWPORT_HEIGHT); + let before = screen.rows(); + let (rows, _held) = memory_page(&files, &entries, true, &ASCII); + let mut picker = Picker::new("What io remembers", rows); + screen + .draw(|frame| picker.render(frame, frame.area(), &theme)) + .expect("frame"); + let drawn = screen.viewport_text().to_string(); + + assert_ascii(&format!("the memory page at {width} columns"), &drawn); + for line in drawn.lines() { + assert!( + line.chars().count() <= width as usize, + "a row overran {width} columns: {line:?}", + ); + } + // **No frame grows the viewport.** Rendering is a draw and nothing else; + // the palette stopped re-placing the viewport in 0.13.0 and no surface + // added since may start again. + assert_eq!( + screen.rows(), + before, + "drawing the memory page changed the viewport's height", + ); + } + + // The marks survive the substitution and still mark. A set that mapped every + // mark to a space would pass the sweep above and destroy the page. + let (rows, _) = memory_page(&files, &entries, false, &ASCII); + let marks: Vec<&str> = rows.iter().filter_map(|row| row.mark).collect(); + for mark in [READ_MARK, UNREAD_MARK, PINNED_MARK, LOOSE_MARK] { + assert!(mark.is_ascii(), "{mark:?} is not ASCII"); + assert_eq!( + mark.chars().count(), + 1, + "{mark:?} is not one cell; a mark of an odd width shifts the column", + ); + assert!( + marks.contains(&mark), + "{mark:?} is on no row of the fixture" + ); + } + assert_ne!( + PINNED_MARK, LOOSE_MARK, + "a pinned note and a loose one have to look different or the mark says nothing", + ); + assert_ne!(READ_MARK, UNREAD_MARK); +} + +/// N3 — the two pickers `/remember` and `/memory` open, and every sentence they +/// commit, draw in ASCII. +#[test] +fn n3_the_memory_prompts_and_sentences_draw_in_ascii() { + use io_cli::commands::{ + forgotten_said, instruction_said, memory_notes, pinned_said, scope_rows, verb_rows, + }; + use io_cli::recall::{Caps, Forgotten, Pinned, Scope, View}; + use io_harness::config::Scope as FileScope; + + let (files, entries) = memory_fixture(); + let theme = ascii(); + + // The scope picker `/remember` opens, at the session's own viewport. + let paths: Vec<(FileScope, std::path::PathBuf)> = + [FileScope::Project, FileScope::Local, FileScope::User] + .into_iter() + .map(|scope| { + ( + scope, + std::path::PathBuf::from("/Users/someone/work/io-cli") + .join(io_cli::memory::file_name(scope)), + ) + }) + .collect(); + for (title, rows) in [ + ("Remember it where?", scope_rows(&paths, &ASCII)), + ("build-command?", verb_rows(true)), + ("build-command?", verb_rows(false)), + ] { + for width in [80u16, 30] { + let (mut screen, _recorder) = + support::screen_of(width, 24, io_cli::term::VIEWPORT_HEIGHT); + let mut picker = Picker::new(title, rows.clone()); + screen + .draw(|frame| picker.render(frame, frame.area(), &theme)) + .expect("frame"); + let drawn = screen.viewport_text().to_string(); + assert_ascii(&format!("{title} at {width} columns"), &drawn); + for line in drawn.lines() { + assert!( + line.chars().count() <= width as usize, + "{title} overran {width} columns: {line:?}", + ); + } + } + } + + // And every sentence either surface commits into the scrollback. + let view = View { + workspace: "/Users/someone/work/io-cli".into(), + entries: entries.clone(), + caps: [Scope::Workspace, Scope::Global] + .map(|scope| Caps { + scope, + limits: io_harness::MemoryLimits::default(), + }) + .to_vec(), + trace: Vec::new(), + draws_cut: true, + }; + assert_ascii( + "the memory page's notes", + &memory_notes(&view, &ASCII).join("\n"), + ); + for file in &files { + assert_ascii( + "an instruction file's sentence", + &instruction_said(file, &ASCII), + ); + } + for outcome in [Pinned::Set, Pinned::NoEntry] { + for pinned in [true, false] { + let (_, said) = pinned_said("build-command", Scope::Workspace, pinned, outcome); + assert_ascii("a pin outcome", &said); + } + } + for outcome in [ + Forgotten::Removed { restore: 12 }, + Forgotten::Refused, + Forgotten::Absent, + ] { + let (_, said) = forgotten_said("build-command", Scope::Global, outcome, &ASCII); + assert_ascii("a withdrawal outcome", &said); + } +} + #[tokio::test] async fn the_approval_overlay_draws_in_ascii() { use io_cli::approval::{self, Approval}; diff --git a/tests/instructions.rs b/tests/instructions.rs new file mode 100644 index 0000000..dd9c340 --- /dev/null +++ b/tests/instructions.rs @@ -0,0 +1,389 @@ +//! Making io-harness read all three guidance files, and reporting honestly which +//! of them it is really reading. +//! +//! Writing `IO.md`, `AGENTS.md` and `AGENTS.local.md` is [`io_cli::memory`]'s +//! other half and `tests/memory.rs` covers it. This file covers the half that +//! decides whether any of it reaches a model: `[instructions] files`, which is +//! the only mechanism io-harness has for finding two of the three +//! (`io-harness-0.69.0/src/config.rs:158` — the automatic set is exactly +//! `["AGENTS.md"]`). +//! +//! Every test sets `IO_CONFIG_HOME`, because that is the only way to move the +//! user scope — both `memory::path`'s answer and +//! `io_harness::config::user_path`'s — without writing into the machine running +//! the suite. The environment is process-wide and this binary's tests share a +//! process, so every one of them takes the lock below. That is the shape +//! `tests/memory.rs`, `tests/home.rs`, `tests/contract.rs` and +//! `tests/configure.rs` already use, and copying it is deliberate: an existing +//! pattern that serialises the writers is worth more than a clever one that +//! avoids them. + +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, MutexGuard}; + +use io_cli::memory::{self, Instruction}; +use io_harness::config::{Config, Scope}; + +/// Held by every test in this file. See the module note. +fn env_lock() -> MutexGuard<'static, ()> { + static LOCK: Mutex<()> = Mutex::new(()); + LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +/// The three, in one place, so a test that means "all of them" cannot quietly +/// mean two. +const SCOPES: [Scope; 3] = [Scope::User, Scope::Project, Scope::Local]; + +/// Point io's home at `home` and clear the variable that would win over it. +/// +/// `IO_CONFIG` names a file outright and beats `IO_CONFIG_HOME` +/// (`config.rs:1668-1673`), so a developer who has one exported would otherwise +/// have this suite writing into their own configuration. +fn home_at(home: &Path) { + std::env::remove_var(io_harness::config::CONFIG_VAR); + std::env::set_var(io_harness::config::CONFIG_HOME_VAR, home); +} + +/// A workspace root and an io home, in one temporary directory that cleans +/// itself up. +fn fixture() -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::tempdir().expect("a temporary directory"); + let root = dir.path().join("workspace"); + std::fs::create_dir_all(&root).expect("the workspace"); + home_at(&dir.path().join("home")); + (dir, root) +} + +fn at(root: &Path, scope: Scope) -> PathBuf { + memory::path(root, scope).expect("every scope has a path once a home is named") +} + +/// Put one identifiable line in a scope's file, through the module's own writer +/// — so the file is exactly the shape `/memory` produces rather than a shape +/// invented here. +fn seed(root: &Path, scope: Scope, line: &str) -> PathBuf { + memory::remember(root, scope, line).expect("the line lands") +} + +fn row(view: &[Instruction], scope: Scope) -> &Instruction { + view.iter() + .find(|row| row.scope == scope) + .unwrap_or_else(|| panic!("the view has no row for {scope:?}")) +} + +/// **The list always names `AGENTS.md`, and the user file's entry is absolute.** +/// +/// The two facts the whole feature rests on, asserted on their own so a failure +/// says which one broke. +/// +/// `files` REPLACES io-harness's default rather than adding to it +/// (`config.rs:1879-1882`: `Some(files) => files.clone()`), so a list written to +/// reach `AGENTS.local.md` and `IO.md` alone would silently stop the +/// repository's own `AGENTS.md` being read — no error, because a name that +/// resolves to nothing is skipped in silence (`config.rs:1886`). +/// +/// And `IO.md` cannot be named relatively at all: every name is resolved +/// `root.join(&name)` against the discovery root (`config.rs:1885`), and io's +/// home is not the workspace. +#[test] +fn the_list_names_the_committed_file_and_reaches_the_home_by_absolute_path() { + let _guard = env_lock(); + let (_dir, root) = fixture(); + + let files = memory::files(&root); + + assert_eq!( + files.first().map(PathBuf::as_path), + Some(Path::new("AGENTS.md")), + "the committed file is not in the list — naming `files` replaces \ + io-harness's default `[\"AGENTS.md\"]` instead of adding to it, so \ + leaving it out stops the repository's own instructions being read and \ + says nothing: {files:?}", + ); + assert!( + files.contains(&PathBuf::from("AGENTS.local.md")), + "the uncommitted sibling is not in the list, and nothing else discovers \ + it: {files:?}", + ); + + let user = at(&root, Scope::User); + assert!( + files.contains(&user), + "the user file is not in the list by its absolute path: {files:?}", + ); + assert!( + user.is_absolute(), + "a relative name would resolve against the workspace, and io's home is \ + not the workspace: {}", + user.display(), + ); +} + +/// **F2.** A distinct line in each of the three files, and io-harness reads all +/// three. +/// +/// The sabotage arm: name only `AGENTS.local.md` and `IO.md` in `files` and this +/// test alone fails, on the committed file that every other agent reads too. +#[test] +fn all_three_files_reach_the_configuration_once_the_list_is_installed() { + let _guard = env_lock(); + let (_dir, root) = fixture(); + + seed(&root, Scope::Project, "the repository rule"); + seed(&root, Scope::Local, "the checkout rule"); + seed(&root, Scope::User, "the operator rule"); + + assert!( + memory::install(&root).expect("the list is written"), + "the first install has a list to write", + ); + + let config = Config::discover(&root).expect("the written list parses"); + let read = config.instructions().join("\n"); + + for line in [ + "the repository rule", + "the checkout rule", + "the operator rule", + ] { + assert!( + read.contains(line), + "`{line}` did not reach the configuration — a guidance file that is \ + written and never read is worse than one that was never written, \ + because the surface said it was recorded:\n{read}", + ); + } + assert_eq!( + config.instructions().len(), + 3, + "one constraint per file, and no file counted twice:\n{read}", + ); +} + +/// The write is a no-op the second time. +/// +/// This runs from a command an operator types repeatedly. A write per +/// invocation would churn `io.toml`'s bytes and its mtime for a change that +/// never happened, and would do it to the one file in this product that carries +/// their credentials. +#[test] +fn installing_a_list_that_is_already_correct_writes_nothing() { + let _guard = env_lock(); + let (_dir, root) = fixture(); + + assert!(memory::install(&root).expect("the first install")); + + let path = io_cli::configure::scope_path(&root, Scope::User).expect("the user path"); + let after_first = std::fs::read_to_string(&path).expect("the user configuration"); + + assert!( + !memory::install(&root).expect("the second install"), + "the second install reported a change it did not need to make", + ); + assert_eq!( + std::fs::read_to_string(&path).expect("the user configuration"), + after_first, + "the second install rewrote the file byte for byte identically, which is \ + still a write", + ); +} + +/// The absolute path is written literally, and never as `${env:HOME}/...` or +/// with a `~`. +/// +/// io-harness does substitute `${env:…}`, and an unset variable is a **hard +/// error** that fails the whole parse (`config.rs:1983-1989`) — on Windows +/// `HOME` frequently is unset, and a configuration that refuses to parse is a +/// session that does not start. `~` is worse: nothing in `config.rs` expands +/// one, so it would be taken as a directory literally named `~`. +#[test] +fn the_user_path_is_written_literally_and_not_as_a_substitution() { + let _guard = env_lock(); + let (_dir, root) = fixture(); + + memory::install(&root).expect("the list is written"); + + let path = io_cli::configure::scope_path(&root, Scope::User).expect("the user path"); + let text = std::fs::read_to_string(&path).expect("the user configuration"); + + assert!( + !text.contains("${"), + "a substitution in this list is a hard parse error wherever the variable \ + is unset, which takes the whole session down rather than one file:\n{text}", + ); + // **A quoted entry that opens with a tilde, not a tilde anywhere in the + // file.** The first spelling of this assertion was `!text.contains('~')` and + // it was red on Windows only: a temporary directory there is handed back in + // 8.3 short form, so the path this crate wrote correctly and literally was + // `C:\Users\RUNNER~1\AppData\Local\Temp\…`. The tilde was the operating + // system's, in a path that resolves, and the test called it a shorthand. + // + // What the criterion is about is io-cli writing a `~` **as a stand-in for a + // home directory it declined to resolve** — and that can only ever appear at + // the start of an entry. Every entry in the array is a quoted string, so + // `"~` is exactly that and nothing else. + assert!( + !text.contains("\"~"), + "io-harness expands no `~`, so an entry beginning with one would name a \ + directory literally called `~`:\n{text}", + ); +} + +/// **F5.** The view says what each file holds and which are actually read. +#[test] +fn the_view_reports_what_each_file_holds_and_that_it_is_read() { + let _guard = env_lock(); + let (_dir, root) = fixture(); + + for scope in SCOPES { + seed(&root, scope, "one rule"); + } + memory::install(&root).expect("the list is written"); + + let config = Config::discover(&root).expect("the written list parses"); + let view = memory::view(&root, &config); + assert_eq!(view.len(), 3, "three files, three rows: {view:?}"); + + for scope in SCOPES { + let row = row(&view, scope); + assert_eq!(row.path, at(&root, scope), "{scope:?} names the wrong file"); + assert!( + row.exists, + "{scope:?} was written and the view says it is absent" + ); + assert!( + row.read, + "{scope:?} exists, is named in the list, and is not read" + ); + assert_eq!( + row.lines, + std::fs::read_to_string(&row.path) + .expect("the file") + .lines() + .count(), + "{scope:?} reports a line count that is not the file's", + ); + assert!( + row.lines > 1, + "{scope:?} has a header and a bullet: {row:?}" + ); + } +} + +/// **F5's sabotage arm.** A project `[instructions] files` replaces the user +/// list, and the view says so rather than reporting every file that exists as +/// read. +/// +/// `["instructions","files"]` is not in io-harness's `APPENDING` set +/// (`config.rs:2052`), so a later scope replaces the array wholesale rather than +/// adding to it — the scopes are merged in the order listed at +/// `config.rs:688-693` and a later value overwrites (`config.rs:2142-2144`), so +/// Local beats Project beats User. +/// This is the case a view inferring `read` from `exists` gets wrong, and it is +/// the case the view exists for: the operator's `IO.md` is on disk, was +/// installed correctly, and is not reaching the model. +#[test] +fn a_project_list_replaces_the_user_one_and_the_unread_files_say_so() { + let _guard = env_lock(); + let (_dir, root) = fixture(); + + for scope in SCOPES { + seed(&root, scope, "one rule"); + } + memory::install(&root).expect("the list is written"); + + // The project's own say. Written as bytes rather than through the edit API, + // because what is under test is io-harness's merge, not io-cli's writer. + std::fs::write( + root.join(io_harness::config::PROJECT_FILE), + "[instructions]\nfiles = [\"AGENTS.md\"]\n", + ) + .expect("the project configuration"); + + let config = Config::discover(&root).expect("both files parse"); + let view = memory::view(&root, &config); + + assert!( + row(&view, Scope::Project).read, + "the one file the project kept is not read: {view:?}", + ); + + for scope in [Scope::Local, Scope::User] { + let row = row(&view, scope); + assert!( + row.exists, + "{scope:?} is on disk and the view says otherwise" + ); + assert!( + !row.read, + "{scope:?} exists and the view calls it read, but the project's own \ + `[instructions] files` replaced the list it was named in — this is \ + the row an operator needs to see, and reporting it green is worse \ + than having no view at all: {row:?}", + ); + } +} + +/// A file that is not there is skipped without an error, and the view says +/// absent rather than unread-for-some-other-reason. +/// +/// io-harness skips a named file that does not exist in silence +/// (`config.rs:1886`), so nothing downstream fails — which is exactly why the +/// view has to distinguish the two. +#[test] +fn a_named_file_that_is_absent_is_skipped_and_reported_absent() { + let _guard = env_lock(); + let (_dir, root) = fixture(); + + seed(&root, Scope::Project, "the only rule there is"); + memory::install(&root).expect("the list is written"); + + let config = Config::discover(&root).expect("two of the three names resolve to nothing"); + assert_eq!( + config.instructions().len(), + 1, + "only the file that exists is read: {:?}", + config.instructions(), + ); + + let view = memory::view(&root, &config); + assert!(row(&view, Scope::Project).read); + + for scope in [Scope::Local, Scope::User] { + let row = row(&view, scope); + assert!(!row.exists, "{scope:?} was never written: {row:?}"); + assert_eq!( + row.lines, 0, + "a file that is not there holds no lines: {row:?}" + ); + assert!(!row.read, "{scope:?} cannot be read: {row:?}"); + } +} + +/// A file that exists and holds nothing but whitespace is not read, and the view +/// does not pretend it is. +/// +/// `read_instructions` trims and skips an empty result (`config.rs:1891-1894`). +/// The row an operator sees is "there, and not reaching the model", which is the +/// only wording that explains why nothing changed after they created it. +#[test] +fn a_whitespace_only_file_exists_and_is_still_not_read() { + let _guard = env_lock(); + let (_dir, root) = fixture(); + + seed(&root, Scope::Project, "the only rule there is"); + std::fs::write(at(&root, Scope::Local), "\n \n\t\n").expect("the blank file"); + memory::install(&root).expect("the list is written"); + + let config = Config::discover(&root).expect("a blank instruction file is not an error"); + let view = memory::view(&root, &config); + + let row = row(&view, Scope::Local); + assert!(row.exists, "the file is on disk: {row:?}"); + assert!( + !row.read, + "io-harness skips a file that trims to nothing, and a view that calls it \ + read leaves the operator with no explanation for why it changed \ + nothing: {row:?}", + ); +} diff --git a/tests/memory.rs b/tests/memory.rs new file mode 100644 index 0000000..d72a7dc --- /dev/null +++ b/tests/memory.rs @@ -0,0 +1,294 @@ +//! The operator memory writer: three scopes, three files, and never a byte lost. +//! +//! Every test in this file writes `IO_CONFIG_HOME`, because that is the only way +//! to move [`memory::path`]'s answer for [`Scope::User`] without touching the +//! machine the suite is running on — `home::in_force` reads the environment at +//! call time, deliberately, so that a `/status` typed an hour into a session +//! answers about the directory in force rather than one cached at startup. +//! +//! The environment is process-wide and this binary's tests share a process, so +//! every one of them takes the lock below. That is the shape `tests/home.rs`, +//! `tests/wizard.rs`, `tests/contract.rs` and `tests/docs.rs` already use, and it +//! is why this file invents nothing: an existing pattern that serialises the +//! writers is worth more than a clever one that avoids them. + +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, MutexGuard}; + +use io_cli::memory; +use io_harness::config::Scope; + +/// Held by every test in this file. See the module note. +fn env_lock() -> MutexGuard<'static, ()> { + static LOCK: Mutex<()> = Mutex::new(()); + LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +/// The three, in one place, so a test that means "all of them" cannot quietly +/// mean two. +const SCOPES: [Scope; 3] = [Scope::User, Scope::Project, Scope::Local]; + +/// Point io's home at `home` and clear the variable that would win over it. +/// +/// `IO_CONFIG` names a file outright and beats `IO_CONFIG_HOME`, so a developer +/// who has one exported would otherwise have this suite writing `IO.md` next to +/// their own configuration file. +fn home_at(home: &Path) { + std::env::remove_var(io_harness::config::CONFIG_VAR); + std::env::set_var(io_harness::config::CONFIG_HOME_VAR, home); +} + +/// A workspace root and an io home, in one temporary directory that cleans +/// itself up. +fn fixture() -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::tempdir().expect("a temporary directory"); + let root = dir.path().join("workspace"); + std::fs::create_dir_all(&root).expect("the workspace"); + home_at(&dir.path().join("home")); + (dir, root) +} + +fn read(path: &Path) -> String { + std::fs::read_to_string(path).unwrap_or_else(|error| panic!("{}: {error}", path.display())) +} + +fn at(root: &Path, scope: Scope) -> PathBuf { + memory::path(root, scope).expect("every scope has a path once a home is named") +} + +/// The three names, and the two roots they sit under. +#[test] +fn each_scope_names_its_own_file_beside_the_configuration_it_belongs_to() { + let _guard = env_lock(); + let dir = tempfile::tempdir().expect("a temporary directory"); + let root = dir.path().join("workspace"); + let home = dir.path().join("home"); + home_at(&home); + + assert_eq!(memory::file_name(Scope::User), "IO.md"); + assert_eq!(memory::file_name(Scope::Project), "AGENTS.md"); + assert_eq!(memory::file_name(Scope::Local), "AGENTS.local.md"); + + assert_eq!( + at(&root, Scope::User), + home.join("IO.md"), + "the user file sits beside the io.toml in force, not in the workspace", + ); + assert_eq!(at(&root, Scope::Project), root.join("AGENTS.md")); + assert_eq!(at(&root, Scope::Local), root.join("AGENTS.local.md")); +} + +/// **The sabotage arm.** A line remembered in one scope lands in that scope's +/// file and in no other. +/// +/// This is the criterion. An implementation that ignored the scope and wrote the +/// committed `AGENTS.md` every time would satisfy every other test in this file +/// — the bullet is there, the bytes are preserved, the order is right — and +/// would put a private note into a pull request the first time an operator typed +/// one. So each scope is written in a fixture of its own and the other two paths +/// are asserted absent, which is the only assertion that can tell the two +/// implementations apart. +#[test] +fn a_line_lands_in_the_scope_it_was_written_for_and_nowhere_else() { + let _guard = env_lock(); + + for scope in SCOPES { + let (_dir, root) = fixture(); + + let written = memory::remember(&root, scope, "prefer small diffs").expect("the line lands"); + assert_eq!(written, at(&root, scope), "remember answers with the file"); + assert!( + read(&written).contains("- prefer small diffs"), + "{written:?} does not contain the line it was given", + ); + + for other in SCOPES.into_iter().filter(|s| *s != scope) { + let path = at(&root, other); + assert!( + !path.exists(), + "a line remembered for {scope:?} created {} — the scope decides the \ + file, and writing the committed one whatever was asked is how a \ + private note reaches a pull request", + path.display(), + ); + } + } +} + +/// A file that did not exist is created saying what it is and, above all, who +/// else will read it. +#[test] +fn an_absent_file_is_created_with_a_header_that_says_whether_it_is_committed() { + let _guard = env_lock(); + + // The phrase each header has to carry. Not the whole sentence — that is + // prose and may be reworded — but the fact an operator has to know before + // typing into the file, which may not be dropped. + for (scope, said) in [ + (Scope::User, "every project"), + (Scope::Project, "shared with everyone who clones"), + (Scope::Local, "not committed"), + ] { + let (_dir, root) = fixture(); + + let written = memory::remember(&root, scope, "a first line").expect("the line lands"); + let text = read(&written); + + assert!( + text.starts_with(&format!("# {}\n", memory::file_name(scope))), + "{scope:?}'s new file does not name itself:\n{text}", + ); + assert!( + text.contains(said), + "{scope:?}'s header does not say `{said}` — whether a guidance file is \ + committed is the whole difference between the three, and the moment \ + the file is made is the only time this module gets to say it:\n{text}", + ); + assert!( + text.ends_with("- a first line\n"), + "the header comes before the first bullet, and the file ends with it:\n{text}", + ); + } +} + +/// Every byte that was there is still there, in the order it was in. +/// +/// The file is somebody's prose — a person wrote it, and another agent may have +/// written into it. A release that rewrote one rather than appending would eat +/// notes nobody has another copy of, and would do it silently. +#[test] +fn every_byte_already_in_the_file_is_still_there() { + let _guard = env_lock(); + + for scope in SCOPES { + let (_dir, root) = fixture(); + let path = at(&root, scope); + std::fs::create_dir_all(path.parent().expect("a parent")).expect("the directory"); + + // Deliberately not the shape this module writes: a heading it did not + // author, a bullet with different spacing, an indented block, a trailing + // blank line. Anything that normalises rather than appends loses one of + // these. + let before = "# notes somebody wrote by hand\n\n * an old bullet\n\n\ + ```\n a fenced block\n```\n\n"; + std::fs::write(&path, before).expect("the existing file"); + + memory::remember(&root, scope, "and one more").expect("the line lands"); + + let after = read(&path); + assert!( + after.starts_with(before), + "{scope:?} did not append — the original bytes are no longer a prefix \ + of the file:\n{after}", + ); + assert_eq!( + after, + format!("{before}- and one more\n"), + "and nothing but the bullet was added", + ); + } +} + +/// A file whose last byte is not a newline does not have its last line joined to +/// the new one. +/// +/// `remember to run the linter` with no trailing newline, appended to naively, +/// becomes `remember to run the linter- and the formatter`: one instruction +/// turned into a different one, in a file that reaches the model on every run. +#[test] +fn a_file_that_does_not_end_in_a_newline_is_not_joined_to() { + let _guard = env_lock(); + + for scope in SCOPES { + let (_dir, root) = fixture(); + let path = at(&root, scope); + std::fs::create_dir_all(path.parent().expect("a parent")).expect("the directory"); + std::fs::write(&path, "- remember to run the linter").expect("the existing file"); + + memory::remember(&root, scope, "and the formatter").expect("the line lands"); + + let after = read(&path); + assert_eq!( + after, "- remember to run the linter\n- and the formatter\n", + "{scope:?} joined the previous author's last line to the new bullet", + ); + assert!( + !after.contains("linter- "), + "the two lines are still two lines:\n{after}", + ); + } +} + +/// An empty line is refused, and refusing creates nothing. +/// +/// A blank line remembered successfully is the failure an operator cannot see: +/// the surface says it was recorded, the file says nothing, and the next session +/// behaves as though they never typed it. +#[test] +fn an_empty_or_whitespace_only_line_is_refused_and_creates_no_file() { + let _guard = env_lock(); + + for scope in SCOPES { + for blank in ["", " ", "\t\n \n"] { + let (_dir, root) = fixture(); + + let refused = memory::remember(&root, scope, blank); + assert!( + refused.is_err(), + "{scope:?} accepted {blank:?}, which records nothing while reporting \ + success", + ); + assert!( + !at(&root, scope).exists(), + "{scope:?} created a file for {blank:?} — a refusal that leaves an \ + empty guidance file behind is a file the operator has to wonder \ + about", + ); + } + } +} + +/// Guidance is a list and a list has an order. Two lines come back in the order +/// they were given. +#[test] +fn two_lines_appear_in_the_order_they_were_remembered() { + let _guard = env_lock(); + + for scope in SCOPES { + let (_dir, root) = fixture(); + + memory::remember(&root, scope, "first").expect("the first line"); + let path = memory::remember(&root, scope, "second").expect("the second line"); + + let text = read(&path); + let first = text.find("- first").expect("the first line is in the file"); + let second = text + .find("- second") + .expect("the second line is in the file"); + assert!( + first < second, + "{scope:?} wrote the second line before the first:\n{text}", + ); + assert_eq!( + text.matches("# ").count(), + 1, + "the header is written once, when the file is made, and not again on \ + every line:\n{text}", + ); + } +} + +/// The line is trimmed before it is written, so a bullet is a bullet. +#[test] +fn surrounding_whitespace_does_not_reach_the_file() { + let _guard = env_lock(); + let (_dir, root) = fixture(); + + let path = + memory::remember(&root, Scope::Project, " prefer small diffs \n").expect("the line lands"); + + assert!( + read(&path).ends_with("- prefer small diffs\n"), + "a pasted line brings its own whitespace, and the file is markdown", + ); +} diff --git a/tests/recall.rs b/tests/recall.rs new file mode 100644 index 0000000..9638ee2 --- /dev/null +++ b/tests/recall.rs @@ -0,0 +1,1010 @@ +//! F6, F7 and F8 — the agent's own durable memory, read back and steered. +//! +//! io-harness has kept durable memory since 0.10.0 and **io-cli has never once +//! read it**. Everything asserted here is therefore a first reading, and the two +//! criteria are shaped around the four ways a first reading of this particular +//! subsystem is wrong while looking right: +//! +//! 1. **The bucket is a canonicalised path.** io-harness keys a workspace's +//! memory on `std::fs::canonicalize(root)` — `src/run/memory.rs:14-19`, and +//! the function is `pub(super)`, so io-cli cannot call it and has to +//! reproduce it. Key on the root as given and the panel is empty beside an +//! agent writing a note every turn, **and only when the checkout is reached +//! through a symlink**, which is the defect that ships green. `/var` on macOS +//! is a symlink to `/private/var`, so a plain `tempdir()` already exercises +//! it; the explicit symlink test below does not rely on that being true of +//! the runner. +//! 2. **There are two buckets.** The workspace's own, and the literal +//! `GLOBAL_MEMORY_WORKSPACE`. A view showing one shows about half of what the +//! agent knows and says nothing about the other half. +//! 3. **The caps are per scope.** `src/contract.rs:376-379` — each scope holds +//! its own, so a run drawing on both may carry up to twice `max_entries`. One +//! number reported as *the* cap is half the real ceiling. +//! 4. **Eviction, refusal and recall emit no `EventKind` at all.** io-harness +//! records them as `ContextEvent` rows on purpose (`src/state.rs:2996-3002`). +//! An implementation reaching for the observer stream reports that none has +//! ever happened, which is indistinguishable from a healthy store. The real +//! run below watches the stream and asserts the *absence*, so that sabotage +//! has a test that fails it. +//! +//! **The fixtures are real rows, never hand-built structs.** `MemoryEntry` could +//! be constructed field by field, and a test that did would assert that this +//! crate agrees with itself about a shape it invented. Every entry here is +//! written through `Store::memory_write_with`, and the eviction, the refusal and +//! the recalls in the last test are produced by an actual turn of the harness's +//! own run loop rather than recorded by hand — which is the only way to know that +//! the rows io-cli reads are the rows io-harness writes. +//! +//! **F7 is the other half: the operator's two levers.** The read half of +//! `src/recall.rs` writes nothing at all; `recall::pin`, `recall::forget` and +//! `recall::unforget` are the only writes, and they exist because a store the +//! agent manages alone is a store an operator cannot correct. The one fixture +//! below that calls `Store::memory_pin` directly predates them and is left +//! alone deliberately: it stages a *refused write*, which is io-harness's own +//! act, and it must keep passing whatever io-cli's wrapper does. +//! +//! **F7's sabotage is invisible from the entry list.** Forget through +//! `Store::memory_delete` instead of `Store::memory_forget` and the entry is +//! gone either way — so the tests below assert the two things only +//! `memory_forget` leaves behind: a restore point (proved by really rewinding +//! it and watching the entry come back) and the key's recall rows removed. +//! +//! **No clock.** Every time asserted on is the string the store wrote. Nothing +//! here sleeps or measures, per `tests/timing.rs`. + +use std::path::Path; +use std::sync::{Arc, Mutex}; + +use io_cli::recall::{self, Forgotten, Happened, Pinned, Scope}; +use io_harness::provider::{CompletionRequest, CompletionResponse, ToolCall}; +use io_harness::{ + ApproveAll, Flow, MemoryKind, MemoryLimits, Observer, Policy, Provider, RunEvent, Session, + Store, TaskContract, GLOBAL_MEMORY_WORKSPACE, +}; + +/// A store on a real temporary path, and the directory that keeps it alive. +/// +/// On disk rather than `Store::memory()`, because the whole of F6's first trap is +/// about paths and a fixture that never touches the filesystem is a fixture that +/// cannot see it. +fn store() -> (tempfile::TempDir, Store) { + let dir = tempfile::tempdir().expect("a temp dir for the store"); + let store = Store::open(dir.path().join("io.db")).expect("a store opens on a temp path"); + (dir, store) +} + +/// Write one entry into `bucket` through io-harness's own public write path. +/// +/// Returns the run that wrote it, because F6 asserts the attribution and a run id +/// invented here would not be one the store knows. +fn remember(store: &Store, bucket: &str, key: &str, value: &str, kind: MemoryKind) -> i64 { + let run = store + .start_run("fixture", bucket) + .expect("a run to attribute the note to"); + let wrote = store + .memory_write_with(bucket, key, value, run, 3, kind, MemoryLimits::default()) + .expect("the write lands"); + assert!(!wrote.refused, "{key} is not pinned, so nothing refuses it"); + run +} + +/// The detail of every trace row of one kind, in the order the store returned +/// them. A free function rather than a closure over `trace`, so the borrow the +/// returned strings carry is the slice's and not an inferred one. +fn details(trace: &[recall::Noted], what: Happened) -> Vec<&str> { + trace + .iter() + .filter(|note| note.happened == what) + .map(|note| note.detail.as_deref().unwrap_or("")) + .collect() +} + +/// A contract carrying `limits`, rooted at `root`. +/// +/// The caps come off a contract the caller passes in and are never read out of a +/// configuration file here: the numbers in force are the ones the turn carries, +/// and a second answer assembled from `io.toml` would be io-cli holding an opinion +/// about a value it does not own. +fn contract(root: &Path, limits: MemoryLimits) -> TaskContract { + TaskContract::workspace("read the memory back", root).with_memory_limits(limits) +} + +// --------------------------------------------------------------------------- +// F6 — every entry in both buckets, and the bucket is the canonicalised root +// --------------------------------------------------------------------------- + +#[test] +fn f6_lists_both_buckets_and_keeps_them_apart() { + let (_keep, store) = store(); + let workspace = tempfile::tempdir().expect("a workspace"); + let bucket = recall::workspace_key(workspace.path()); + + let wrote = remember( + &store, + &bucket, + "test-command", + "cargo test --lib", + MemoryKind::Fact, + ); + remember( + &store, + GLOBAL_MEMORY_WORKSPACE, + "package-manager", + "pnpm", + MemoryKind::Decision, + ); + + let view = recall::view( + &store, + workspace.path(), + &contract(workspace.path(), MemoryLimits::default()), + None, + ) + .expect("the view reads"); + + let keys: Vec<(&str, Scope)> = view + .entries + .iter() + .map(|e| (e.key.as_str(), e.scope)) + .collect(); + assert_eq!( + keys, + vec![ + ("test-command", Scope::Workspace), + ("package-manager", Scope::Global), + ], + "both buckets, and each row still says which one it came from — a merged \ + list cannot answer 'is this true everywhere or only here', which is the \ + one question the two scopes exist to separate", + ); + + let entry = &view.entries[0]; + assert_eq!(entry.kind, "fact"); + assert_eq!(entry.value, "cargo test --lib"); + assert!(!entry.pinned); + assert_eq!( + (entry.run_id, entry.step), + (wrote, 3), + "the attribution the store holds, so a stale note is traceable to the step \ + that wrote it", + ); + assert!( + !entry.created_at.is_empty(), + "the stored string, passed through — io-cli may not compute a time", + ); + assert_eq!( + entry.draws, 0, + "nothing has recalled it yet, and zero is a fact rather than a gap", + ); + + assert_eq!( + view.entries[1].kind, "decision", + "the two kinds are spelled here because `MemoryKind::as_str` is private \ + in io-harness (src/state.rs:1812)", + ); +} + +#[cfg(unix)] +#[test] +fn f6_the_workspace_bucket_is_the_canonicalised_root() { + // The defect this is the whole test for: a checkout reached through a symlink + // — `~/src` pointing at `/Volumes/work/src`, a macOS `/var` temp path, a + // container bind mount — writes its memory under the resolved path, because + // io-harness canonicalises at `src/run/memory.rs:15`. io-cli keying on the + // path the operator typed finds an empty bucket and reports, confidently, + // that the agent has learnt nothing. + let (_keep, store) = store(); + let real = tempfile::tempdir().expect("the real workspace"); + let parent = tempfile::tempdir().expect("somewhere to hang the link"); + let link = parent.path().join("checkout"); + std::os::unix::fs::symlink(real.path(), &link).expect("a symlink"); + + assert_eq!( + recall::workspace_key(&link), + recall::workspace_key(real.path()), + "one directory reached two ways is one bucket, which is exactly what \ + io-harness's `memory_key` promises", + ); + assert_ne!( + recall::workspace_key(&link), + link.to_string_lossy(), + "and the key is NOT the path as given — if these were equal this test \ + would pass against the sabotage it exists to catch", + ); + + // Written where io-harness would write it: under the resolved path. + remember( + &store, + &recall::workspace_key(real.path()), + "build", + "cargo build --release", + MemoryKind::Fact, + ); + + // Read through the symlink, which is all the operator ever has. + let view = recall::view( + &store, + &link, + &contract(&link, MemoryLimits::default()), + None, + ) + .expect("the view reads"); + assert_eq!( + view.entries.len(), + 1, + "SABOTAGE: key the lookup on the root as given instead of canonicalising \ + it and this is 0 — an empty panel beside an agent writing memory every \ + turn", + ); + assert_eq!(view.entries[0].key, "build"); + assert_eq!( + view.workspace, + recall::workspace_key(real.path()), + "and the view names the bucket it actually read, so a reader can see \ + which path answered", + ); +} + +#[test] +fn f6_a_root_that_cannot_be_canonicalised_falls_back_to_the_path_as_given() { + // io-harness's fallback, reproduced deliberately (`src/run/memory.rs:16`): a + // root that cannot be resolved yet should still have memory rather than none. + // Dropping the fallback and returning an error would make every note written + // against a since-deleted workspace unreadable, which is the case a reader + // most wants to look at. + let (_keep, store) = store(); + let gone = tempfile::tempdir() + .expect("a parent") + .path() + .join("deleted"); + assert!(!gone.exists(), "the fixture is a path that is not there"); + + let bucket = recall::workspace_key(&gone); + assert_eq!( + bucket, + gone.to_string_lossy(), + "unresolvable falls back to the path as given, byte for byte", + ); + + remember( + &store, + &bucket, + "why", + "the workspace was moved", + MemoryKind::Fact, + ); + let view = recall::view( + &store, + &gone, + &contract(&gone, MemoryLimits::default()), + None, + ) + .expect("the view reads"); + assert_eq!(view.entries.len(), 1, "the fallback bucket is readable"); +} + +// --------------------------------------------------------------------------- +// F8 — the caps, per scope +// --------------------------------------------------------------------------- + +#[test] +fn f8_the_caps_are_named_per_scope_and_the_ceiling_is_both() { + let (_keep, store) = store(); + let workspace = tempfile::tempdir().expect("a workspace"); + let limits = MemoryLimits { + max_entries: 4, + max_chars: 900, + max_entry_chars: 120, + }; + + let view = recall::view( + &store, + workspace.path(), + &contract(workspace.path(), limits), + None, + ) + .expect("the view reads"); + + assert_eq!( + view.caps + .iter() + .map(|c| (c.scope, c.limits.max_entries)) + .collect::>(), + vec![(Scope::Workspace, 4), (Scope::Global, 4)], + "one row per scope, because each scope holds its own \ + (io-harness src/contract.rs:376-379)", + ); + assert_eq!( + view.entries_ceiling(), + 8, + "SABOTAGE: report `max_entries` as the ceiling and a reader is told 4 \ + when a run may carry 8 — half the real number, and the half that makes \ + an eviction look like a bug", + ); + assert_eq!(view.chars_ceiling(), 1_800); + assert_eq!( + view.caps[0].limits.max_entry_chars, 120, + "the per-entry cap is per entry and is NOT doubled by there being two \ + scopes", + ); +} + +// --------------------------------------------------------------------------- +// F8 — an eviction, a refusal and a recall, from a turn that really happened +// --------------------------------------------------------------------------- + +/// A provider that plays one batch of `remember` calls per turn. +/// +/// Written here rather than reused from `tests/support`, whose `Scripted` only +/// writes files: the three trace rows this file is about are produced by the +/// `remember` tool and by nothing else, so the script has to be able to call it. +struct Remembering { + batches: Mutex>>, +} + +impl Remembering { + fn with(batches: Vec>) -> Self { + let batches = batches + .into_iter() + .map(|batch| { + batch + .into_iter() + .map(|(k, v)| remember_call(k, v)) + .collect() + }) + .collect(); + Self { + batches: Mutex::new(batches), + } + } +} + +/// One `remember` tool call. The keys and values below are bare identifiers, so +/// they need no JSON escaping and this stays four lines instead of importing an +/// encoder. +fn remember_call(key: &str, value: &str) -> ToolCall { + ToolCall { + name: io_harness::tools::REMEMBER_TOOL.to_string(), + arguments: format!("{{\"key\":\"{key}\",\"value\":\"{value}\"}}") + .parse() + .expect("assembled as JSON, so it parses as JSON"), + } +} + +impl Provider for Remembering { + async fn complete( + &self, + _request: CompletionRequest, + ) -> io_harness::Result { + let calls: Vec = self + .batches + .lock() + .expect("the script is not poisoned") + .pop_front() + .unwrap_or_default(); + Ok(CompletionResponse { + // Text only once the batch is empty, so the loop stops for the + // ordinary reason and not for a contrived one. + text: calls.is_empty().then(|| "done".to_string()), + tool_calls: calls, + ..Default::default() + }) + } +} + +/// Every event the run loop announced, so the absence of a memory event can be +/// asserted rather than assumed. +#[derive(Clone, Default)] +struct Watching { + seen: Arc>>, +} + +impl Observer for Watching { + fn event(&self, event: &RunEvent) -> Flow { + self.seen + .lock() + .expect("not poisoned") + .push(format!("{:?}", event.kind)); + Flow::Continue + } +} + +#[tokio::test] +async fn f8_eviction_refusal_and_recall_are_read_from_the_trace() { + let (_keep, store) = store(); + let workspace = tempfile::tempdir().expect("a workspace"); + let bucket = recall::workspace_key(workspace.path()); + + // Two notes at the cap. `alpha` is pinned by the operator, which is what makes + // the run's overwrite a refusal rather than a write. + remember( + &store, + &bucket, + "alpha", + "the operators value", + MemoryKind::Decision, + ); + remember(&store, &bucket, "beta", "something older", MemoryKind::Fact); + store + .memory_pin(&bucket, "alpha", true) + .expect("the operator pins it"); + + let limits = MemoryLimits { + max_entries: 2, + ..MemoryLimits::default() + }; + let task = contract(workspace.path(), limits); + let mut session = Session::open(&store, workspace.path()).expect("a session"); + let watching = Watching::default(); + + // Turn one: overwrite the pinned note (refused), then write a third note, + // which puts the bucket over the cap of two and evicts `beta`. + let first = session + .turn_bounded_observed( + &task, + &Remembering::with(vec![vec![ + ("alpha", "the runs value"), + ("gamma", "learned this turn"), + ]]), + &store, + &Policy::permissive(), + &ApproveAll, + &watching, + ) + .await + .expect("a scripted turn cannot fail"); + + // Turn two: a second run, so the notes carried into both are drawn on twice. + let second = session + .turn_bounded_observed( + &task, + &Remembering::with(vec![vec![("delta", "learned next turn")]]), + &store, + &Policy::permissive(), + &ApproveAll, + &watching, + ) + .await + .expect("a scripted turn cannot fail"); + + // --- the three kinds, read from the trace ----------------------------- + let trace = recall::trace(&store, first.run_id).expect("the trace reads"); + assert_eq!( + details(&trace, Happened::Refused).len(), + 1, + "the pinned note was not overwritten, and the refusal is a row: an agent \ + that believes it corrected something and did not will act on the \ + correction it thinks it made", + ); + assert!(details(&trace, Happened::Refused)[0].contains("alpha")); + assert_eq!( + details(&trace, Happened::Evicted).len(), + 1, + "a third note under a cap of two drops one, and which one it dropped is \ + the whole reason to record it", + ); + assert!(details(&trace, Happened::Evicted)[0].contains("beta")); + assert!( + !details(&trace, Happened::Recalled).is_empty(), + "the turn carried notes from earlier runs into its prompt", + ); + + // --- the sabotage: the observer stream knows nothing about any of it --- + let seen = watching.seen.lock().expect("not poisoned").clone(); + assert!( + seen.iter().any(|k| k.contains("MemoryWrote")), + "the stream does carry the write, so this assertion is about what the \ + stream omits rather than about an observer that never fired", + ); + for forbidden in ["Evict", "Refus", "Recall"] { + assert!( + !seen.iter().any(|k| k.contains(forbidden)), + "SABOTAGE: io-harness emits no EventKind for {forbidden} \ + (src/state.rs:2996-3002), so an implementation reading evictions off \ + the observer stream reports that none has ever happened and looks \ + perfectly healthy. These must come from Store::context_events.", + ); + } + + // --- draws: distinct runs, not rows ----------------------------------- + assert_ne!(first.run_id, second.run_id, "two turns, two runs"); + let view = + recall::view(&store, workspace.path(), &task, Some(second.run_id)).expect("the view reads"); + + // How many recall ROWS name `alpha`, which is the number the wrong + // implementation reports. Each turn assembles a prompt on every step, so a + // note carried through a two-step turn is two rows in one run. + let rows = [first.run_id, second.run_id] + .iter() + .flat_map(|run| store.memory_recalls(*run).expect("the recalls read")) + .filter(|recall| recall.key == "alpha") + .count(); + assert!( + rows > 2, + "the fixture is only meaningful if some run carried `alpha` more than \ + once; it carried it {rows} time(s) in total", + ); + + let alpha = view + .entries + .iter() + .find(|e| e.key == "alpha") + .expect("pinned, so no eviction could take it"); + assert_eq!( + alpha.draws, 2, + "SABOTAGE: count recall rows instead of distinct runs and this reads \ + {rows}. A row is written once per carried key per step, so rows measure \ + how long a run went on for — one two-hundred-step run would outvote \ + fifty runs that each leaned on the note once, and the number would grow \ + with age rather than with usefulness. Two runs drew on it; two is the \ + answer.", + ); + assert!(alpha.pinned, "and the pin is visible in the row"); + + let delta = view + .entries + .iter() + .find(|e| e.key == "delta") + .expect("written by the second turn"); + assert_eq!( + delta.draws, 1, + "written during the second turn and carried into that same turn's next \ + step, so exactly one run has drawn on it — which is the distinction the \ + count is for: it is in the store as often as `alpha` is, and half as \ + proven", + ); + + assert!( + view.entries.iter().all(|e| e.key != "beta"), + "the evicted note is gone from the bucket — the trace row is the only \ + place it still exists, which is why the trace is read at all", + ); + assert!( + !view.draws_cut, + "a handful of runs is nowhere near the scan ceiling", + ); + + // The view's trace follows the run it was given, not the newest one. + assert!( + view.trace.iter().any(|n| n.happened == Happened::Recalled), + "the second run recalled too", + ); +} + +// --------------------------------------------------------------------------- +// F7 — the operator pins and forgets, and forgetting leaves a way back +// --------------------------------------------------------------------------- + +/// The value of one key as the store holds it, or a panic naming the key. +fn stored(store: &Store, bucket: &str, key: &str) -> io_harness::MemoryEntry { + store + .memory_get(bucket, key) + .expect("the store reads") + .unwrap_or_else(|| panic!("`{key}` should still be in `{bucket}`")) +} + +#[test] +fn f7_pinning_and_unpinning_act_on_the_bucket_the_scope_names() { + let (_keep, store) = store(); + let workspace = tempfile::tempdir().expect("a workspace"); + let bucket = recall::workspace_key(workspace.path()); + + // The same key in both buckets. This is the only fixture that can catch a + // wrapper which takes the scope and then ignores it — with two different + // keys, pinning the wrong bucket is a silent no-op that every assertion + // about the right one still passes. + remember(&store, &bucket, "editor", "helix", MemoryKind::Decision); + remember( + &store, + GLOBAL_MEMORY_WORKSPACE, + "editor", + "whatever is on PATH", + MemoryKind::Decision, + ); + + assert_eq!( + recall::pin(&store, workspace.path(), Scope::Global, "editor", true) + .expect("the pin lands"), + Pinned::Set, + ); + assert!( + stored(&store, GLOBAL_MEMORY_WORKSPACE, "editor").pinned, + "the global note is the one that was pinned", + ); + assert!( + !stored(&store, &bucket, "editor").pinned, + "SABOTAGE: route every write to the workspace bucket and this is the row \ + that moved. An operator pinning what the agent believes EVERYWHERE \ + would instead pin a same-named note in whichever checkout they happened \ + to be standing in, and the global belief would keep being overwritten \ + by every run.", + ); + + assert_eq!( + recall::pin(&store, workspace.path(), Scope::Global, "editor", false) + .expect("the unpin lands"), + Pinned::Set, + ); + assert!( + !stored(&store, GLOBAL_MEMORY_WORKSPACE, "editor").pinned, + "unpinning is the same lever the other way, and it is the prerequisite \ + for withdrawing a pinned note", + ); + + // "Nothing to pin" is an outcome of its own. io-harness will not invent an + // entry to carry the pin (`src/state/memory.rs:556-558`), so a `bool` read as + // "did it work" would leave a surface showing a pin the store does not hold. + assert_eq!( + recall::pin( + &store, + workspace.path(), + Scope::Workspace, + "never-written", + true + ) + .expect("the call succeeds even though there is nothing to pin"), + Pinned::NoEntry, + ); + assert!( + store + .memory_get(&bucket, "never-written") + .expect("the store reads") + .is_none(), + "and nothing was conjured to hang the pin on", + ); +} + +#[test] +fn f7_a_pinned_entry_survives_an_overwrite_and_an_unpinned_one_does_not() { + // This is what a pin is *for*: a correction a person made must not be + // silently replaced by the next thing the agent decides. io-harness enforces + // it in the write itself — `WHERE memory.pinned IS NOT 1`, + // `src/state/memory.rs:430`. + let (_keep, store) = store(); + let workspace = tempfile::tempdir().expect("a workspace"); + let bucket = recall::workspace_key(workspace.path()); + + remember( + &store, + &bucket, + "owner", + "the platform team", + MemoryKind::Decision, + ); + remember(&store, &bucket, "retries", "three", MemoryKind::Fact); + assert_eq!( + recall::pin(&store, workspace.path(), Scope::Workspace, "owner", true) + .expect("the operator pins the correction"), + Pinned::Set, + ); + + // A later run rewrites both keys. Not through `remember`, which asserts the + // write was not refused — a refusal is exactly what half of this is about. + let run = store + .start_run("a later run knows better", &bucket) + .expect("a run to attribute the writes to"); + let onto_pinned = store + .memory_write_with( + &bucket, + "owner", + "whoever ran me last", + run, + 1, + MemoryKind::Decision, + MemoryLimits::default(), + ) + .expect("the write is attempted"); + let onto_plain = store + .memory_write_with( + &bucket, + "retries", + "nine", + run, + 1, + MemoryKind::Fact, + MemoryLimits::default(), + ) + .expect("the write lands"); + + assert!( + onto_pinned.refused, + "the run was refused, and told so — an agent that believes it corrected \ + something and did not will act on the correction it thinks it made", + ); + assert!(!onto_plain.refused, "nothing protects the unpinned note"); + + assert_eq!( + stored(&store, &bucket, "owner").value, + "the platform team", + "SABOTAGE: never pin, and the operator's answer is gone one turn later \ + with nothing anywhere saying so. The pin is the only lever io-cli gives \ + over a store the agent otherwise manages alone.", + ); + assert_eq!( + stored(&store, &bucket, "retries").value, + "nine", + "and the control moved, so the fixture is testing the pin rather than a \ + store that refuses every write", + ); + + let view = recall::view( + &store, + workspace.path(), + &contract(workspace.path(), MemoryLimits::default()), + None, + ) + .expect("the view reads"); + assert!( + view.entries + .iter() + .find(|e| e.key == "owner") + .expect("still there") + .pinned, + "and the pin is visible to a reader, so the survivor can be told from a \ + note that simply has not been rewritten yet", + ); +} + +#[test] +fn f7_a_pinned_entry_survives_eviction_at_the_cap_and_an_unpinned_one_does_not() { + // The other half of what a pin buys, and the half that makes the cap + // survivable: `src/state/memory.rs:736-739` skips a pinned entry when it + // drops notes to hold the caps. It still *counts* towards them, so pinning + // does not quietly raise the ceiling — it makes writes fail loudly instead. + let (_keep, store) = store(); + let workspace = tempfile::tempdir().expect("a workspace"); + let bucket = recall::workspace_key(workspace.path()); + + // `plain` first, so it is also the first eviction candidate: nothing has + // recalled either, and the candidate order falls through to `created_at ASC, + // id ASC` (`src/state/memory.rs:653-660`). The pin is therefore the only + // thing that can decide which of the two goes. + remember(&store, &bucket, "plain", "an older note", MemoryKind::Fact); + remember( + &store, + &bucket, + "pinned", + "the operators correction", + MemoryKind::Decision, + ); + assert_eq!( + recall::pin(&store, workspace.path(), Scope::Workspace, "pinned", true) + .expect("the operator pins it"), + Pinned::Set, + ); + + let tight = MemoryLimits { + max_entries: 2, + ..MemoryLimits::default() + }; + let run = store + .start_run("one note too many", &bucket) + .expect("a run to attribute the write to"); + let wrote = store + .memory_write_with( + &bucket, + "newcomer", + "learned this turn", + run, + 1, + MemoryKind::Fact, + tight, + ) + .expect("the write lands"); + + assert!(!wrote.refused, "`newcomer` is new, so nothing refuses it"); + assert_eq!( + wrote.evicted, + ["plain"], + "SABOTAGE: without the pin the store drops the oldest, least-proven note \ + and the operator's correction is the oldest note in this bucket — so an \ + unpinned correction is the FIRST thing a cap throws away", + ); + + let keys: Vec = store + .memory_list(&bucket) + .expect("the bucket reads") + .into_iter() + .map(|e| e.key) + .collect(); + assert_eq!(keys.len(), 2, "the cap of two holds"); + assert!( + keys.contains(&"pinned".to_string()), + "the pinned note stands" + ); + assert!( + keys.contains(&"newcomer".to_string()), + "and the new one landed" + ); + assert!( + !keys.contains(&"plain".to_string()), + "while the unpinned one went, which is the comparison that makes this a \ + test of the pin and not of the cap", + ); +} + +#[tokio::test] +async fn f7_forgetting_removes_the_entry_and_leaves_a_way_back() { + // The criterion this whole file's F7 half exists for. Both implementations + // — `Store::memory_forget` and `Store::memory_delete` — leave the entry + // gone, so *the entry being gone proves nothing*. What only the first one + // leaves behind is a restore point and a bucket with the key's recall rows + // removed, and those are what is asserted. + let (_keep, store) = store(); + let workspace = tempfile::tempdir().expect("a workspace"); + let bucket = recall::workspace_key(workspace.path()); + + // Written before the turn so the turn carries it into its prompt and the key + // accrues real recall rows. `Store::record_memory_recall` is `pub(crate)` + // (`src/state/memory.rs:786`), so a real turn is the only way to make one — + // which is why this reuses the `Remembering` harness above rather than + // hand-writing a row io-harness would never have written. + remember( + &store, + &bucket, + "doomed", + "the flaky test is in parser rs", + MemoryKind::Fact, + ); + + let task = contract(workspace.path(), MemoryLimits::default()); + let mut session = Session::open(&store, workspace.path()).expect("a session"); + let watching = Watching::default(); + let turn = session + .turn_bounded_observed( + &task, + &Remembering::with(vec![vec![("unrelated", "kept")]]), + &store, + &Policy::permissive(), + &ApproveAll, + &watching, + ) + .await + .expect("a scripted turn cannot fail"); + + let doomed_recalls = |run: i64| { + store + .memory_recalls(run) + .expect("the recalls read") + .into_iter() + .filter(|r| r.key == "doomed") + .count() + }; + assert!( + doomed_recalls(turn.run_id) > 0, + "the fixture is only meaningful if the turn really drew on `doomed` — \ + otherwise the cleared-rows assertion below would pass against anything", + ); + + let Forgotten::Removed { restore } = + recall::forget(&store, workspace.path(), Scope::Workspace, "doomed") + .expect("the withdrawal runs") + else { + panic!("an unpinned entry that is there is removed"); + }; + + assert!( + store + .memory_get(&bucket, "doomed") + .expect("the store reads") + .is_none(), + "gone from the bucket — which is ALSO true under the sabotage, and is \ + exactly why the next two assertions exist", + ); + + assert_eq!( + doomed_recalls(turn.run_id), + 0, + "SABOTAGE: `Store::memory_delete` is a bare `DELETE FROM memory` \ + (io-harness `src/state/memory.rs:863-869`) and leaves every recall row \ + standing. The evidence a withdrawn note accrued would keep voting in \ + the eviction order (`src/state/memory.rs:653-660`) on behalf of a note \ + that no longer exists, and a draw count read back for a later note of \ + the same key would carry the dead one's history.", + ); + + // The way back, driven for real — the same `rewind_run` io-cli's undo path + // already calls (`src/rewind.rs:181`). + assert_eq!( + recall::unforget(&store, workspace.path(), restore).expect("the rewind runs"), + ["doomed"], + "SABOTAGE: `memory_delete` writes no `memory_snapshots` row (io-harness \ + `src/state/memory.rs:838-848`), so `rewind_run` finds nothing to put \ + back (`src/run.rs:749`) and this is empty. The operator who withdrew \ + the wrong note would have no way back at all — and would find that out \ + at the one moment they wanted it.", + ); + + let back = stored(&store, &bucket, "doomed"); + assert_eq!( + back.value, "the flaky test is in parser rs", + "byte for byte, from the restore point taken BEFORE the removal \ + (io-harness `src/state/memory.rs:838-848`)", + ); + assert!( + !back.pinned, + "restored unpinned, which is not a guess: a pinned entry cannot be \ + forgotten in the first place", + ); + + // And the undoing is in the trace, where io-cli's own rewind reporting reads + // it (`src/rewind.rs:219-220`). + let record = &store.rewinds(restore).expect("the rewinds read")[0]; + assert_eq!(record.memory_restored, ["doomed"]); + assert!( + record.memory_removed.is_empty(), + "the entry was withdrawn by the operator, not created by that run — the \ + two are opposite directions of the same rewind", + ); + assert_eq!( + record.undid_step, None, + "a whole-run rewind rather than a step revert (io-harness \ + `src/state.rs:3436-3440`)", + ); +} + +#[test] +fn f7_a_pinned_entry_is_refused_and_an_unknown_key_is_absent() { + let (_keep, store) = store(); + let workspace = tempfile::tempdir().expect("a workspace"); + let bucket = recall::workspace_key(workspace.path()); + + remember( + &store, + &bucket, + "owner", + "the platform team", + MemoryKind::Decision, + ); + assert_eq!( + recall::pin(&store, workspace.path(), Scope::Workspace, "owner", true) + .expect("the operator pins it"), + Pinned::Set, + ); + + assert_eq!( + recall::forget(&store, workspace.path(), Scope::Workspace, "owner") + .expect("the call succeeds; the withdrawal does not"), + Forgotten::Refused, + "SABOTAGE: `MemoryForget::Pinned` means the harness REFUSED \ + (io-harness `src/state/memory.rs:829-831`), and it is the one outcome \ + that looks like the other two if it is folded into a `bool`. Reported \ + as success it tells an operator their note is gone while it stays in \ + the store and is carried into every later prompt — the same failure the \ + pinned flag exists to prevent one level down.", + ); + assert_eq!( + stored(&store, &bucket, "owner").value, + "the platform team", + "and the entry stands, untouched", + ); + + // Unpinning first is the way through, which is what makes naming the reason + // worth doing: the operator learns the order of the two acts. + assert_eq!( + recall::pin(&store, workspace.path(), Scope::Workspace, "owner", false) + .expect("the unpin lands"), + Pinned::Set, + ); + assert!( + matches!( + recall::forget(&store, workspace.path(), Scope::Workspace, "owner") + .expect("the withdrawal runs"), + Forgotten::Removed { .. }, + ), + "unpinned, the same call withdraws it", + ); + + assert_eq!( + recall::forget(&store, workspace.path(), Scope::Workspace, "owner") + .expect("saying it twice is not an error"), + Forgotten::Absent, + "a key that is not there is its own outcome — not an error, and not a \ + second removal either", + ); + assert_eq!( + recall::forget(&store, workspace.path(), Scope::Workspace, "never-written") + .expect("nor is a key that was never there"), + Forgotten::Absent, + ); +} diff --git a/tests/reload.rs b/tests/reload.rs new file mode 100644 index 0000000..5c95043 --- /dev/null +++ b/tests/reload.rs @@ -0,0 +1,300 @@ +//! **F3 and F4** — the configuration is re-discovered once per turn, and a file +//! that stops parsing does not stop the session. +//! +//! Every fixture here is a real directory with a real `io.toml` and a real +//! `AGENTS.md` in it, driven through [`io_cli::reload::Configuration`]. Nothing +//! is stubbed: `[instructions]` is applied from a field only `Config::discover` +//! populates — `Config::from_toml` has no root to resolve a file name against — +//! so a fixture built in memory would assert an empty instruction list against a +//! repository that names one, and pass. `tests/contract.rs` records the same +//! constraint for the same reason. + +use std::path::Path; +use std::sync::{Mutex, MutexGuard, OnceLock}; + +use io_cli::reload::Configuration; + +/// Held by every test in this file. +/// +/// `Config::discover` reads `$IO_CONFIG`, `$IO_CONFIG_HOME`, `$XDG_CONFIG_HOME` +/// and `$HOME` at call time, the environment is process-wide, and these tests +/// share a process — so two of them discovering at once would each be reading +/// the other's fixture. Intermittently, on a loaded machine, which is the most +/// expensive kind of failure to diagnose. +/// +/// The guard is taken for the *whole* of each test rather than around one call, +/// because a `Configuration` re-discovers on every `refresh` and every one of +/// those reads the same variables. +/// +/// This is the shape `tests/configure.rs`, `tests/contract.rs` and +/// `tests/wizard.rs` already keep. It is deliberately not a sleep or a retry: +/// `tests/timing.rs` forbids both, and neither would fix a race anyway. +fn env_lock() -> MutexGuard<'static, ()> { + static LOCK: Mutex<()> = Mutex::new(()); + LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +/// One empty user scope for this whole file, set once. +/// +/// Pointed at a directory holding no file, the user scope is skipped and the +/// workspace's own `io.toml` is the whole of the configuration — otherwise every +/// assertion below would be about whatever the person running the suite happens +/// to have configured. `IO_CONFIG` names a file outright and would win over the +/// directory, so an inherited one has to go or the fixture is not the fixture. +/// +/// Lifted from `tests/contract.rs::no_user_scope`, which is where this +/// repository settled the question. +fn no_user_scope() { + static HOME: OnceLock = OnceLock::new(); + HOME.get_or_init(|| { + let dir = tempfile::tempdir().expect("a temporary directory"); + std::env::set_var("IO_CONFIG_HOME", dir.path()); + std::env::remove_var("IO_CONFIG"); + dir + }); +} + +/// A workspace on disk, and the [`Configuration`] a driver would be holding after +/// startup had read it. +/// +/// Built through `io_cli::configure::reload` — the same call the type under test +/// makes every turn — so the fixture and the behaviour cannot disagree about what +/// a successful read produces. +/// +/// The caller holds [`env_lock`], and holds the returned directory for as long as +/// the configuration is used: `TempDir` deletes the workspace when it drops. +fn workspace(io_toml: &str, agents: &str) -> (tempfile::TempDir, Configuration) { + no_user_scope(); + let dir = tempfile::tempdir().expect("a workspace"); + std::fs::write(dir.path().join("io.toml"), io_toml).expect("the project file"); + std::fs::write(dir.path().join("AGENTS.md"), agents).expect("the instruction file"); + // The root is taken before `dir` is moved into the tuple, which is the only + // order that compiles. + let root = dir.path().to_path_buf(); + let (config, settings) = + io_cli::configure::reload(&root).expect("the fixture configuration parses"); + (dir, Configuration::new(root, config, settings)) +} + +/// Whether any composed instruction carries `line`. +/// +/// io-harness words each instruction file as `Project instructions from +/// `AGENTS.md`:\n` (`io-harness-0.69.0/src/config.rs:1893-1896`), so the +/// assertion is a containment and not an equality — this file is not the place +/// that pins the harness's wording. +fn instructs(held: &Configuration, line: &str) -> bool { + held.config() + .instructions() + .iter() + .any(|i| i.contains(line)) +} + +/// The theme the held pair reports, which is the `CliSettings` half. +/// +/// Used as the witness throughout because it comes from `Config::app`, which is +/// the half a reload that refreshed only the `Config` would leave stale — the +/// failure `io_cli::configure::reload`'s own doc comment exists to prevent. +fn theme(held: &Configuration) -> Option<&str> { + held.settings().and_then(|s| s.theme.as_deref()) +} + +/// A file rewritten behind the type's back. +/// +/// **Deliberately not through `io_cli::configure::write`.** F3 asks that a change +/// made by *another process* reach the next turn, and the property that delivers +/// it is that `Configuration` holds no watcher, no invalidation hook and no +/// record of who wrote: `refresh` goes back to `Config::discover`, which reads +/// the bytes on disk. A test that changed the file through io-cli's own writer +/// would pass on an implementation that only ever noticed its own writes, which +/// is exactly the implementation F3 rules out. +fn behind_its_back(path: &Path, text: &str) { + std::fs::write(path, text).expect("the fixture file is writable"); +} + +/// An unterminated table header — the shape an editor leaves halfway through a +/// save, and the case F4 exists for. +const BROKEN: &str = "[app.io-cli\ntheme = \"dark\"\n"; + +/// A *different* kind of refusal, so the "distinct failure" arm is comparing two +/// sentences the parser genuinely words differently rather than two positions in +/// the same one. A duplicate key and a missing bracket are different objections. +const BROKEN_OTHERWISE: &str = "[run]\nmax_steps = 10\nmax_steps = 11\n"; + +/// **F3.** A line written into an instruction file after the session started is +/// in the configuration the next turn is built from, with nothing restarted. +#[test] +fn f3_a_line_written_mid_session_reaches_the_next_turn() { + let _guard = env_lock(); + let (dir, mut held) = workspace( + "[app.io-cli]\ntheme = \"dark\"\n", + "Prefer the smaller diff.\n", + ); + + // What the session started on. + assert!(instructs(&held, "Prefer the smaller diff.")); + assert!( + !instructs(&held, "Never touch the lockfile."), + "the fixture must not already carry the line the test is about to write", + ); + + behind_its_back( + &dir.path().join("AGENTS.md"), + "Prefer the smaller diff.\nNever touch the lockfile.\n", + ); + + // The turn boundary, and nothing else. No restart, no re-launch, no call + // into the writer. + assert_eq!( + held.refresh(), + None, + "a configuration that still parses has nothing to report", + ); + assert!( + instructs(&held, "Never touch the lockfile."), + "the line written mid-session is not in the configuration the next turn \ + builds from; io-harness snapshots `[instructions]` inside \ + `Config::discover` and there is no other way to pick a change up", + ); + assert!( + instructs(&held, "Prefer the smaller diff."), + "the re-read replaced the instructions rather than re-reading them", + ); +} + +/// **F3, the other half.** A change to `io.toml` reaches *both* what the harness +/// decides and what io-cli's own section says, because they are re-read as a +/// pair. +#[test] +fn f3_the_cli_section_is_re_read_with_the_rest() { + let _guard = env_lock(); + let (dir, mut held) = workspace("[app.io-cli]\ntheme = \"dark\"\n", "Anything.\n"); + assert_eq!(theme(&held), Some("dark")); + + behind_its_back( + &dir.path().join("io.toml"), + "[app.io-cli]\ntheme = \"light\"\n", + ); + + assert_eq!(held.refresh(), None); + assert_eq!( + theme(&held), + Some("light"), + "the `Config` was refreshed and `CliSettings` was not, so `/config` would \ + report a theme no turn is drawing with", + ); +} + +/// **F4.** A configuration that stops parsing does not stop the session: the next +/// turn runs on the last one that discovered cleanly, and the operator is told +/// which file refused and what it said. +/// +/// This is the arm the F4 sabotage has to fail. An implementation that propagated +/// the discovery error — dropping the held pair, or replacing it with an empty +/// configuration — passes every other test in this file and fails the three +/// assertions below. +#[test] +fn f4_a_file_that_stops_parsing_leaves_the_last_good_configuration_in_force() { + let _guard = env_lock(); + let (dir, mut held) = workspace( + "[app.io-cli]\ntheme = \"dark\"\n", + "Prefer the smaller diff.\n", + ); + + behind_its_back(&dir.path().join("io.toml"), BROKEN); + + let refusal = held + .refresh() + .expect("a configuration that no longer parses is something to say"); + assert!( + refusal.contains("io.toml"), + "the operator has to be told WHICH file refused: {refusal}", + ); + // And what it said, not only which file. The tail is taken after the name + // rather than by comparing against the path, because macOS resolves a + // temporary directory through a symlink and the two spellings would not + // match. + let (_, said) = refusal + .rsplit_once("io.toml") + .expect("the refusal names the file"); + assert!( + !said.trim_start_matches(':').trim().is_empty(), + "the refusal must carry what the file said, not only its name: {refusal}", + ); + + // The session continues, on the last configuration that read cleanly. + assert_eq!( + theme(&held), + Some("dark"), + "the held settings were surrendered to a file that no longer parses", + ); + assert!( + instructs(&held, "Prefer the smaller diff."), + "the held configuration was surrendered to a file that no longer parses", + ); +} + +/// **F4, the repair.** When the file parses again the new configuration is picked +/// up, with nothing further asked of anybody. +#[test] +fn f4_a_repaired_file_is_picked_up_with_nothing_asked() { + let _guard = env_lock(); + let (dir, mut held) = workspace("[app.io-cli]\ntheme = \"dark\"\n", "Anything.\n"); + let io_toml = dir.path().join("io.toml"); + + behind_its_back(&io_toml, BROKEN); + assert!(held.refresh().is_some()); + + behind_its_back(&io_toml, "[app.io-cli]\ntheme = \"light\"\n"); + assert_eq!( + held.refresh(), + None, + "a repaired file is not an occasion for a notice", + ); + assert_eq!( + theme(&held), + Some("light"), + "the repaired file's content is what the next turn is built from", + ); +} + +/// **F4, the reporting rule.** The same refusal is said once, and said again +/// after the file has been good in between. +/// +/// The refresh happens at the top of every turn, so a file left broken for six +/// prompts would otherwise produce the same sentence six times — which is how a +/// product teaches operators to stop reading its notices. +#[test] +fn f4_a_refusal_is_reported_once_and_again_after_a_success() { + let _guard = env_lock(); + let (dir, mut held) = workspace("[app.io-cli]\ntheme = \"dark\"\n", "Anything.\n"); + let io_toml = dir.path().join("io.toml"); + let good = "[app.io-cli]\ntheme = \"dark\"\n"; + + behind_its_back(&io_toml, BROKEN); + let first = held.refresh().expect("the first refusal is news"); + assert_eq!( + held.refresh(), + None, + "the same refusal on the next turn is the same sentence again", + ); + assert_eq!(held.refresh(), None, "and on the turn after that"); + + // A refusal that says something different is a different thing to say. + behind_its_back(&io_toml, BROKEN_OTHERWISE); + let second = held + .refresh() + .expect("a refusal the operator has not seen is news"); + assert_ne!(first, second, "the fixture's two refusals must differ"); + + // Good again, then broken the same way as the first time. The second break is + // news even though its text has been said before. + behind_its_back(&io_toml, good); + assert_eq!(held.refresh(), None); + behind_its_back(&io_toml, BROKEN); + assert_eq!( + held.refresh().as_deref(), + Some(first.as_str()), + "a file that breaks, is fixed, and breaks again must report twice; \ + a success has to clear what was reported", + ); +} diff --git a/tests/stdin.rs b/tests/stdin.rs index 4838848..03adf30 100644 --- a/tests/stdin.rs +++ b/tests/stdin.rs @@ -161,6 +161,72 @@ fn a_placement_waits_out_the_reader_turn_already_in_flight() { ); } +#[test] +fn a_placement_taken_inside_another_holds_the_terminal_until_the_outer_one_ends() { + let _serial = serially(); + + // **The renderer takes this lock at every site that asks the terminal a + // question, and the binary still takes one around the operations built out + // of several of those sites** — re-placing the viewport is an erase, an + // attach and a frame. So a placement inside a placement is the ordinary + // case, and `std::sync::Mutex` is not reentrant: a second `placing` that + // queued would wait for the thread that is already holding it, which is the + // thread drawing the session. That failure is a hang rather than an + // assertion, so what is asserted here is the half that can fail loudly — the + // inner one ending must not tell the reader the terminal is free. + let answered = Arc::new(AtomicU8::new(RUNNING)); + let reader = { + let outer = io_cli::stdin::placing(); + let inner = io_cli::stdin::placing(); + assert!( + io_cli::stdin::placement_waiting(), + "a nested placement does not say the terminal is taken", + ); + drop(inner); + assert!( + io_cli::stdin::placement_waiting(), + "the inner placement gave the terminal back while the outer one was \ + still using it — the reader is free to swallow the answer to a \ + query the outer placement has not asked yet", + ); + + let told = Arc::clone(&answered); + let reader = thread::spawn(move || { + let took = io_cli::stdin::reading(|| ()); + told.store( + if took.is_some() { TOOK_IT } else { DECLINED }, + Ordering::SeqCst, + ); + }); + for _ in 0..LONG_ENOUGH { + if answered.load(Ordering::SeqCst) != RUNNING { + break; + } + std::hint::spin_loop(); + } + drop(outer); + reader + }; + reader.join().expect("the reader thread does not panic"); + + assert_eq!( + answered.load(Ordering::SeqCst), + DECLINED, + "a reader took, or queued for, a terminal a nested placement was using", + ); + + // And the thread is not left believing it still holds what it gave back. A + // record that leaked would make every later placement on this thread a token + // that locks nothing, which is the original defect with no way to see it. + let again = io_cli::stdin::placing(); + assert!( + io_cli::stdin::placement_waiting(), + "a placement taken after a nested one ended did not take the terminal at \ + all", + ); + drop(again); +} + #[test] fn a_reader_that_stood_aside_gets_the_terminal_back_afterwards() { let _serial = serially(); diff --git a/tests/support/mod.rs b/tests/support/mod.rs index ed6a4be..1fbbf2f 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -131,6 +131,12 @@ impl Write for Fixed { } impl Backend for Fixed { + /// ratatui 0.30 gave a backend its own failure type. This one delegates to a + /// `CrosstermBackend` and `Screen` requires `Backend`, so + /// naming anything else here would make the fixture stop being usable where + /// the real terminal is. + type Error = io::Error; + fn draw<'a, I>(&mut self, content: I) -> io::Result<()> where I: Iterator,