From d80d6f4949d2317e071ec4251cc2cce40b4bf5fb Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Wed, 26 Aug 2026 18:16:56 +0530 Subject: [PATCH 01/14] =?UTF-8?q?chore(develop):=20The=20operator=20memory?= =?UTF-8?q?=20writer:=20a=20line=20appended=20to=20the=20mark=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: US-IO-CLI-0.18.0-T01 --- src/home.rs | 18 ++- src/lib.rs | 3 + src/memory.rs | 181 ++++++++++++++++++++++++++++++ tests/memory.rs | 292 ++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 493 insertions(+), 1 deletion(-) create mode 100644 src/memory.rs create mode 100644 tests/memory.rs 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/lib.rs b/src/lib.rs index 679a0f2..bdb03e8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -53,12 +53,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/memory.rs b/src/memory.rs new file mode 100644 index 0000000..f46d986 --- /dev/null +++ b/src/memory.rs @@ -0,0 +1,181 @@ +//! 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.** +//! +//! # 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::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) +} diff --git a/tests/memory.rs b/tests/memory.rs new file mode 100644 index 0000000..287bc2d --- /dev/null +++ b/tests/memory.rs @@ -0,0 +1,292 @@ +//! 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", + ); +} From d29720b2f51087e83de57be5a6b689bfaae335ba Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Wed, 26 Aug 2026 18:16:56 +0530 Subject: [PATCH 02/14] =?UTF-8?q?chore(develop):=20Per-turn=20re-discovery?= =?UTF-8?q?=20through=20the=20existing=20`configure::=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: US-IO-CLI-0.18.0-T03 --- src/reload.rs | 143 +++++++++++++++++++++++ tests/reload.rs | 297 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 440 insertions(+) create mode 100644 src/reload.rs create mode 100644 tests/reload.rs 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/tests/reload.rs b/tests/reload.rs new file mode 100644 index 0000000..4d0cd2b --- /dev/null +++ b/tests/reload.rs @@ -0,0 +1,297 @@ +//! **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", + ); +} From f5d8dd341c0e5cfd97d89f9d5509fdc12c123202 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Wed, 26 Aug 2026 18:16:56 +0530 Subject: [PATCH 03/14] =?UTF-8?q?chore(develop):=20The=20agent's=20own=20m?= =?UTF-8?q?emory,=20read:=20both=20buckets=20with=20the=20wor=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: US-IO-CLI-0.18.0-T04 --- src/recall.rs | 384 +++++++++++++++++++++++++++++++++++ tests/recall.rs | 522 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 906 insertions(+) create mode 100644 src/recall.rs create mode 100644 tests/recall.rs diff --git a/src/recall.rs b/src/recall.rs new file mode 100644 index 0000000..d1eeb89 --- /dev/null +++ b/src/recall.rs @@ -0,0 +1,384 @@ +//! 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. +//! +//! This module is the read. It writes nothing: no `memory_put`, no +//! `memory_write`, no `memory_pin`, no `memory_forget`. Pinning and forgetting +//! are an operator's actions and belong to their own surface; a reader that could +//! also mutate is a reader somebody eventually calls from a render pass. +//! +//! 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. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +use io_harness::{Error, 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)) +} diff --git a/tests/recall.rs b/tests/recall.rs new file mode 100644 index 0000000..f2130fb --- /dev/null +++ b/tests/recall.rs @@ -0,0 +1,522 @@ +//! F6 and F8 — the agent's own durable memory, read back. +//! +//! 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. +//! +//! **`Store::memory_pin` appears once, in a fixture.** A refused write needs a +//! pinned entry and nothing else produces one. `src/recall.rs` itself writes +//! nothing at all; pinning is a later task's, and this is the operator's action +//! staged so the refusal can happen. +//! +//! **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, Happened, 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", + ); +} From efd849e522b7fec783d9b13115f25d13e13b1966 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Wed, 26 Aug 2026 18:38:30 +0530 Subject: [PATCH 04/14] =?UTF-8?q?chore(develop):=20The=20`[instructions]`?= =?UTF-8?q?=20list:=20written=20complete,=20written=20to=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: US-IO-CLI-0.18.0-T02 --- src/edit.rs | 23 +++ src/memory.rs | 238 +++++++++++++++++++++++++++- tests/instructions.rs | 358 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 617 insertions(+), 2 deletions(-) create mode 100644 tests/instructions.rs 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/memory.rs b/src/memory.rs index f46d986..ca96547 100644 --- a/src/memory.rs +++ b/src/memory.rs @@ -33,7 +33,9 @@ //! 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.** +//! `[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 //! @@ -47,7 +49,7 @@ use std::io::Write as _; use std::path::{Path, PathBuf}; -use io_harness::config::Scope; +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. @@ -179,3 +181,235 @@ pub fn remember(root: &Path, scope: Scope, line: &str) -> Result 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/tests/instructions.rs b/tests/instructions.rs new file mode 100644 index 0000000..81cf25e --- /dev/null +++ b/tests/instructions.rs @@ -0,0 +1,358 @@ +//! 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}", + ); + assert!( + !text.contains('~'), + "io-harness expands no `~`, so this would name a directory 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:?}", + ); +} From 0e098ba26b75565b8a2af315e332aba4c3b8fae0 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Wed, 26 Aug 2026 18:38:30 +0530 Subject: [PATCH 05/14] =?UTF-8?q?chore(develop):=20Pin=20and=20forget,=20w?= =?UTF-8?q?ith=20forgetting=20through=20`Store::memory=5F=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: US-IO-CLI-0.18.0-T05 --- src/recall.rs | 232 +++++++++++++++++++++++++- tests/recall.rs | 429 +++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 651 insertions(+), 10 deletions(-) diff --git a/src/recall.rs b/src/recall.rs index d1eeb89..e046a52 100644 --- a/src/recall.rs +++ b/src/recall.rs @@ -8,11 +8,23 @@ //! had never used, and had no way to know that the harness had quietly dropped //! one to hold a cap. //! -//! This module is the read. It writes nothing: no `memory_put`, no -//! `memory_write`, no `memory_pin`, no `memory_forget`. Pinning and forgetting -//! are an operator's actions and belong to their own surface; a reader that could +//! 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 @@ -58,11 +70,40 @@ //! 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::{Error, MemoryKind, MemoryLimits, Store, TaskContract, GLOBAL_MEMORY_WORKSPACE}; +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. /// @@ -382,3 +423,186 @@ fn draws(store: &Store) -> Result<(BTreeMap<(String, String), BTreeSet>, bo } 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/tests/recall.rs b/tests/recall.rs index f2130fb..a76839d 100644 --- a/tests/recall.rs +++ b/tests/recall.rs @@ -1,4 +1,4 @@ -//! F6 and F8 — the agent's own durable memory, read back. +//! 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 @@ -35,10 +35,19 @@ //! 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. //! -//! **`Store::memory_pin` appears once, in a fixture.** A refused write needs a -//! pinned entry and nothing else produces one. `src/recall.rs` itself writes -//! nothing at all; pinning is a later task's, and this is the operator's action -//! staged so the refusal can happen. +//! **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`. @@ -46,7 +55,7 @@ use std::path::Path; use std::sync::{Arc, Mutex}; -use io_cli::recall::{self, Happened, Scope}; +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, @@ -520,3 +529,411 @@ async fn f8_eviction_refusal_and_recall_are_read_from_the_trace() { "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, + ); +} From 081c3c15fbd1e0fb48e28a8d8c59582e2b0875b4 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Wed, 26 Aug 2026 19:11:38 +0530 Subject: [PATCH 06/14] =?UTF-8?q?chore(develop):=20Replace=20`tui=5Ftextar?= =?UTF-8?q?ea::TextArea`=20with=20an=20editing=20model=20=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: US-IO-CLI-0.18.0-T10 --- src/composer.rs | 54 ++- src/editor.rs | 876 ++++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + src/wizard.rs | 90 +++-- tests/editor.rs | 760 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 1724 insertions(+), 57 deletions(-) create mode 100644 src/editor.rs create mode 100644 tests/editor.rs 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/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/lib.rs b/src/lib.rs index bdb03e8..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; 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/editor.rs b/tests/editor.rs new file mode 100644 index 0000000..433d23d --- /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, "sk-or-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(), + ["sk-or-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, "sk-or-v1-secret"); + editor.key(chord(KeyCode::Char('w'), KeyModifiers::CONTROL)); + assert_eq!(editor.lines(), ["sk-or-v1-"]); + + undo(&mut editor); + assert_eq!(editor.lines(), ["sk-or-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(), + ); +} From cbffc1694c95c3bfba726fac1388374034fdace9 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Wed, 26 Aug 2026 19:11:38 +0530 Subject: [PATCH 07/14] =?UTF-8?q?chore(develop):=20Take=20ratatui=20to=200?= =?UTF-8?q?.30=20with=20crossterm=20held=20at=200.28,=20drop=20=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: US-IO-CLI-0.18.0-T11 --- .github/dependabot.yml | 47 +- Cargo.lock | 1055 +++++++++++++++++++++++++++++++++++----- Cargo.toml | 30 +- src/keys.rs | 7 +- src/term.rs | 15 +- tests/dependencies.rs | 93 +++- tests/support/mod.rs | 6 + 7 files changed, 1119 insertions(+), 134 deletions(-) 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/Cargo.lock b/Cargo.lock index e616c89..b1faf00 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" @@ -1038,7 +1264,7 @@ name = "io-cli" version = "0.17.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..96345f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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/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/term.rs b/src/term.rs index 31c270f..0c2417e 100644 --- a/src/term.rs +++ b/src/term.rs @@ -111,7 +111,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, @@ -310,7 +310,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 { @@ -568,7 +568,7 @@ impl Screen { } } -impl Drop for Screen { +impl + Write> Drop for Screen { fn drop(&mut self) { self.restore(); } @@ -606,6 +606,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/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/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, From 70a7eb4a6aee3d54687fc2cf42d3176a383caf4b Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Wed, 26 Aug 2026 19:11:39 +0530 Subject: [PATCH 08/14] =?UTF-8?q?chore(develop):=20`/remember`=20and=20`/m?= =?UTF-8?q?emory`=20as=20surfaces:=20both=20commands=20in=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: US-IO-CLI-0.18.0-T06 --- src/commands.rs | 532 +++++++++++++++++++++++++++++++++++++++++++++- src/main.rs | 350 ++++++++++++++++++++++++++++++ tests/commands.rs | 422 ++++++++++++++++++++++++++++++++++++ tests/glyphs.rs | 223 +++++++++++++++++++ 4 files changed, 1521 insertions(+), 6 deletions(-) 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/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/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/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}; From bf2ae93cf21a92f898d4b22d71ec27d98360dfd2 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Wed, 26 Aug 2026 19:11:39 +0530 Subject: [PATCH 09/14] =?UTF-8?q?chore(develop):=20Documentation=20truth:?= =?UTF-8?q?=20both=20commands=20in=20the=20README=20table=20=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: US-IO-CLI-0.18.0-T08 --- CHANGELOG.md | 47 +++++++++++++++++++++++++++++++++++++++++++++++ README.md | 28 ++++++++++++++++++++++++++-- 2 files changed, 73 insertions(+), 2 deletions(-) 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/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` From 6c014f343303b10edaf5f9c237f11d732a55f824 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Wed, 26 Aug 2026 19:12:04 +0530 Subject: [PATCH 10/14] style: apply rustfmt to the release's new modules and tests The five files were written by hand across several tasks and reflowed in one pass, so the formatting change is separated from the code it formats rather than folded into a task's own commit. --- src/memory.rs | 5 +- tests/instructions.rs | 31 +++++++++--- tests/memory.rs | 8 +-- tests/recall.rs | 111 ++++++++++++++++++++++++++++++++++-------- tests/reload.rs | 5 +- 5 files changed, 129 insertions(+), 31 deletions(-) diff --git a/src/memory.rs b/src/memory.rs index ca96547..8f11256 100644 --- a/src/memory.rs +++ b/src/memory.rs @@ -145,7 +145,10 @@ pub fn remember(root: &Path, scope: Scope, line: &str) -> Result 1, "{scope:?} has a header and a bullet: {row:?}"); + assert!( + row.lines > 1, + "{scope:?} has a header and a bullet: {row:?}" + ); } } @@ -285,7 +298,10 @@ fn a_project_list_replaces_the_user_one_and_the_unread_files_say_so() { 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.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 \ @@ -324,7 +340,10 @@ fn a_named_file_that_is_absent_is_skipped_and_reported_absent() { 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_eq!( + row.lines, 0, + "a file that is not there holds no lines: {row:?}" + ); assert!(!row.read, "{scope:?} cannot be read: {row:?}"); } } diff --git a/tests/memory.rs b/tests/memory.rs index 287bc2d..d72a7dc 100644 --- a/tests/memory.rs +++ b/tests/memory.rs @@ -262,7 +262,9 @@ fn two_lines_appear_in_the_order_they_were_remembered() { 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"); + 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}", @@ -282,8 +284,8 @@ 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"); + let path = + memory::remember(&root, Scope::Project, " prefer small diffs \n").expect("the line lands"); assert!( read(&path).ends_with("- prefer small diffs\n"), diff --git a/tests/recall.rs b/tests/recall.rs index a76839d..9638ee2 100644 --- a/tests/recall.rs +++ b/tests/recall.rs @@ -119,7 +119,13 @@ fn f6_lists_both_buckets_and_keeps_them_apart() { 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); + let wrote = remember( + &store, + &bucket, + "test-command", + "cargo test --lib", + MemoryKind::Fact, + ); remember( &store, GLOBAL_MEMORY_WORKSPACE, @@ -216,8 +222,13 @@ fn f6_the_workspace_bucket_is_the_canonicalised_root() { ); // 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"); + let view = recall::view( + &store, + &link, + &contract(&link, MemoryLimits::default()), + None, + ) + .expect("the view reads"); assert_eq!( view.entries.len(), 1, @@ -242,7 +253,10 @@ fn f6_a_root_that_cannot_be_canonicalised_falls_back_to_the_path_as_given() { // 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"); + 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); @@ -252,9 +266,20 @@ fn f6_a_root_that_cannot_be_canonicalised_falls_back_to_the_path_as_given() { "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"); + 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"); } @@ -272,8 +297,13 @@ fn f8_the_caps_are_named_per_scope_and_the_ceiling_is_both() { max_entry_chars: 120, }; - let view = recall::view(&store, workspace.path(), &contract(workspace.path(), limits), None) - .expect("the view reads"); + let view = recall::view( + &store, + workspace.path(), + &contract(workspace.path(), limits), + None, + ) + .expect("the view reads"); assert_eq!( view.caps @@ -316,7 +346,12 @@ 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()) + .map(|batch| { + batch + .into_iter() + .map(|(k, v)| remember_call(k, v)) + .collect() + }) .collect(); Self { batches: Mutex::new(batches), @@ -382,7 +417,13 @@ async fn f8_eviction_refusal_and_recall_are_read_from_the_trace() { // 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, + "alpha", + "the operators value", + MemoryKind::Decision, + ); remember(&store, &bucket, "beta", "something older", MemoryKind::Fact); store .memory_pin(&bucket, "alpha", true) @@ -467,8 +508,8 @@ async fn f8_eviction_refusal_and_recall_are_read_from_the_trace() { // --- 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"); + 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 @@ -594,8 +635,14 @@ fn f7_pinning_and_unpinning_act_on_the_bucket_the_scope_names() { // 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"), + recall::pin( + &store, + workspace.path(), + Scope::Workspace, + "never-written", + true + ) + .expect("the call succeeds even though there is nothing to pin"), Pinned::NoEntry, ); assert!( @@ -617,7 +664,13 @@ fn f7_a_pinned_entry_survives_an_overwrite_and_an_unpinned_one_does_not() { 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, + "owner", + "the platform team", + MemoryKind::Decision, + ); remember(&store, &bucket, "retries", "three", MemoryKind::Fact); assert_eq!( recall::pin(&store, workspace.path(), Scope::Workspace, "owner", true) @@ -707,7 +760,13 @@ fn f7_a_pinned_entry_survives_eviction_at_the_cap_and_an_unpinned_one_does_not() // 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); + 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"), @@ -749,8 +808,14 @@ fn f7_a_pinned_entry_survives_eviction_at_the_cap_and_an_unpinned_one_does_not() .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(&"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 \ @@ -884,7 +949,13 @@ fn f7_a_pinned_entry_is_refused_and_an_unknown_key_is_absent() { 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, + "owner", + "the platform team", + MemoryKind::Decision, + ); assert_eq!( recall::pin(&store, workspace.path(), Scope::Workspace, "owner", true) .expect("the operator pins it"), diff --git a/tests/reload.rs b/tests/reload.rs index 4d0cd2b..5c95043 100644 --- a/tests/reload.rs +++ b/tests/reload.rs @@ -83,7 +83,10 @@ fn workspace(io_toml: &str, agents: &str) -> (tempfile::TempDir, Configuration) /// 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)) + held.config() + .instructions() + .iter() + .any(|i| i.contains(line)) } /// The theme the held pair reports, which is the `CliSettings` half. From d1e48d2d38ed4649b14a800e787109ee5787939f Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Wed, 26 Aug 2026 20:13:06 +0530 Subject: [PATCH 11/14] fix: answer ratatui 0.30's cursor query from inside the renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ratatui 0.30 made `Terminal::insert_before` end in `Terminal::clear`, and 0.30's `clear` snapshots the cursor before clearing so it can restore it (`ratatui-core-0.1.2/src/terminal/buffers.rs:147`). 0.29's wrote the escape and asked nothing. So a commit now queries the terminal, the reply arrives on stdin, and io-cli's keyboard reader thread takes it — leaving the query to time out after two seconds. The call that failed was the commit of the splash, so every session died before its first prompt. The stand-aside `stdin` already provides was written for exactly this hazard, but it was applied at the two explicit placement sites because in 0.29 those were the only places that queried. It now goes where the queries are: `attach_raw`, `commit`, `commit_raw`, `resize`, and the one `draw` where the recorded size moved. A still screen takes no lock, so an unchanged frame still writes no bytes. `stdin::placing()` becomes reentrant, because `main` holds a guard around the larger operations that are built out of these sites and a second acquisition on one thread would wait for itself forever. A thread-local records which guard is the holder rather than a depth count, so releasing out of order is still correct. Found by driving the built binary. The whole suite passes on the broken binary — nothing under tests/ drives a real pty. --- src/stdin.rs | 76 ++++++++++++++++++++++++++++++++++++-------- src/term.rs | 83 ++++++++++++++++++++++++++++++++++++++++++------- tests/frames.rs | 74 +++++++++++++++++++++++++++++++++++++++++++ tests/stdin.rs | 66 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 274 insertions(+), 25 deletions(-) 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 0c2417e..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 @@ -120,8 +132,14 @@ pub struct Screen + Write> { /// 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)?; @@ -348,6 +376,12 @@ impl + Write> 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 + Write> 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 + Write> 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 + Write> 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)) } 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/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(); From 38e0ffdcf4570fde2422841808dda1e750d42ae2 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Wed, 26 Aug 2026 20:13:10 +0530 Subject: [PATCH 12/14] chore: bump to 0.18.0 The release binary is built after this so the pty captures name the version they are evidence for. --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b1faf00..ef205eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1261,7 +1261,7 @@ dependencies = [ [[package]] name = "io-cli" -version = "0.17.0" +version = "0.18.0" dependencies = [ "clap", "crossterm 0.28.1", diff --git a/Cargo.toml b/Cargo.toml index 96345f8..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 From 9b86287ad18850f5d0cad81458dc9c2f12ed0a06 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Wed, 26 Aug 2026 20:14:56 +0530 Subject: [PATCH 13/14] test: use a fixture key that does not match a live vendor prefix The masking tests typed a string beginning `sk-or-v1-`, which is OpenRouter's own key prefix. It is obviously synthetic to a reader and not to a secret scanner, and a repository that trips its own scanning on every push teaches people to wave the alerts through. Same length and the same word boundary, so the mask-width and `Ctrl+W` assertions are unchanged. --- tests/editor.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/editor.rs b/tests/editor.rs index 433d23d..5fde3b9 100644 --- a/tests/editor.rs +++ b/tests/editor.rs @@ -478,7 +478,7 @@ fn ctrl_a_and_ctrl_e_go_to_the_ends_of_the_row() { #[test] fn a_masked_field_shows_the_mask_and_nothing_of_what_was_typed() { let mut editor = Editor::masked('•'); - typed(&mut editor, "sk-or-v1-secret"); + typed(&mut editor, "aa-bb-v1-secret"); assert_eq!(editor.shown(), "•".repeat(15)); assert!( @@ -488,7 +488,7 @@ fn a_masked_field_shows_the_mask_and_nothing_of_what_was_typed() { ); assert_eq!( editor.lines(), - ["sk-or-v1-secret"], + ["aa-bb-v1-secret"], "the buffer still holds it, because the wizard has to read it", ); } @@ -745,12 +745,12 @@ fn undo_in_a_masked_field_comes_back_masked() { // `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, "sk-or-v1-secret"); + typed(&mut editor, "aa-bb-v1-secret"); editor.key(chord(KeyCode::Char('w'), KeyModifiers::CONTROL)); - assert_eq!(editor.lines(), ["sk-or-v1-"]); + assert_eq!(editor.lines(), ["aa-bb-v1-"]); undo(&mut editor); - assert_eq!(editor.lines(), ["sk-or-v1-secret"], "the key came back"); + 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-"), From 1130ed1710db9980c1a992e1db8fa9feac89fbe9 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Wed, 26 Aug 2026 20:21:58 +0530 Subject: [PATCH 14/14] test: assert no entry opens with a tilde, not that the file holds none MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `the_user_path_is_written_literally_and_not_as_a_substitution` was red on windows-latest only. It asserted `!text.contains('~')`, and a temporary directory on Windows comes 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 assertion called it a shorthand. The property the criterion is about is io-cli writing `~` as a stand-in for a home directory it declined to resolve, which can only appear at the start of an entry. Every entry is a quoted string, so `"~` is that and nothing else. The sealed record is not amended: nothing it claims became false. Its evidence line quotes the macOS suite, and the operational claim it makes about the three-platform matrix is the one this commit makes true. --- tests/instructions.rs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/tests/instructions.rs b/tests/instructions.rs index 6e76b60..dd9c340 100644 --- a/tests/instructions.rs +++ b/tests/instructions.rs @@ -211,9 +211,21 @@ fn the_user_path_is_written_literally_and_not_as_a_substitution() { "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 this would name a directory called `~`:\n{text}", + !text.contains("\"~"), + "io-harness expands no `~`, so an entry beginning with one would name a \ + directory literally called `~`:\n{text}", ); }