diff --git a/AGENTS.md b/AGENTS.md index 856d20c..23f95b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,7 +58,8 @@ the only dev-dependency. Adding one is a deliberate, argued act — read the com - **Process spawns** are permitted in `src/shell.rs` (the `!` escape) and `src/fetch.rs` (one `git clone` for a marketplace) and nowhere else. - **TOML parsing** is permitted in `src/edit.rs` alone. Every other module that needs a value out - of a configuration file goes through `edit::value_at`, `edit::sections` or `edit::array`. + of a configuration file goes through `edit::value_at`, `edit::sections`, `edit::keys` or + `edit::array`. `src/marketplace.rs` reads a stranger's `plugin.toml` that way, and `src/adapt.rs` *writes* one without ever parsing it. - **JSON deserialization** is permitted in `src/import.rs` and `src/adapt.rs`, and nowhere else. diff --git a/CHANGELOG.md b/CHANGELOG.md index f54993c..cf9570e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,145 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +## [0.33.0] - 2026-08-31 + +io-harness 0.72.0 lets an agent ask several things at once and lets a choice carry +a description and a preview. All of it reached io-cli the moment the pin moved and +none of it was drawn: a batched ask emitted an event the transcript had no +disposition for, a described choice was a label with its sentence thrown away, and +a question that accepted several answers accepted one. A resumed batch was worse +than blind — it came back as one row of numbered prose with an empty choices +column, so what an operator saw was a wall of text and nothing to pick. + +This release makes the question surface the one surface. A batch arrives as one +overlay and is answered in place; an offer explains itself and can show what +taking it would do; `Space` marks a set where the question takes one. + +The other half is a write nobody chose. `/config` was refused mid-turn because its +bare list carried a row that re-read the provider's catalogue and wrote a scope +file — and because `Left` and `Right` on a row wrote the file on the keystroke, +with no confirmation, which was the only such write in the product. Both are gone +rather than guarded, and the bare `/config` reports while a turn runs. + +### Added + +**An agent can ask several questions at once and they arrive as one overlay.** One +question is on the screen at a time, with the same offers, context and free-text +row a single question has. Deciding one moves to the next undecided one; deciding +the last delivers the whole batch. `PgUp` and `PgDn` walk it, and a decided +question re-opens with your own answer back in the composer. Two lines of the head +say which question of how many, and what this one was already decided as. + +There is no review pane and no submit key, deliberately. The answers are already +on the screen they were typed into, one page-key apart; a second rendering of them +is a second thing that can disagree with the first, and a submit key that answers +nothing is the reflexive `Enter` this overlay has always been careful about. +Nothing is sent until every question is decided, because io-harness commits a batch +only when every entry has an answer — four of five parks the run as thoroughly as +none. `Esc` decides the question on the screen as *nobody here can answer this* and +moves on; the run still parks, which is all `Esc` has ever promised. + +**A choice can explain itself.** A description is drawn on a row of its own under +the label and stays there, because comparing five offers needs all five sentences +at once. A preview unfolds beneath the offer under the marker, one at a time, and +folds when the marker moves — five blocks at once is a wall nobody reads. It is +marked the way this product already marks quoted words, since a preview is +somebody else's text. `Enter` on an offer whose preview is open still answers with +that offer. + +**A question that takes several answers takes them.** `Space` marks and unmarks +the offer under the marker, the offers carry a box from the moment the list opens, +and `Enter` sends the marked set — or, with nothing marked, the offer you are +looking at, because an empty answer is information the agent did not have and +would now believe. The set is joined by io-harness's own speller, so two interfaces +answering one question produce the same text. Marks are held against the list you +were given and survive the query that hides a row: an operator narrows a list in +order to find each row to mark, and a filter that un-marked as it went would throw +away the marks made under the last query. + +**`/config` runs while a turn is in flight, in its bare form**, taking the +mid-turn set from ten commands to eleven. `/config ` and `/config +` descend toward a write and keep their refusal — the first time the +run-state guard has read past a command's first word. The whole-command refusal +still covers `/plugin`, `/mcp`, `/provider`, `/skills`, `/memory` and `/store`. + +**`io plugin remove` takes a bundle's name as well as its directory.** The path is +read first and against the disk, so an existing script means what it always meant. +Two declared bundles of one name are refused with both directories printed rather +than resolved by order. + +### Changed + +**No arrow key writes a configuration file.** `Left` and `Right` on a `/config` +row used to step a boolean or a closed set of words to its next value and write +that value into a scope file on the keystroke, unconfirmed. They now open that +setting's values with the marker on the value in force, and `Enter` is the +confirmation every other managed surface already uses. + +**The price refresh moved off the bare `/config` list** to one descent below +`prices.as_of`, the date it writes, with *leave it* at row 0. It was the last row +of the list, which made a keystroke on a surface whose whole job is reporting read +the network, write a file and reassign the running turn's configuration. + +**`io resume --list` says how many questions a parked row is waiting on.** A +batched ask is one row with one id and one `--answer` answers all of it — there is +no per-question flag, because io-harness parks a batch as a single row and records +a single reply against it. The refusal now says so and tells you to number your +answers to match. The questions themselves are counted rather than pasted into a +one-line detail, which read as if the first were the whole ask. + +### Fixed + +**`io skill add /SKILL.md` installed a skill that could never be removed.** +The destination was named from the source's file name, so the commonest layout on +disk wrote `~/.io-cli/skills/SKILL.md` — a shape io-cli read as a folder skill and +then refused both to remove and to disable, forever, with a sentence about a +directory that did not exist. The installed file is named from the skill's own +name now, and `io skill remove ` takes it back out. + +**A skill `/import` wrote as a folder could not be removed or turned off.** The +product shipped a verb that created state its own management surface refused to +manage. Both levers work on it, and a disabled folder skill stays visible on the +surface that re-enables it. Disabling a loose `SKILL.md` sitting directly in the +skills directory is refused: the move keeps the name, so it would land as +`disabled/SKILL.md` and be re-offered as a skill called *disabled*, taking every +parked skill's hiding place with it. + +**`io plugin add` printed `plugin remove takes it back out` and the verb read +its argument as a path**, so the sentence was false on the door that printed it. + +**A batched ask emitted an event nothing in the transcript could dispose of.** +io-harness 0.72.0 emits `QuestionsAsked` for a batch and does not also emit +`QuestionAsked`, so the surface this release exists for was the one ask the +transcript was blind to. The declared event kinds move from 51 to 52. + +**A resumed batch came back as a wall of text with nothing to pick.** The parked +row's question column is numbered prose and its choices column is empty, so a +resume read only those and drew both. The questions are carried whole now. + +**Thirty-four of the forty-six in-page links in the documentation went nowhere.** +0.30.2 split a 2,847-line README into nineteen guide pages and moved the text +faithfully, but every anchor kept pointing inside the file it had left. The link +gate skipped fragment-only links by design, so nothing caught it and nothing +stopped it growing. Every anchor now names the page that holds the heading, and +the gate resolves both spellings instead of skipping one. Found while bringing this +release's own prose back to true, and repaired here rather than left for a version +that would have had to find it again. + +**A comment said io-harness substitutes two forms when it substitutes three.** +`${cmd:…}` is the third, and the same sentence went on to say a manifest refuses +all three. The correction had already been made once, in 0.21.0, in the other file +that carries the claim. + +### Known limitations + +**A single question that takes several answers loses that fact when it parks.** +io-harness's `PendingQuestion` has no column for it and the singular writer records +none, so a lone multi-select that parks and is resumed comes back as a pick-one. A +batched ask keeps it, because a batch carries its questions whole. This is +upstream, and it is stated rather than papered over with a default that would read +as a fact. + ## [0.32.0] - 2026-08-30 The agent could ask you a question and you could not answer it. `Intent::render` @@ -2838,7 +2977,8 @@ client, tool, sandbox, policy engine or session store of its own. - There is no crates.io publish and `cargo install` is not an install path. - No test in this release asserts on wall-clock time. -[Unreleased]: https://github.com/initorigin/io-cli/compare/v0.32.0...HEAD +[Unreleased]: https://github.com/initorigin/io-cli/compare/v0.33.0...HEAD +[0.33.0]: https://github.com/initorigin/io-cli/compare/v0.32.0...v0.33.0 [0.32.0]: https://github.com/initorigin/io-cli/compare/v0.31.0...v0.32.0 [0.31.0]: https://github.com/initorigin/io-cli/compare/v0.30.2...v0.31.0 [0.30.2]: https://github.com/initorigin/io-cli/compare/v0.30.1...v0.30.2 diff --git a/Cargo.lock b/Cargo.lock index 37f2db9..61fe74c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1850,7 +1850,7 @@ dependencies = [ [[package]] name = "io-cli" -version = "0.32.0" +version = "0.33.0" dependencies = [ "clap", "crossterm", @@ -1867,9 +1867,9 @@ dependencies = [ [[package]] name = "io-harness" -version = "0.71.0" +version = "0.72.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b8b8ddbe1020cf14a903d8623cbea4c1e0669344bb443af92f70d2cd0af2e6e" +checksum = "2d116567f264ba451d220c30b42eaa7be505b0ff2b89289257751e542beb82de" dependencies = [ "base64 0.23.1", "calamine", diff --git a/Cargo.toml b/Cargo.toml index f1d17ca..a490f43 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "io-cli" -version = "0.32.0" +version = "0.33.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 @@ -63,7 +63,7 @@ path = "src/lib.rs" # still correct about what this *renderer* can be handed; it is simply no longer # load-bearing on the build. Turning `barcode` back off is what would restore # it, and `documents` would then have to be spelled out as its other four. -io-harness = { version = "0.71", features = ["media", "browser", "documents"] } +io-harness = { version = "0.72", features = ["media", "browser", "documents"] } # **`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 diff --git a/README.md b/README.md index ee3881d..ae3e8ed 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ posture on the next.](assets/screenshot.png) | **Undo** | `Esc Esc` at an empty prompt rewinds the last turn — its files, its memory and the conversation head | | **Conversations that survive** | `/resume` reopens an earlier session and answers whatever its last run stopped on, `/fork` continues from an earlier turn, `/clear` starts fresh without leaving | | **A paused run, answered** | A question, a plan or an interrupted call is decided where it was left and the run carries on from the step it stopped at — from a session, or from a script with `io resume` | +| **Everything it asks, in one place** | Several questions asked at once arrive as one overlay, answered one at a time and sent together; an offer may explain itself and preview what taking it would do; `Space` marks where a question takes more than one answer | | **Headless** | `io exec` runs one goal to completion with documented exit codes and `--json`, and `io resume` carries a parked one on | | **Readable without seeing it** | `--plain` animates nothing and commits every state change as text, for a screen reader, a braille display or a log | | **Markdown, rendered** | Headings, bullets, code and emphasis drawn as themselves rather than printed as notation | @@ -138,7 +139,7 @@ the limits that capability actually has. | Guide | What it covers | | --- | --- | -| [While it works](docs/guide/the-session.md) | What a running turn shows you, how the agent's manner is drawn, answering without opening a run, how much reasoning a turn buys, and background jobs | +| [While it works](docs/guide/the-session.md) | What a running turn shows you, how the agent's manner is drawn, answering without opening a run, how much reasoning a turn buys, background jobs, and the one surface the agent asks everything through | | [Keys](docs/guide/keys.md) | Every default binding, and moving one | | [Commands](docs/guide/commands.md) | All thirty-six, grouped by what you are doing | | [Bringing your setup across](docs/guide/import.md) | What `/import` finds on this machine, item by item, and the credential it never reads | @@ -196,7 +197,7 @@ says so once at startup, naming the number that is no longer in force and `[run] max_steps` as where the cap lives now. **A section that was ignored may start being read**, which 0.14.0 did to eleven of them, and that is a behaviour change for a file that already carried one; it is the migration note in -[Configuration](#configuration) and in the changelog rather than something to +[Configuration](docs/guide/configuration.md#configuration) and in the changelog rather than something to find out from a turn. ## Security diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index 87d3a93..c0fc9ad 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -147,19 +147,23 @@ points an operator can enter the state. **Every HTTP MCP server is refused by default**, because the default network effect is `Deny`. A bare-host rule opens it. -**Ten commands run while a turn is in flight, and the rest are refused.** `/status`, `/context`, -`/cost`, `/stats`, `/help`, `/theme`, `/copy`, `/expand`, `/fleet` and `/image` report while the -agent works; `/` and `@` open the palette and path completion; `/compact` and `/steer` reach the -turn through their own arms as they always have. Every other command keeps its refusal, in the same -sentence, and the rule is what a command *does* rather than how harmless it looks: anything that -reassigns the session or the provider, writes the store or a configuration file, or submits a turn -of its own is refused. - -**`/config` is refused mid-turn in every form, including the bare one.** Its picker offers a row -that re-reads the provider's catalogue, writes the prices into a scope file and reassigns the -configuration the running turn is holding. The run-state guard is on the whole command deliberately -— splitting a bare form from an argued one is where a mistake ships a write into a running turn — -and the same applies to `/plugin`, `/mcp`, `/provider`, `/skills`, `/memory` and `/store`. +**Eleven commands run while a turn is in flight, and the rest are refused.** `/status`, +`/context`, `/cost`, `/stats`, `/help`, `/theme`, `/copy`, `/expand`, `/fleet`, `/image` and +`/config` report while the agent works; `/` and `@` open the palette and path completion; +`/compact` and `/steer` reach the turn through their own arms as they always have. Every other +command keeps its refusal, in the same sentence, and the rule is what a command *does* rather than +how harmless it looks: anything that reassigns the session or the provider, writes the store or a +configuration file, or submits a turn of its own is refused. + +**`/config` is admitted bare and refused the moment it carries a word.** Through 0.32.0 it was +refused in every form, because the bare list carried a row that re-read the provider's catalogue, +wrote the prices into a scope file and reassigned the configuration the running turn was holding — +and because a horizontal arrow on a row wrote the scope file where it stood. 0.33.0 removed both +acts rather than guarding them: the refresh is one descent below `prices.as_of`, the arrows open a +setting's values, and the bare list reports and nothing more. `/config ` and +`/config ` descend toward a write and keep their refusal, which is the first time the +run-state guard has read past a command's first word. The whole-command refusal still applies to +`/plugin`, `/mcp`, `/provider`, `/skills`, `/memory` and `/store`. **A mid-stream token figure is an estimate and is drawn as one.** `EventKind::Token` carries text and no count, because providers do not bill per chunk, so the figure that moves while a step is diff --git a/docs/guide/accounting.md b/docs/guide/accounting.md index 6bfc2b7..3ab66b8 100644 --- a/docs/guide/accounting.md +++ b/docs/guide/accounting.md @@ -58,8 +58,8 @@ whose prices it read and threw away on the same row. The rates land in `[prices] which is io-harness's own section: `as_of` for the date, and one line per model under `[prices.models]`. -**Refreshing is a row on `/config`, and it shows every rate that would move before -it writes anything** — what each was and what it would become — and you can +**Refreshing is a row one descent below `prices.as_of` on `/config`, and it shows +every rate that would move before it writes anything** — what each was and what it would become — and you can decline the lot. That is not courtesy. The file records what a rate *is* and never where it came from, so io-cli cannot tell a correction you made by hand from a value an older catalogue served; it does not guess, it shows you. A refetch that diff --git a/docs/guide/commands.md b/docs/guide/commands.md index 024cba5..c7c3204 100644 --- a/docs/guide/commands.md +++ b/docs/guide/commands.md @@ -95,7 +95,7 @@ still answers what that key is set to without writing anything. `/provider`'s tw new verbs are rows on its panel and never words you type: which link to add is chosen from what your shell can already authenticate, and a list is the only honest way to ask that. See [Without leaving the -session](#without-leaving-the-session). +session](configuration.md#without-leaving-the-session). `/effort` is new in 0.26.0 and sits under **this turn** beside `/model`, because the two are the same question asked twice: which model the work goes to, and how @@ -104,7 +104,7 @@ the level it sets holds for that turn and for every turn after it until you chan it — and a bare `/effort` reports the level in force and changes nothing. `/effort off` is not a fourth level below `low`: it goes back to sending no reasoning field at all, which is what every release before this one sent. See [How -much reasoning a turn buys](#how-much-reasoning-a-turn-buys). +much reasoning a turn buys](the-session.md#how-much-reasoning-a-turn-buys). `/profile` sits under **the session** from 0.26.0, and it is a correction rather than a reshuffle — the third time this sentence has been written, after 0.19.0's @@ -123,17 +123,17 @@ diff` because it is the third thing you do with work that has just finished: one puts the answer somewhere, one puts the patch somewhere, and this one makes the patch permanent. Its description says *ask the agent* because that is literally what the word does — it sends a prompt, and the agent reviews, stages and writes -the message. See [Git](#git). +the message. See [Git](git.md#git). `/plugin` is new in 0.20.0 and sits beside those two because it is the third surface of the same kind: something a configuration file declares by name, whose effect on the session is otherwise invisible. See [Capability -bundles](#capability-bundles). +bundles](plugins.md#capability-bundles). `/import` is new in 0.21.0 and is last in the group because it is the one command here you use once: the others are returned to for the life of the install. It writes files, which is the whole reason it is under **configure**. See [Bringing -your setup across](#bringing-your-setup-across). +your setup across](import.md#bringing-your-setup-across). `/gates` is new in 0.24.0 and is under **configure** for that same reason and not because of what its first screen shows. It opens on the criterion in force and the @@ -143,16 +143,16 @@ That promise is worth more here than it is for a server list: a gate set by accident does not merely change what the next turn talks to, it can spend a whole extra turn against a real model deciding the first one was not finished. It sits before `/import` because it is one you come back to. See [Verification -gates](#verification-gates). +gates](verification.md#verification-gates). `/resume` does more than reopen from 0.23.0: each row says what that session's last run stopped on, and choosing one answers it. No command was added for that — the one that was already there was extended. See [When a run stops for -you](#when-a-run-stops-for-you). +you](resume.md#when-a-run-stops-for-you). `/cost` and `/stats` are new in 0.22.0 and are two commands rather than one because they are two questions: `/cost` says what the work cost, `/stats` says -whether it worked. See [What it costs](#what-it-costs). +whether it worked. See [What it costs](accounting.md#what-it-costs). `/image`, `/copy` and `/copy diff` sit under **this turn** from 0.22.0, and it is a correction rather than a way of making room — the same sentence 0.19.0 wrote @@ -223,6 +223,13 @@ nothing else — the conversation it ends is still in the store and still listed `/resume`, and your terminal's scrollback is still your terminal's. It is refused while a turn is running. +**Eleven of these run while a turn is in flight and the rest are refused**, and +the rule is what a command does rather than how harmless it looks. `/config` +joined that first set in 0.33.0, in its bare form only: `/config ` and +`/config ` descend toward a write and keep the refusal. The whole +list, and the reason each side of it is where it is, is on +[Keys](keys.md) — one place, so the two cannot come to disagree. + `/copy` uses OSC 52, so it reaches the clipboard of the machine you are *sitting at* rather than the one you are ssh'd into. Nothing acknowledges an OSC 52 write: the line it prints says what was sent and how large it was, never that it diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index c8b3ad9..e71661a 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -35,12 +35,37 @@ names ladders from 1. `app.io-cli.gates.expect_exit` is the one signed key and i ladder runs through zero into the negatives, because a process may legitimately be expected to exit on one. -**A horizontal arrow changes a boolean or a closed set of words where it stands**, -without opening anything — see [Keys](#keys) for why it is the arrows and not the -spacebar. Each press writes, re-reads and redraws the row from the file's own -answer rather than from an account of what was just done, and each one is -committed to the scrollback, so cycling through four values leaves four lines -saying what happened rather than one footer notice overwriting the other three. +**A horizontal arrow opens a row's values, with the marker on the value in +force.** `Left` and `Right` do what `Enter` does, and they open on where you +already are so you can see it before you move; `Enter` on a value is the +confirmation, and the write goes into the file the descent names. + +**No arrow key writes a configuration file, and until 0.33.0 one did.** `Left` +and `Right` on a boolean or a closed set of words used to step to the next value +and write it into a scope file on the keystroke — one press, one write, nothing +asked. It was the only unconfirmed write in this product reachable from a bare +arrow key, and it is the reason `/config` could not be opened while a turn was +running at all. Removing the write rather than guarding it is what made the bare +list safe enough to report mid-turn; see [Keys](keys.md) for the commands that now +answer while the agent works. + +**The price refresh is one descent below `prices.as_of`, and it used to be the +last row of the bare list.** There it made a keystroke on `/config` read the +network, write a scope file and reassign the configuration a running turn was +holding — which is a list that acts, on a surface whose whole job is saying what +is in force. It is now where the act belongs: choose `prices.as_of`, the date the +refresh writes, and the descent offers *leave it* at the top and the re-read under +it, with the date it last read beside it. Row 0 declines and nothing happens, +which is what row 0 does on every confirmation in this product. What the refresh +then shows you before it writes is unchanged — see +[What it costs](accounting.md). + +`prices.as_of` is still the one key you cannot type a value into. A date typed by +hand is a claim about a fetch that never happened, so `/config prices.as_of +`, `io config set prices.as_of` and every other typing door refuse it — and +they refuse it in the same words, because a key one door writes and another will +not is the asymmetry this surface exists to remove. The descent offers the fetch +instead of the date. **Every row also offers *unset it*, which removes the key rather than writing a default's text into a file.** The distinction is the one this whole surface opens @@ -115,7 +140,7 @@ with `--env`, `--header`, `--timeout-secs` and `--scope`, read by the same parse panel was missing: `servers::add` existed, was tested, and was called from nothing, so a list you could prune was a list you could not grow. See [Managing the configuration without a -session](#managing-the-configuration-without-a-session) for the grammar, which is +session](headless.md#managing-the-configuration-without-a-session) for the grammar, which is one grammar. **Adding one reports whether the policy will let it start.** io-harness denies @@ -126,7 +151,7 @@ a disclosure and never a veto: the entry is written either way. It is where a server is added, rather than at the run that first needs one, because a refused entry looks exactly as valid as a working one and the alternative is finding out a turn later. The same paragraph in [Managing the configuration without a -session](#managing-the-configuration-without-a-session) has the rule to write. +session](headless.md#managing-the-configuration-without-a-session) has the rule to write. **It switches a server off and back on**, which through io-harness 0.69.0 it could not: `McpServer` carried no key for it, and an `enabled = false` invented @@ -235,7 +260,7 @@ vendored into a repository work for everyone who clones it; one kept elsewhere i written absolute. A bundle deeper than three directories, or outside the root entirely, is named outright — `/plugin add `, or `io plugin add ` from a shell — and is refused by the same check rather than by a shallower one. See -[Capability bundles](#capability-bundles). +[Capability bundles](plugins.md#capability-bundles). **From 0.29.0 the same verb also takes a name.** `/plugin add ultraship` installs a bundle out of a marketplace you have added, and `ultraship@zeroonething/ultraship` @@ -247,7 +272,7 @@ the disk rather than the spelling, so one word cannot mean a directory on a mach that has one and a marketplace bundle on a machine that does not. Installing by name **declares the bundle switched off and shows you what it would -bring before it brings it** — see [Marketplaces](#marketplaces). Installing by +bring before it brings it** — see [Marketplaces](plugins.md#marketplaces). Installing by path does not: that directory is one you already have. **`/profile`** switches to a named `[profile.]` for the session, and @@ -265,15 +290,15 @@ Nine keys live there, and eight tables: | `max_parallel_reads` | how many read-only tool calls one turn may run at once. Absent, it is io-harness's own 10; `0` is clamped to 1 rather than meaning none. A `TaskContract` field with no io-harness configuration key of its own, which is why it is named here. | | `spawn_background_after_secs` | how long a spawned child may run before it is backgrounded. Absent, a child is waited for however long it takes. | | `detached_spawns` | whether a spawn may detach at all. Absent, it may. `false` buys a trace with every child's whole life in it, which a detached child gives up. | -| `conversational` | whether a prompt that is only a question may be answered in one completion, with no steps and no tools. Absent leaves io-harness's own classification where it is, which is what every release before 0.26.0 did; `false` opens a full run for every prompt. See [Answered without opening a run](#answered-without-opening-a-run). | -| `[app.io-cli.keys]` | the session's keys, by action name. See [Moving a key](#moving-a-key). | -| `[app.io-cli.containment]` | the caps a fan-out runs under. Absent, a session cannot decompose anything. See [The fleet](#the-fleet). | +| `conversational` | whether a prompt that is only a question may be answered in one completion, with no steps and no tools. Absent leaves io-harness's own classification where it is, which is what every release before 0.26.0 did; `false` opens a full run for every prompt. See [Answered without opening a run](the-session.md#answered-without-opening-a-run). | +| `[app.io-cli.keys]` | the session's keys, by action name. See [Moving a key](keys.md#moving-a-key). | +| `[app.io-cli.containment]` | the caps a fan-out runs under. Absent, a session cannot decompose anything. See [The fleet](fleet.md#the-fleet). | | `[[app.io-cli.mcp]]` | MCP servers for the turn, in io-harness's own shape. Merged with the top-level `[[mcp]]`, and wins a collision of ids. | | `[[app.io-cli.lsp]]` | language servers for this workspace. Merged with the top-level `[[lsp]]`, and wins a collision of ids. | | `[app.io-cli.browser]` | a browser the agent may drive. Never downloaded — it is one you already have. | -| `[app.io-cli.gates]` | what "done" means for this repository: one of `command` (with `expect_exit`), `file` (with `contains`), or `rubric` (with `reviewer`, and `allow_self_review` if the judge may be the model that did the work), plus `retries`, which defaults to 1 and is report-only at 0. Naming none of the three, or more than one, is refused rather than resolved by precedence. See [Verification gates](#verification-gates). | -| `[app.io-cli.routing]` | when a run should change models, and to which: `escalate_after` with `failures` and `model`, `downshift_under` with `bytes` and `model`, each a sub-table and both optional. Absent, a run asks one model from the first token to the last. **The rules do not fire under `[app.io-cli.containment]`**, which the session says at start, on `/config`, and when `/contain on` is typed. A rule that cannot be obeyed — half a rule, a threshold of zero, or an empty model — is refused by name and leaves the run unrouted. See [Which model a run asks](#which-model-a-run-asks). | -| `[app.io-cli.prices]` | where the rates in `[prices]` came from: `source_url` names a catalogue to read instead of io-harness's default, and `source` and `models` record what the last read was and how many models it priced. The last two are written by a fetch rather than by hand. See [Where a price comes from](#where-a-price-comes-from). | +| `[app.io-cli.gates]` | what "done" means for this repository: one of `command` (with `expect_exit`), `file` (with `contains`), or `rubric` (with `reviewer`, and `allow_self_review` if the judge may be the model that did the work), plus `retries`, which defaults to 1 and is report-only at 0. Naming none of the three, or more than one, is refused rather than resolved by precedence. See [Verification gates](verification.md#verification-gates). | +| `[app.io-cli.routing]` | when a run should change models, and to which: `escalate_after` with `failures` and `model`, `downshift_under` with `bytes` and `model`, each a sub-table and both optional. Absent, a run asks one model from the first token to the last. **The rules do not fire under `[app.io-cli.containment]`**, which the session says at start, on `/config`, and when `/contain on` is typed. A rule that cannot be obeyed — half a rule, a threshold of zero, or an empty model — is refused by name and leaves the run unrouted. See [Which model a run asks](providers.md#which-model-a-run-asks). | +| `[app.io-cli.prices]` | where the rates in `[prices]` came from: `source_url` names a catalogue to read instead of io-harness's default, and `source` and `models` record what the last read was and how many models it priced. The last two are written by a fetch rather than by hand. See [Where a price comes from](accounting.md#where-a-price-comes-from). | Because the section is unvalidated by design, an unrecognised *value* reads as the default rather than stopping a session from starting. A section io-harness cannot @@ -300,7 +325,7 @@ says. Two more paths arrive with the shipped skills. `~/.io-cli/skills/disabled/` holds the ones that are turned off, which is a directory rather than a setting — see -[Skills](#skills). And `~/.io-cli/.skills-manifest` is where io-cli records the +[Skills](skills.md#skills). And `~/.io-cli/.skills-manifest` is where io-cli records the bytes it last wrote for each shipped skill, so an upgrade can tell an untouched file from one you edited. It sits in the home and deliberately *not* in the skills directory, because every markdown file in there is offered to the model @@ -309,7 +334,7 @@ and a state file is not a skill. io also records in the home that it has offered to bring your setup across from another agent tool, so that offer is made once on a first run and never again however many times you start a session. Opening it deliberately is `/import` — -see [Bringing your setup across](#bringing-your-setup-across). +see [Bringing your setup across](import.md#bringing-your-setup-across). 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` @@ -408,7 +433,7 @@ browser at all. io-cli's own `[app.io-cli.browser]` is read from either scope. `NO_COLOR` is read from the environment rather than from this file, and so is the locale behind `glyphs`. See [Reading it without seeing -it](#reading-it-without-seeing-it). +it](accessibility.md#reading-it-without-seeing-it). --- diff --git a/docs/guide/git.md b/docs/guide/git.md index c55de07..1f93bc4 100644 --- a/docs/guide/git.md +++ b/docs/guide/git.md @@ -97,7 +97,7 @@ made — no git, not a repository, the boundary refusing that path — the spawn fails with the reason and **no child starts**, rather than quietly sharing the parent's tree and reintroducing the collision the switch exists to remove. `/fleet` marks the rows whose roster entry asked for one. See [The -fleet](#the-fleet). +fleet](fleet.md#the-fleet). That mark is a property of the roster entry and not a directory. io-harness records a child's actual worktree path and hands it back to nobody, so a path diff --git a/docs/guide/headless.md b/docs/guide/headless.md index 7b72ec2..97a346a 100644 --- a/docs/guide/headless.md +++ b/docs/guide/headless.md @@ -47,7 +47,7 @@ would call a truncated run a finished one. **Exit `6` is new in 0.24.0 and it is the only one that is.** It says something no other row could: the criterion you set in -[`[app.io-cli.gates]`](#verification-gates) did not pass — the tests failed, the +[`[app.io-cli.gates]`](verification.md#verification-gates) did not pass — the tests failed, the file was not written, the reviewer said no. **Two routes reach it.** The run ends the way `0` ends, of its own accord, and the gate then answers failed; or io-harness ends the run itself as `VerificationFailed`, which is a run that spent @@ -136,7 +136,7 @@ io resume 41 --goal "add a test for the parser and run it" | Flag | Does | | --- | --- | | `--list` | list the runs waiting for a person and carry none of them on | -| `--answer ` | answer the question the run stopped on | +| `--answer ` | answer the question the run stopped on — all of it, when the run stopped on several asked at once | | `--plan ` | `approve`, `revise` or `cancel` the plan the run proposed | | `--correction ` | what the plan should do differently; required by `--plan revise` and refused without it | | `--recovery ` | `retry`, `abandon` or `completed` — what happened to the call the run was interrupted in the middle of | @@ -146,6 +146,39 @@ io resume 41 --goal "add a test for the parser and run it" | `--policy ` | the posture for the rest of this run; defaults to the one the run itself recorded | | `--provider ` | `openrouter`, `anthropic` or `openai` — take the credential and model from the environment | +**A batched ask is one row, one id and one `--answer`.** An agent can ask several +things at once, and io-harness parks the whole ask as a single `pending_questions` +row answered through a single `question_id` — so the pause is still +`waiting_on: "question"` and there is no per-question flag to reach for. What +`--list` adds is a `questions` count on the row, because the one thing you cannot +see from `waiting_on` alone is how much that single answer has to cover: it is the +number of questions for a question row and `null` for the three pauses that are +not questions. + +So one text answers the lot, and the refusal that names the pause says so — +number your answers to match the questions and send them as one string: + +```sh +io resume --list --json | jq -r 'select(.questions > 1) | .run_id' +io resume 41 --answer "1. the parser that is already there 2. no, leave the CLI alone" +``` + +**The questions themselves are not on this command line.** `--list` counts them — +`3 questions` on the row, and the `questions` field in `--json` — and says so +rather than pasting the numbered ask into a one-line detail, which reads as if the +first question were the whole thing. To see them, resume the run at a terminal; +`io` draws them one at a time there. What the door cannot do is take the ask +apart: io-harness records one reply against one row, and the per-question +breakdown is written only by a responder inside the running process. + +**One limitation to know before you script against it.** A *single* question that +takes several answers loses that fact in the store: io-harness's +`PendingQuestion` has no column for it and the singular writer records none, so a +lone multi-select that parks and is resumed comes back as a pick-one. A batched +ask keeps it, because a batch carries its questions whole. This is upstream of +io-cli and is stated rather than papered over with a default that would read as a +fact. + **Each pause takes its own input, and exactly one.** clap cannot see which pause a run is on, so a flag for the wrong one is checked against the store and refused rather than ignored: `--plan approve` typed at a run holding a question is @@ -168,7 +201,7 @@ run, and it wins. **A turn you interrupted is refused here in the same words the session uses**, before a provider is built — see [When a run stops for -you](#when-a-run-stops-for-you) for why it cannot be carried on. +you](resume.md#when-a-run-stops-for-you) for why it cannot be carried on. The exit status is the table above: a resumed run that pauses again exits `4` naming the new pause, and `io resume --list` exits `0` whether or not it found @@ -205,6 +238,7 @@ io mcp probe semlith # start it and ask what it offers io mcp remove semlith io skill add ./my-skill.md +io skill add ./my-skill/SKILL.md # installed as my-skill.md, from its own name io skill list io skill remove my-skill @@ -213,7 +247,8 @@ io plugin add ultraship # or a name from a marketplace io plugin install ultraship # the same verb io plugin search review io plugin list -io plugin remove ./bundles/rust-review +io plugin remove ./bundles/rust-review # the directory +io plugin remove rust-review # or the name of a bundle you declared io plugin marketplace add zeroonething/ultraship io plugin marketplace list @@ -262,6 +297,16 @@ refuse one by name — the change goes to the file that declares the entry, and scope chosen here would aim a position counted in one file's array at another file's. +**`io plugin remove` takes a directory or a bundle's name.** The path is read +first and against the disk, so `io plugin remove ./bundles/rust-review` means what +it has always meant; only when no configuration file declares that directory is +the word matched against the names of the bundles you have declared — loaded, +switched off and failed to load alike. Two of one name are refused with both +directories printed, because taking whichever was found first would delete a +`[[plugin]]` entry you never pointed at and nothing would say so. Until 0.33.0 +only the path was read, while `plugin add` printed a line telling you to remove it +by id. + **`io plugin add ` from a marketplace declares the bundle and stops there.** It writes the entry switched off, prints the disclosure to standard error, and exits zero; switching it on is `/plugin`, in a session, where there is somebody to diff --git a/docs/guide/import.md b/docs/guide/import.md index 3774711..1131de8 100644 --- a/docs/guide/import.md +++ b/docs/guide/import.md @@ -73,7 +73,7 @@ the rest of the import still goes through. Going over io-harness's ceiling refus **every** skill instead: the harness rejects a whole directory rather than the excess, so an operator at 63 skills who imported three more would get a session in which every turn dies at run start with nothing visible to blame. See -[Skills](#skills). +[Skills](skills.md#skills). --- diff --git a/docs/guide/keys.md b/docs/guide/keys.md index 9fbe5cf..d3e5211 100644 --- a/docs/guide/keys.md +++ b/docs/guide/keys.md @@ -57,33 +57,51 @@ than waiting for it. **Two more are borrowed by `/config` from 0.28.0**, and they are left out of the table above for that same reason: it is what the session binds all the time, and these are held for as long as one list is open and handed back when it shuts. -`Right` and `Left` on -a `/config` row whose setting is a boolean or a closed set of words change it to -the next value — and the value after that, and back again — writing each one and -redrawing the row from the file's own answer rather than from an account of what -was just done. No picker does anything with a horizontal arrow — they fall -through to a do-nothing arm — and the interception is scoped to this one list, so -it takes no key away from any other surface. The composer still moves its cursor -with them and an approval still moves between its answers; neither has the -keyboard while a picker is up. - -**It is the arrows and not the spacebar, and that is a compromise rather than a -preference.** `Space` is the obvious key for a toggle and it is unavailable: a -picker treats every printable character as a fuzzy filter, so the space in a -two-word query is a keystroke the list has already claimed, and binding it would -change a setting in the middle of typing a search for a different one. `Left` and -`Right` are simply the keys the picker does not want. The cost is -discoverability: an arrow does not announce itself the way a spacebar would, and -nothing on the row says to press one. That is a real limit of the compromise, and -stating it beats leaving it to be found. What makes it a small one is that `Enter` -opens the same values as a list and does everything the arrows do and more — so a -key nobody finds costs a keystroke rather than a capability. - -A number is deliberately not cycled. Its values are a ladder rather than a pair, -too long to step through without seeing where you are, and a held arrow would -write the file once per key repeat — so `Enter` opens it instead. A key io-cli -does not know the values of says so and asks you to press `Enter`, rather than -absorbing the keystroke and looking broken. +`Right` and `Left` on a `/config` row open that setting's values as a list, with +the marker already on the value in force, so you can see where you are before you +move. No picker does anything with a horizontal arrow — they fall through to a +do-nothing arm — and the interception is scoped to this one list, so it takes no +key away from any other surface. The composer still moves its cursor with them and +an approval still moves between its answers; neither has the keyboard while a +picker is up. + +**Until 0.33.0 those two arrows wrote the file on the keystroke.** On a boolean or +a closed set of words they stepped to the next value, wrote it into a scope file +and redrew the row — one press, one write, no confirmation. It was the only +unconfirmed write in this product reachable from a bare arrow key, and it is why +`/config` could not be opened while a turn was running. The arrows now do what +`Enter` does; `Enter` on a value is the confirmation, exactly as it is on every +other managed surface, and `Up`/`Down` move inside the values while `Left`/`Right` +do nothing there. Nothing about the arrows is a shortcut past a decision any more, +which is the whole of the change: the affordance was worth keeping and the write +was not. + +**A third is borrowed by a question that takes several answers, and it is the +spacebar.** Where the agent asks something that accepts more than one choice, the +offers are drawn with a box in front of each — `[ ]` and `[x]` in both glyph sets, +because a tick is not a character every terminal has — and `Space` marks and +unmarks the offer under the marker. `Enter` sends the marked set; with nothing +marked it sends the offer you are looking at, so a reflexive `Enter` still answers +with something the agent asked rather than with nothing. + +It is borrowed and not bound, and the distinction is load-bearing. Every other +list in this product treats a printable character as a filter, and half the labels +it filters over have spaces in them — `Any OpenAI-compatible endpoint`, a +session's own workspace row — so a spacebar taken from every picker at once would +be a filter that cannot spell the rows it is filtering. Only a list that was told +it accepts several spends the key, and on that list a space typed into the +free-text row is prose again, because that row is not an offer and cannot be +marked. + +A marked offer stays marked while you narrow the list, including when the narrowing +hides it. An operator marking five rows out of four hundred narrows the list to +find each one, and a filter that un-marked as it went would throw away the marks +made under the last query. The cost is stated rather than hidden: a marked row can +be off the screen while it is marked. + +**`PgUp` and `PgDn` are borrowed when the agent asks several things at once.** +They walk the batch, one question on the screen at a time; see +[While it works](the-session.md) for what that surface does. ### Moving a key @@ -138,10 +156,13 @@ the defaults that shipped, and marks `Ctrl+C` as fixed. ## While a turn is running -**Ten commands report while the agent works.** `/status`, `/context`, `/cost`, -`/stats`, `/help`, `/theme`, `/copy`, `/expand`, `/fleet` and `/image` all answer -mid-turn, `/` opens the palette and `@` completes a path, and `/compact` and -`/steer` reach the running turn as they always have. +**Eleven commands report while the agent works**, and they are `/status`, +`/context`, `/cost`, `/stats`, `/help`, `/theme`, `/copy`, `/expand`, `/fleet`, +`/image` and `/config`. + +The palette opens mid-turn too, and path completion with it — the `/` and the `@` +in the table above. `/compact` and `/steer` reach the running turn through their +own arms, as they always have, and are not part of this set. Until 0.32.0 every slash but those last two was refused with *not while a turn is running — Ctrl+C interrupts it first*, so reading a status page meant stopping @@ -151,14 +172,21 @@ product had and withheld. **Everything else keeps that refusal, and the rule is what a command does rather than how harmless it looks.** A command that reassigns the session or the provider, writes the store or a configuration file, or submits a turn of its own -is refused: `/clear`, `/resume`, `/fork`, `/model`, `/undo`, `/setup`, `/import`, -`/profile`, `/effort`, `/contain`, `/plan`, `/gates`, `/remember`, `/commit`, -`/config`, `/plugin`, `/mcp`, `/provider`, `/skills`, `/memory`, `/store`, and a -`!` line. - -**`/config` is refused even bare**, and that is worth saying because it looks like -a report. Its picker offers a row that re-reads your provider's catalogue, writes -the prices into a scope file and reassigns the configuration the running turn is -holding. The guard is on the whole command rather than on the verb, deliberately: -splitting a reading form from a writing one is where a mistake ships a write into -a turn that is already running. +is refused: `/exit`, `/setup`, `/model`, `/resume`, `/fork`, `/commit`, +`/remember`, `/memory`, `/skills`, `/mcp`, `/provider`, `/plugin`, `/import`, +`/profile`, `/effort`, `/contain`, `/undo`, `/plan`, `/clear`, `/store`, +`/export`, `/gates`, and a `!` line. + +**`/config` joined the first list in 0.33.0, and only in its bare form.** Through +0.32.0 it was refused in every form, because the bare list carried a row that +re-read your provider's catalogue, wrote the prices into a scope file and +reassigned the configuration the running turn was holding — and because a +horizontal arrow on a row wrote the file where it stood. Neither is there now: the +refresh moved one descent below `prices.as_of`, the arrows open the values instead +of writing one, and what is left is a list of what every setting is and which file +decided it. + +So the split is no longer between a safe spelling of a command and a dangerous +one. `/config ` and `/config ` descend toward a write and keep +the refusal, which is the first time the mid-turn rule has read past a command's +first word. A trailing space is still the bare form. diff --git a/docs/guide/limits.md b/docs/guide/limits.md index eddd9b0..a5168ad 100644 --- a/docs/guide/limits.md +++ b/docs/guide/limits.md @@ -14,10 +14,10 @@ from 0.24.0**, which was the last of these to be named here and was named with i reason: it needed a surface of its own, and `/gates` is that surface. There is still no `[verify]` section, because there is none in io-harness's schema to apply — what a session carries comes from `[app.io-cli.gates]`, which is io-cli's -own. See [Verification gates](#verification-gates). **`[run.commit_identity]` is +own. See [Verification gates](verification.md#verification-gates). **`[run.commit_identity]` is *read* from 0.25.0**: it has reached a turn's contract since 0.14.0 like every other section, and until there was a commit to author it was a value nothing in -this interface had cause to look at. See [Git](#git). +this interface had cause to look at. See [Git](git.md#git). That leaves one key still unapplied, and it has a reason too: `run.templates` is the thirteenth `[run]` key, reachable only through its own accessor. It is not a @@ -26,7 +26,7 @@ silent omission — this is where it is named. **A price is never invented, and a missing one is never a zero.** io-cli compiles no rates in and estimates nothing, so an install that has connected no provider reports tokens and no currency, and a total containing a model the table does not -price is reported as a floor. See [What it costs](#what-it-costs). +price is reported as a floor. See [What it costs](accounting.md#what-it-costs). **`/import` does not bring a permission boundary across, and never will.** Another tool spells a permission as a command line; io-harness matches a binary name. The @@ -34,7 +34,7 @@ nearest honest translation is wider than what you granted, so io reports the allowlist it found and writes no rule at all. Setting the boundary here is `Shift+Tab`, `/config`, or the `io-permissions` skill — three surfaces where you can see what you are granting. See [Bringing your setup -across](#bringing-your-setup-across). +across](import.md#bringing-your-setup-across). **Git stops at your own checkout.** The seven built-ins the agent has are fixed argvs with no remote among them, so nothing here pushes, fetches or opens a pull @@ -43,7 +43,7 @@ deletes a branch either — both throw away work, and both are yours to decide. io-cli starts no git process of its own: the branch on the status line is a read of `.git/HEAD`, and `/commit` is a prompt. What lifts the tools when your posture refuses them is a permission rule, not a code path that runs git behind the -policy. See [Git](#git). +policy. See [Git](git.md#git). **Sixel is still absent**, because encoding it means palette quantisation and another dependency, for terminals that either speak one of the two protocols @@ -76,7 +76,7 @@ a model that accepts them.** six of them write. A model that reaches for `docx_write` in a session where you never meant a document to be written is stopped by the write gate rather than by the absence of the tool — which is what the gate is for, and why the writers are -named one by one in [Documents](#documents) rather than counted. +named one by one in [Documents](media.md#documents) rather than counted. **An image the agent was *given* rather than asked for is not shown.** A picture returned by an MCP tool, and a browser screenshot, both become images inside diff --git a/docs/guide/plugins.md b/docs/guide/plugins.md index 029e2aa..f9b7f51 100644 --- a/docs/guide/plugins.md +++ b/docs/guide/plugins.md @@ -51,6 +51,25 @@ the two lists have no relation to the order entries appear in any file, and a ro number read against the wrong list removes a bundle you never mentioned. Where no file names the directory, io says so and removes nothing. +**From a shell it is `io plugin remove`, and it takes a directory or a name.** +`io plugin add` has always printed *`plugin remove ` takes it back out* and +the verb read its argument as a path only, so the sentence was false on the door +that printed it. The path is still read first and against the disk, so +`io plugin remove ./bundles/rust-review` means exactly what it always meant, and a +directory that is declared is always removed as one. Only when no configuration +file declares that directory is the word read as the name of a bundle you have +declared — across the loaded ones, the ones switched off and the ones that failed +to load, which are the entries you most want gone, since a bundle whose manifest +will not parse is one you cannot fix from the manifest. + +**Two bundles of one name are refused, with both directories named.** A bundle's +id is unique among the ones io-harness actually *loaded*; two declared with +`enabled = false` may share one, which is the whole point of that flag. Taking +whichever was found first would delete a `[[plugin]]` entry you never pointed at, +silently, and you would find out when a bundle's skills stopped being offered. So +io says how many carry the name and prints each one's directory, which is the +spelling that tells them apart and the spelling the path reading above resolves. + The directory itself is never touched. This surface edits a configuration file, and deleting somebody's work because they stopped loading it is not a thing a list should do. Declaring a bundle is still a line you write yourself: a path is diff --git a/docs/guide/skills.md b/docs/guide/skills.md index aba253e..5d56248 100644 --- a/docs/guide/skills.md +++ b/docs/guide/skills.md @@ -38,6 +38,27 @@ there is no undo, and a file whose `name:` is already claimed by another skill, because two names resolving to one skill is the fatal case described below. A bundle's skill is not yours to add or remove, and says so. +**`io skill add ./my-skill/SKILL.md` works, and until 0.33.0 it did not.** The +commonest layout on disk is a directory holding a `SKILL.md`, and the installed +file used to be named after the source's *file name* — so that add wrote +`~/.io-cli/skills/SKILL.md`, a shape io-cli then read as a folder skill and +refused to remove or disable forever, with a sentence about a directory that did +not exist. The installed file is now named from the skill's **own** name: the +frontmatter `name:` where there is one, the containing directory for a `SKILL.md`, +the file stem otherwise. `io skill add ./my-skill/SKILL.md` installs `my-skill.md` +and `io skill remove my-skill` takes it back out, and the name every check asks +about is the name a run will resolve rather than the word `SKILL`. + +**A skill `/import` wrote as a folder is manageable too, and it never was.** +`/import` could write a skill into `~/.io-cli/skills//SKILL.md`, and neither +lever would touch that shape — so the product shipped a verb that created state +its own management surface refused to manage. Both work on it now: removing takes +the folder, and turning it off moves the whole `/` into `disabled/` rather +than the file inside it. A loose `SKILL.md` sitting directly in the skills +directory is still refused by the off switch, and deliberately: the move keeps the +name, so it would land as `disabled/SKILL.md` and be re-offered as a skill called +*disabled*, taking every other parked skill's hiding place with it. + Until 0.30.0 the only thing that had ever written into that directory was `/import`, following a tool io happened to detect. If you had written a skill yourself, there was no door. diff --git a/docs/guide/the-session.md b/docs/guide/the-session.md index 671d5b9..b6b413a 100644 --- a/docs/guide/the-session.md +++ b/docs/guide/the-session.md @@ -42,7 +42,7 @@ computed in: the tokens are what happened and the money is what they came to. It is **absent** — not `$0` — where there is no price table, where the table prices none of the models this run called, or where nothing has run yet. Those three things are different and none of them means free; `/cost` is where they are told -apart. See [What it costs](#what-it-costs). +apart. See [What it costs](accounting.md#what-it-costs). **Two things about that line were corrected in the same release, both of them priority inversions.** The footer used to drop its whole right-hand group — the @@ -271,3 +271,81 @@ It now moves, as an **estimate** taken from the streamed text, and it is written with a leading tilde — `~1.2k tok` — so it can never be read as settled. When the step commits, the provider's own number replaces it and the tilde goes. The two are never added together. + +## The agent asks everything in one surface + +When the agent needs to know something, the offers and a row that takes your own +words are one list. Move to an offer and `Enter` sends it verbatim; move to the +last row and a composer unfolds under it and takes what you type. There is no key +that moves between two panes, because there are not two panes. + +**An agent can ask several things at once, and they arrive as one overlay.** One +question is on the screen at a time — its question line, its context, its offers, +its free-text row: exactly the surface a single question has, so answering one +does not change because four others exist. Deciding it moves to the next question +that has not been decided, and deciding the last one sends the whole batch. Two +lines of the head say where you are: which question of how many, and what this one +was already decided as if you have come back to it. + +**`PgUp` and `PgDn` walk the batch**, in either direction, at any point before it +is sent. A question you have already decided re-opens with your own answer back in +the composer, so changing your mind is retyping or re-sending your own words +rather than a second kind of screen with a second set of keys. There is no review +pane and no submit key: the answers are already on the screen you typed them into, +one page-key apart, and a second rendering of them is a second thing that can +disagree with the first. + +**Nothing is sent until every question is decided.** io-harness commits a batch +only when every entry has an answer, so four out of five parks the run exactly as +thoroughly as none. Declining is a decision — `Esc` decides the question on the +screen as *nobody here can answer this* and moves on, and the run parks, which is +the only thing `Esc` has ever promised. + +A batch of one is a batch of one. The overlay carries no batch chrome, `PgUp` and +`PgDn` do nothing, and every key does what it did before. + +### An offer can say more than its label + +A choice may carry a **description** — one sentence saying what taking it means — +and a **preview** — a short block showing what taking it would do. They are drawn +differently because they are different things. + +A description is always on the screen, on a row of its own under the label it +explains, because someone comparing five offers needs all five sentences at once. +A preview is a block, and five blocks at once is a wall nobody reads — so it +unfolds beneath the offer under the marker, one at a time, and folds again when +the marker moves. It is marked as quoted words, the same way this product draws a +model's own blockquote, because a preview is somebody else's text and not io's. +On an offer carrying both, the open preview sits between the label and its +description. + +`Enter` on an offer whose preview is open still answers with that offer. The +question is which row holds the marker, never whether anything is unfolded. + +io-harness bounds a preview before it sends one and io-cli does not restate those +bounds as its own: what arrives is drawn. What is bounded here is the drawing — +the block asks for the rows it wraps to, and the viewport clamps that to what your +terminal can spare. + +### A question that takes several answers + +Where a question accepts more than one, each offer is drawn with a box in front of +it and `Space` marks and unmarks the one under the marker. `Enter` sends the +marked set; with nothing marked it sends the offer you are looking at, because an +empty answer is not an answer — it is information the agent did not have and would +now believe. What crosses the wire is io-harness's own spelling of a multiple +answer, not a joiner written here, so two interfaces answering one question +produce the same text. + +The box is drawn on every offer from the moment the list opens, marked or not. A +column that appeared only once something was marked would be a list that shifts +sideways as you use it, and — worse — a question that takes several would look +exactly like one that takes one until you had already pressed `Enter`. + +**A known limitation, and it is upstream.** io-harness's store has no column for +"this question takes several answers" on a *singular* ask, and the singular writer +records none — so a single multi-select question that parks and is resumed later +comes back as a pick-one. A batched ask keeps it, because a batch carries its +questions whole. Nothing io-cli can read recovers the flag for the singular case, +and defaulting it either way would be io-cli stating as fact something no longer +on disk. diff --git a/docs/guide/verification.md b/docs/guide/verification.md index 9e3de01..23e8c1e 100644 --- a/docs/guide/verification.md +++ b/docs/guide/verification.md @@ -95,7 +95,7 @@ carries one criterion on the contract and reports the verdict io-harness came back with. `io exec` gains exit `6` for this — the agent finished and the work does not hold -up. See [Exit status](#exit-status). +up. See [Exit status](headless.md#exit-status). --- diff --git a/src/adapt.rs b/src/adapt.rs index 2091b20..1a0e6ae 100644 --- a/src/adapt.rs +++ b/src/adapt.rs @@ -64,7 +64,7 @@ //! instead, and every path inside it is **absolute** and points back into the //! clone. That is not a convenience: //! [`Plugin::skills_dir`](io_harness::Plugin::skills_dir) is `self.root.join(d)` -//! (`io-harness-0.71.0/src/plugin.rs:268`), and `Path::join` of an absolute +//! (`io-harness-0.72.0/src/plugin.rs:268`), and `Path::join` of an absolute //! argument discards the base — so an absolute `skills` is the one spelling that //! reaches a directory outside the manifest's own root. //! @@ -367,7 +367,7 @@ pub const SKILLS_DIR: &str = "skills"; /// io-harness calls the same thing a **template**, and the two formats are the /// same file: markdown, optional frontmatter, `$ARGUMENTS` for what the operator /// typed. `Templates::discover` takes every `*.md` in a directory -/// (`io-harness-0.71.0/src/template.rs:279`), so what is translated is the +/// (`io-harness-0.72.0/src/template.rs:279`), so what is translated is the /// directory and never the files in it — nothing is copied and nothing is /// rewritten. pub const COMMANDS_DIR: &str = "commands"; @@ -437,7 +437,7 @@ pub fn at(root: &Path, owner: &str, repo: &str, name: &str) -> PathBuf { /// `agents/*.md` becomes an `[[agent]]`, and each server in `.mcp.json` becomes an /// `[[mcp]]`. Every path written is **absolute** and points into the clone: /// `Plugin::skills_dir` is `self.root.join(d)` -/// (`io-harness-0.71.0/src/plugin.rs:268`) and `Path::join` of an absolute +/// (`io-harness-0.72.0/src/plugin.rs:268`) and `Path::join` of an absolute /// argument discards the base, so an absolute path is what lets a manifest in /// io's home name a directory in somebody else's checkout. /// @@ -543,7 +543,7 @@ pub fn generate(bundle: &Path, name: &str, into: &Path) -> Result Option { /// adapted, so expanding it reads nothing of the host — it writes down a path io /// already knew. `${env:…}`, `${file:…}`, `${cmd:…}` and every shell variable a /// bundle writes are the opposite: io-harness refuses each of them inside a -/// manifest in every scope (`io-harness-0.71.0/src/plugin.rs:774`), because a +/// manifest in every scope (`io-harness-0.72.0/src/plugin.rs:774`), because a /// bundle is a third party's directory even when the file naming it is the /// operator's own. /// @@ -921,7 +921,7 @@ fn unquoted(said: &str) -> &str { /// /// **A transport io-harness does not speak is a refusal naming the server**, not /// a server quietly left out. io-harness speaks stdio and streamable HTTP -/// (`io-harness-0.71.0/src/mcp.rs:289`), so a bundle declaring `sse` is declaring +/// (`io-harness-0.72.0/src/mcp.rs:289`), so a bundle declaring `sse` is declaring /// a capability no adapter can deliver — and a manifest carrying one fewer server /// than the bundle does is exactly the silent absence this module's docs exist to /// end. The whole generation stops, because a bundle is installed for what it diff --git a/src/app.rs b/src/app.rs index c7db217..b187476 100644 --- a/src/app.rs +++ b/src/app.rs @@ -192,6 +192,23 @@ pub struct App { /// question with prose — and because only one of them authorizes anything. /// Both are modal, and [`App::modal`] is the one place that knows it. intent: Option, + /// Deliveries that arrived while one was already on screen. + /// + /// **The defect this closes (0.33.0):** [`Self::open_intent`] assigned + /// `intent` unconditionally, so a second question arriving mid-answer replaced + /// the first and dropped its reply channel — and a dropped channel is `None`, + /// which io-harness reads as *pause the run and keep the question*. The first + /// question became a silent pause nobody was ever shown. The mpsc has always + /// carried N; only this field assumed one. + /// + /// **Invariant: empty whenever `intent` is `None`.** A delivery is held only + /// because something is on screen, and the moment the screen frees up the next + /// one takes it — otherwise a run would sit waiting on an overlay that nothing + /// will ever open. Held here rather than left in the channel because the + /// driver's select arm must be allowed to keep draining: a question left + /// unreceived is indistinguishable, from the far end, from one nobody will + /// answer. + held: std::collections::VecDeque, /// The plan on screen, if one is waiting to be decided. /// /// While it is up io-harness's own policy denies every write and every exec @@ -362,6 +379,7 @@ impl App { pending: Vec::new(), approval: None, intent: None, + held: std::collections::VecDeque::new(), plan: None, remembered: Vec::new(), root: std::path::PathBuf::new(), @@ -428,10 +446,43 @@ impl App { /// /// Committed nowhere, for the same reason an approval is not: a question in /// the scrollback can be scrolled away from a run that is blocked on it. The - /// transcript gets `QuestionAsked` from the event stream, which is the note + /// transcript gets `QuestionsAsked` from the event stream, which is the note /// that the run stopped rather than the question itself. - pub fn open_intent(&mut self, asked: crate::intent::Asked) { - self.intent = Some(crate::intent::Intent::new(asked)); + /// + /// **A delivery arriving while one is on screen is HELD, not dropped** (F3, + /// 0.33.0). Until this release the assignment was unconditional, so the second + /// question replaced the first and took its reply channel with it — see + /// [`Self::held`] for what that did to the run behind it. Nothing about the + /// second question is decided here beyond *later*: it opens by itself the + /// moment [`Self::answer_intent`] frees the screen. + /// + /// **This is the whole of the driver's select arm.** Handing the guard to the + /// library rather than writing it beside the `recv` is not tidiness: nothing + /// under `tests/` links the driver, so a guard written there is a decision no + /// test can assert and no sabotage can falsify — which is exactly how this + /// defect survived three releases. + /// + /// Takes anything a delivery can be made from, so both the channel's own + /// [`crate::intent::Questions`] and a bare [`crate::intent::Asked`] are + /// accepted without a second entry point. + pub fn open_intent(&mut self, questions: impl Into) { + let questions = questions.into(); + if self.intent.is_some() { + self.held.push_back(questions); + return; + } + self.intent = Some(crate::intent::Intent::new(questions)); + } + + /// Put the next held delivery on screen, if the screen is free and there is + /// one. The one place [`Self::held`]'s invariant is restored. + fn open_held(&mut self) { + if self.intent.is_some() { + return; + } + if let Some(next) = self.held.pop_front() { + self.intent = Some(crate::intent::Intent::new(next)); + } } /// A question a *previous* process left in the store, opened to be answered @@ -448,40 +499,64 @@ impl App { self.intent = Some(intent); } - /// Answer the open question, or decline it with `None`. + /// Answer the open delivery — one entry per question it carried, in the order + /// it carried them, `None` for each one declined. /// /// Declining is not a refusal: io-harness persists the question and pauses /// the run, so the answer can arrive after this process has exited. What is /// said in the scrollback says which of the two happened. /// + /// **A batch with one `None` in it parks, and the scrollback has to say so** + /// (F2). The harness commits a batch only when every entry is `Some`, so four + /// answers out of five stop the run exactly as thoroughly as none of them + /// would — and a transcript showing four `answered` lines and nothing else + /// would describe a turn that carried on. The closing line is that correction, + /// and it is committed only when there is something to correct. + /// /// **Returns what nobody took.** A live question's answer travels down the /// channel its run is blocked on and this is `None`; a question opened from /// the store has no such channel, so the answer comes back here and the /// caller must hand it to [`crate::resume`]. Discarding it there would drop /// the operator's answer in silence, which is why it is returned rather than - /// swallowed as it was before 0.23.0. + /// swallowed as it was before 0.23.0. Still a single `Option` because only the + /// stored path can produce one, and a stored overlay answers **one row** — + /// not, since 0.33.0, one question. A batch parks as a single + /// `PendingQuestion` with one `question_id`, and io-harness resumes it with one + /// compare-and-swap, so several answers are assembled into the one text that + /// row takes. #[must_use = "a resumed question's answer is delivered by the caller, not by the overlay"] - pub fn answer_intent(&mut self, answer: Option) -> Option> { + pub fn answer_intent(&mut self, answers: Vec>) -> Option> { let intent = self.intent.take()?; - match &answer { - Some(text) => self.record( - Tone::Muted, - format!("answered {} {text}", self.theme.glyphs.dash), - ), - // **Declining is a real answer, so it is not a warning.** io-harness - // documents `None` as "nobody here can answer this": the question is - // persisted and the run pauses, resumable. Nothing has gone wrong and - // nothing was refused — the operator chose the option the surface - // offers them, and `Tone::Warning`'s own word is `warning`. - None => self.record( + let dash = self.theme.glyphs.dash; + for answer in &answers { + match answer { + Some(text) => self.record(Tone::Muted, format!("answered {dash} {text}")), + // **Declining is a real answer, so it is not a warning.** + // io-harness documents `None` as "nobody here can answer this": + // the question is persisted and the run pauses, resumable. Nothing + // has gone wrong and nothing was refused — the operator chose the + // option the surface offers them, and `Tone::Warning`'s own word + // is `warning`. + None => self.record( + Tone::Muted, + format!("left unanswered {dash} the run pauses and keeps the question"), + ), + } + } + if answers.len() > 1 && answers.iter().any(Option::is_none) { + self.record( Tone::Muted, format!( - "left unanswered {} the run pauses and keeps the question", - self.theme.glyphs.dash + "the run pauses and keeps all {} {dash} a batch is committed only when every \ + question is answered", + answers.len() ), - ), + ); } - intent.resolve(answer) + let undelivered = intent.resolve(answers); + // The screen is free, so whatever arrived while it was not takes it. + self.open_held(); + undelivered } /// A plan was proposed and nothing has been done about it yet. @@ -554,6 +629,13 @@ impl App { // opens two today, and this must not be the line that makes that // assumption load-bearing. let had_intent = self.intent.take().is_some(); + // **And everything held behind it.** Leaving a parked overlay is "get me + // out of this", and a held delivery that opened in its place would be the + // opposite of what the key was pressed for. Dropping the deliveries drops + // their reply channels, which is `None` — the run those questions belong + // to parks with them persisted, which is where a leave leaves things + // anyway. + self.held.clear(); let had_plan = self.plan.take().is_some(); if had_intent || had_plan { self.record( @@ -1128,6 +1210,11 @@ impl App { // pauses a run rather than the one that denies it — and this run has // already stopped, so the pause costs nothing that was still moving. self.intent = None; + // Everything held behind it goes with it, and for the same reason: the run + // that asked those questions is the run that has just ended, so there is + // nobody left to answer to. The senders drop, which is `None`, which is + // the pause — and this run has already stopped. + self.held.clear(); // And for a plan, where `None` is the safe direction twice over: the run // is over, and a plan nobody decided is a plan nothing acted on. self.plan = None; @@ -1795,12 +1882,15 @@ impl App { // key is the operator typing prose, which is why this arm is below the // approval's and above everything else. if let Some(open) = self.intent.as_mut().filter(|_| !interrupting) { - if let Some(answer) = open.key(key) { + // `Some` only once every question of the delivery has been decided — + // the overlay walks a batch itself, so this arm cannot tell a batch + // from a single question and does not need to. + if let Some(answers) = open.key(key) { // Handed back rather than dropped when the question came from the // store: `answer_intent` returns whatever no run was waiting for, // and the one thing that must never happen to an operator's // answer is that it goes nowhere quietly. - if let Some(undelivered) = self.answer_intent(answer) { + if let Some(undelivered) = self.answer_intent(answers) { return Command::Answered(undelivered); } } diff --git a/src/commands.rs b/src/commands.rs index fac0e9b..fc51db6 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1431,7 +1431,7 @@ pub const READ_MARK: &str = "+"; /// 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 -/// (`read_instructions`, `io-harness-0.71.0/src/config.rs:2111`). +/// (`read_instructions`, `io-harness-0.72.0/src/config.rs:2111`). pub const UNREAD_MARK: &str = "-"; /// What one row of the memory page stands for. @@ -1647,7 +1647,7 @@ pub fn memory_notes(view: &crate::recall::View, glyphs: &crate::glyphs::Glyphs) /// 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 -/// (`DEFAULT_INSTRUCTIONS`, `io-harness-0.71.0/src/config.rs:159`, read at +/// (`DEFAULT_INSTRUCTIONS`, `io-harness-0.72.0/src/config.rs:159`, read at /// `:2120`) and there is nothing on any /// other surface to pull on. pub fn instruction_said( @@ -2578,26 +2578,56 @@ fn table>(rows: &[(S, S)], width: usize, theme: &Theme) -> Vec` and +/// `/config ` keep their refusal, which is the first time +/// [`runs_mid_turn`] has had a reason to read past the first word. /// /// `/copy diff` is `/copy`'s second word rather than a command of its own here, so /// the first word decides and both forms are admitted; both only read. pub const MID_TURN: &[&str] = &[ - "/status", "/context", "/cost", "/stats", "/help", "/theme", "/copy", "/expand", "/fleet", + "/status", + "/context", + "/cost", + "/stats", + "/help", + "/theme", + "/copy", + "/expand", + "/fleet", "/image", + "/config", + "/settings", ]; +/// The commands admitted mid-turn only when nothing follows the word. +/// +/// The mechanism exists for `/config` rather than the other way round: bare it +/// opens a list that reports, while `/config ` descends toward a write and +/// `/config ` is one. A command here is admitted by [`MID_TURN`] and +/// then refused again by [`runs_mid_turn`] the moment it carries an argument. +/// +/// **`/settings` is here because [`parse`] treats it as the same command.** Both +/// spellings reach the same three arms, so admitting one and refusing the other +/// would make a command's availability depend on which of its two names an +/// operator typed. Adding it to [`MID_TURN`] alone would be worse than leaving it +/// out: the bare-only refusal keys on the name, so `/settings ` would +/// have written into a running turn — the one thing this whole split exists to +/// stop. +pub const BARE_ONLY_MID_TURN: &[&str] = &["/config", "/settings"]; + /// Whether `word` is the first word of a command in [`COMMANDS`]. /// /// **The precedence rule for a skill invocation, and it points the safe way.** A @@ -2619,17 +2649,25 @@ pub fn names_a_command(word: &str) -> bool { /// Whether this typed line may run while a turn is in flight. /// -/// Takes the whole line rather than a name, so a command whose admission ever -/// depends on its arguments has somewhere to say so. None does today — -/// `MID_TURN`'s doc records why `/config` is refused outright — and the first word -/// decides. `line` is what `Command::Slash` carries: the leading slash already -/// stripped, and trimmed. +/// Takes the whole line rather than a name, so a command whose admission depends +/// on its arguments has somewhere to say so. Since 0.33.0 one does: `/config` +/// reports in its bare form and writes in its argued ones, so it is in +/// [`MID_TURN`] and in [`BARE_ONLY_MID_TURN`], and a word after it puts the +/// refusal back. `line` is what `Command::Slash` carries: the leading slash +/// already stripped, and trimmed. pub fn runs_mid_turn(line: &str) -> bool { let mut words = line.split_whitespace(); let Some(first) = words.next() else { return false; }; - if !MID_TURN.contains(&format!("/{first}").as_str()) { + let name = format!("/{first}"); + if !MID_TURN.contains(&name.as_str()) { + return false; + } + // **`next()`, not a length check on the line.** Trailing whitespace is not an + // argument, and `split_whitespace` has already discarded it — so `/config ` + // is the bare form and `/config prices.as_of` is not. + if BARE_ONLY_MID_TURN.contains(&name.as_str()) && words.next().is_some() { return false; } true @@ -2663,6 +2701,7 @@ mod mid_turn_tests { "/context", "/copy", "/copy diff", + "/config", "/fleet", "/image", "/cost", @@ -2689,7 +2728,6 @@ mod mid_turn_tests { "/steer", "/compact", "/commit", - "/config", "/remember", "/memory", "/skills", @@ -2717,21 +2755,65 @@ mod mid_turn_tests { ); } - /// **`/config` is refused in every form**, including the bare one the release - /// contract originally admitted. Its picker carries a row that writes a scope - /// file and reassigns the running turn's `Config`, and a filter that removed - /// just that row would be the bare-from-argued split this product decided not - /// to make. Asserted in all four shapes so the decision cannot be undone by - /// half. + /// **`/config` reports bare and is refused argued** — the 0.33.0 shape, and + /// the discharge of `US-IO-CLI-0.32.0-I11`. + /// + /// What changed is not the guard but the surface under it: the price refresh + /// left the bare picker for a descent below `prices.as_of`, so the bare list + /// has no row that acts and there is nothing left to filter out. The refusal + /// that remains is about arguments, which genuinely do write. + /// + /// Trailing whitespace is the bare form. It is asserted because + /// `split_whitespace` is what makes that true, and a future rewrite reaching + /// for `line.contains(' ')` would refuse `/config ` while admitting nothing + /// new — a regression no other assertion here would catch. #[test] - fn config_is_refused_in_every_form_including_the_bare_one() { - assert!(!runs_mid_turn("config")); - assert!(!runs_mid_turn("config ")); + fn config_reports_bare_mid_turn_and_is_refused_the_moment_it_carries_a_word() { + assert!(runs_mid_turn("config")); + assert!(runs_mid_turn("config ")); assert!(!runs_mid_turn("config list")); assert!(!runs_mid_turn("config get run.max_steps")); assert!(!runs_mid_turn("config run.max_steps 40")); } + /// The bare-only rule reaches exactly one command, and every other admitted + /// command keeps its arguments. + /// + /// Without this, moving a second command into `BARE_ONLY_MID_TURN` — or + /// dropping the `BARE_ONLY_MID_TURN` check for a blanket "no arguments + /// mid-turn" — would pass every other assertion in this file. `/copy diff` is + /// the one that proves it: it is an admitted command whose second word is part + /// of the command. + #[test] + fn the_bare_only_rule_reaches_config_alone() { + assert_eq!(BARE_ONLY_MID_TURN, &["/config", "/settings"]); + // Both spellings of one command, held to one answer. `parse` reads + // `"config" | "settings"` in every arm, so a session that admitted one and + // refused the other would make availability depend on which name was + // typed — and admitting `/settings` without the bare-only rule would let + // `/settings ` write into a running turn. + for spelling in ["config", "settings"] { + assert!(runs_mid_turn(spelling)); + assert!(runs_mid_turn(&format!("{spelling} "))); + assert!(!runs_mid_turn(&format!("{spelling} list"))); + assert!(!runs_mid_turn(&format!("{spelling} run.max_steps 40"))); + } + assert!(runs_mid_turn("copy diff")); + assert!(runs_mid_turn("theme dark")); + assert!(runs_mid_turn("expand 3")); + for name in MID_TURN { + if BARE_ONLY_MID_TURN.contains(name) { + continue; + } + let argued = format!("{} something", name.trim_start_matches('/')); + assert!( + runs_mid_turn(&argued), + "{name} is admitted bare but refused with an argument, and it is \ + not in BARE_ONLY_MID_TURN", + ); + } + } + #[test] fn a_word_that_is_not_a_command_is_refused() { assert!(!runs_mid_turn("")); diff --git a/src/configure.rs b/src/configure.rs index 73a6247..ce39584 100644 --- a/src/configure.rs +++ b/src/configure.rs @@ -244,7 +244,10 @@ pub enum Kind { /// /// One key, `prices.as_of`. It is in the catalogue because a date an operator /// cannot see is a claim with no expiry they cannot check — but it is a fact to - /// read, and the act beside it is [`REFRESH_PRICES`]. + /// read, and the act beside it is [`REFRESH_PRICES`], one descent below the row + /// through [`descent`]. A `Machine` key still has no value to type, which is + /// why `value_rows` answers `None` for it and `manage::config_value` refuses it + /// by name; the descent offers the act, never the key. Machine, } @@ -252,7 +255,7 @@ pub enum Kind { /// /// **Both halves are the dependency's since io-harness 0.71.0, and neither is /// written here any more**: the list is `Effect::ALL` -/// (`io-harness-0.71.0/src/policy.rs:127`) and each spelling is `Effect::as_str` +/// (`io-harness-0.72.0/src/policy.rs:127`) and each spelling is `Effect::as_str` /// (`:143`), which is the word io-harness's own deserializer reads. /// /// Until this release io-cli held a copy of both — an array naming three variants @@ -279,7 +282,7 @@ pub fn effects() -> Vec { /// The `ExecMode` variants, spelled by io-harness itself. /// -/// **The list is `ExecMode::ALL` (`io-harness-0.71.0/src/sandbox.rs:424`) and the +/// **The list is `ExecMode::ALL` (`io-harness-0.72.0/src/sandbox.rs:424`) and the /// spellings are `ExecMode::as_str` (`:431`).** io-cli wrote the variant list out /// by hand until this release for a reason that was the dependency's and not a /// choice made here: `ExecMode` is `#[non_exhaustive]` (`sandbox.rs:378-381`), and @@ -383,7 +386,7 @@ pub fn kind_of(key: &str) -> Option { /// preference — but half of the old reason is now false and the correction is /// worth writing down.** io-harness 0.71.0 names its own defaults: /// `DEFAULT_MAX_STEPS` = 8, `DEFAULT_WORKSPACE_MAX_STEPS` = 12 and -/// `DEFAULT_MAX_RETRIES` = 2 (`io-harness-0.71.0/src/contract.rs:652,670,686`), +/// `DEFAULT_MAX_RETRIES` = 2 (`io-harness-0.72.0/src/contract.rs:652,670,686`), /// re-exported at the crate root. "There is nothing to read" was true when this /// was written and is not true now. What is still true is that none of it anchors /// *this* ladder: @@ -468,52 +471,6 @@ pub fn ladder(current: Option, signed: bool) -> Vec { ordered.into_iter().map(|(_, rung)| rung).collect() } -/// The next value along, for a kind that can be cycled where it stands. -/// -/// **Booleans and closed enums only, and a number deliberately not.** A number's -/// ladder cannot be shown on the row it is being changed on, and a held arrow -/// would write the file once per key repeat; it descends into a value picker -/// instead. So this answers `None` for every other kind, and the caller's arrow -/// does nothing rather than doing something invisible. -/// -/// **The keys are horizontal arrows because they are not printable.** -/// `crate::picker::Picker` consumes every printable character as a fuzzy filter -/// (`src/picker.rs:423-430`), so the obvious binding — Space — cannot be used: a -/// two-word query contains one, and binding it would toggle a setting in the -/// middle of typing a search. `Left` and `Right` fall to the picker's `_ => -/// Outcome::Idle` arm and are the only free keys that read as "the value beside -/// this one". -/// -/// `current` is the value in force as the file spells it, or `None` for a key no -/// file names. An unset boolean cycles to `true` first, because the operator -/// reached for the arrow in order to change something. -#[must_use] -pub fn cycled(kind: &Kind, current: Option<&str>, forward: bool) -> Option { - let options: Vec = match kind { - Kind::Flag => vec!["false".to_string(), "true".to_string()], - Kind::Choice(options) => options.clone(), - _ => return None, - }; - let bare = current.map(|value| value.trim().trim_matches('"')); - let at = bare.and_then(|value| options.iter().position(|option| option == value)); - let next = match at { - // Wrapping, because a closed set has no end to fall off: an operator - // arrowing past the last option means the first one. - Some(at) if forward => (at + 1) % options.len(), - Some(at) => (at + options.len() - 1) % options.len(), - // A key no file names, or one holding a value this build does not know. - // Either way there is no position to step from, so the two directions - // enter the list at its two ends — forward at the last option, backward at - // the first. For a boolean that means the first press of `Right` turns it - // on, which is what an operator reaching for the arrow on an unset flag - // meant; for a closed enum it is simply symmetric, and one more press - // reaches everything either way. - None if forward => options.len() - 1, - None => 0, - }; - options.get(next).cloned() -} - /// A value as TOML spells it for that kind. /// /// **The serialized value, never the label**, which is the difference F3's own @@ -562,8 +519,8 @@ pub fn shape_of(key: &str, config: &Config) -> Option { "a URL returning a model catalogue — for example: https://openrouter.ai/api/v1/models" } "prices.as_of" => { - "written by the price refresh rather than typed; use the last row of `/config` to \ - re-read the catalogue" + "written by the price refresh rather than typed; choose it on `/config` and the \ + refresh that re-reads the catalogue is the row after `leave it`" } // A key the catalogue does not name. Its shape is the operator's own // business — io-cli has no schema for it and inventing one here would be @@ -580,7 +537,7 @@ pub fn shape_of(key: &str, config: &Config) -> Option { /// The models `[prices.models]` names, across every scope, sorted and deduplicated. /// /// **Read from the dependency's own table since io-harness 0.71.0, not scraped -/// out of the files.** `PriceTable::models` (`io-harness-0.71.0/src/pricing.rs:268`) +/// out of the files.** `PriceTable::models` (`io-harness-0.72.0/src/pricing.rs:268`) /// lists every model the table can actually price, and [`Config::prices`] has /// always built that table out of the three scopes — so the merged question this /// used to hand-roll is precisely the one the accessor answers, and the gap filed @@ -606,7 +563,7 @@ pub fn shape_of(key: &str, config: &Config) -> Option { /// /// **This takes the `Config` the caller already holds, and must never re-discover /// one.** `Config::discover` resolves every `${env:}`, `${file:}` and `${cmd:}` as -/// it reads (`io-harness-0.71.0/src/config.rs:517`), so a second discovery re-runs +/// it reads (`io-harness-0.72.0/src/config.rs:517`), so a second discovery re-runs /// an operator's credential commands — which for a `${cmd:}` fetching a key out of /// a keychain means a Touch-ID prompt raised in order to draw a menu, every time /// the picker opens. Taking a `&Config` is not an optimisation; it is the @@ -646,7 +603,7 @@ pub fn destination(config: &Config, key: &str) -> (Scope, bool) { /// Whether writing `value` to `key` would be refused in a **project-scoped** file. /// /// io-harness refuses five (key, value) pairs in a committed `io.toml` -/// (`PROJECT_WIDENING`, `io-harness-0.71.0/src/config.rs:1998-2008`): the two acts +/// (`PROJECT_WIDENING`, `io-harness-0.72.0/src/config.rs:1998-2008`): the two acts /// defaulted to `allow`, egress re-opened inside the sandbox, the portable floor /// switched off, and the widest exec mode. The narrowing value of each stays legal, /// which is what the scope is for. @@ -684,11 +641,38 @@ pub fn widens_project(key: &str, value: &str) -> bool { /// a key that is not in any file and never will be would show as "not set" /// forever on a surface whose whole job is saying what is in force. /// +/// **It is not a row of the bare list, since 0.33.0.** Through 0.32.0 it was +/// appended after the settings, and the bare `/config` therefore *was* a write and +/// a reassignment of the running configuration — which is the whole of why the +/// command was refused mid-turn (`crate::commands::MID_TURN`, and +/// `US-IO-CLI-0.32.0-I11` with it). Moving it one descent below `prices.as_of`, +/// where [`descent`] hands it out, is what makes the bare list a list of facts and +/// nothing else; the act is still one keystroke further on, beside the very date it +/// writes. +/// /// It lives here rather than in the driver so a test can reach it: nothing under /// `tests/` can link `src/main.rs`, and a row spelled in the driver is a row no /// test can assert on. pub const REFRESH_PRICES: &str = "!refresh-prices"; +/// The key the decline row of a `/config` descent carries. +/// +/// **A sentinel and not the label, for exactly the reason [`REFRESH_PRICES`] is +/// one.** The rows of [`descent`] travel in a `Vec` the driver matches on, +/// and until 0.33.0 the decline row's entry in it was the bare string +/// [`crate::store::LEAVE_IT`] — `leave it`, with no `!` in front of it. TOML +/// accepts `"leave it" = true` as a quoted key, [`settings`] sweeps every key out +/// of `Config::origins()` onto the bare `/config` list, and the driver matches +/// these keys by value: an operator with that key in a file would have got a real +/// row whose Enter hit the do-nothing arm and reported nothing at all. `!` is the +/// one character no key in this product's catalogue starts with, and it is why the +/// act beside this row is spelled the way it is. +/// +/// The *label* stays [`crate::store::LEAVE_IT`] — that is the word the operator +/// reads, and every confirmation in this product opens on it. What changes is only +/// the key underneath it, which nobody reads. +pub const DECLINE: &str = "!leave-it"; + /// The label that sentinel wears on the picker. pub fn refresh_row(setting: &Setting) -> crate::picker::Row { let detail = match &setting.value { @@ -698,6 +682,60 @@ pub fn refresh_row(setting: &Setting) -> crate::picker::Row { crate::picker::Row::with_detail("prices: re-read the catalogue", detail) } +/// The acts one key descends into, as `(title, rows, keys)`, or `None` where the +/// key descends into values alone. +/// +/// **The parallel `keys` vector is what the caller decides on, never the row's +/// position or its label** — the same shape `/gates` already uses for +/// [`crate::app::PROPOSED_GATE`]: a sentinel sits in the list where a real key +/// would, so one `match` covers both kinds of row. Row 0 is labelled +/// [`crate::store::LEAVE_IT`] and declines, which is this product's rule for every +/// confirmation, and `crate::store::acts` is what reads that position. Its *key* is +/// [`DECLINE`] rather than that label, because a key a file could also name is a +/// key two different rows answer to. +/// +/// **Exactly one key answers `Some`, and it is named rather than derived from its +/// [`Kind`].** `prices.as_of` is `Kind::Machine` and so is offered no value to +/// type — but "machine-written" is not "has an act": a second `Machine` key added +/// later would inherit a price refresh that has nothing to do with it. The act +/// belongs to the price table, so it is the price table's key that opens it, and +/// every other key — machine-written or not — descends exactly where it did. +/// +/// This offers no way to *type* `prices.as_of`. `manage::config_value` still +/// refuses that key by name and this changes nothing about it: a date typed by hand +/// is a claim about a fetch that never happened. What the descent offers is the +/// fetch. +#[must_use] +pub fn descent( + config: &Config, + key: &str, +) -> Option<(String, Vec, Vec)> { + if key != "prices.as_of" { + return None; + } + let setting = setting(config, key); + let title = match &setting.value { + Some(as_of) => format!( + "{key} is {as_of} ({}), and is written by the refresh rather than typed", + setting.decided.word() + ), + None => format!("{key} is not set, and is written by the refresh rather than typed"), + }; + let rows = vec![ + crate::picker::Row::with_detail( + crate::store::LEAVE_IT, + "nothing is read and nothing is written", + ), + refresh_row(&setting), + ]; + // **The label is `store::LEAVE_IT`; the key beside it is [`DECLINE`].** A row's + // label is what the operator reads and its key is what the driver matches on, + // and only the second one has to be a sentinel — see `DECLINE` for the + // collision that made it one. + let keys = vec![DECLINE.to_string(), REFRESH_PRICES.to_string()]; + Some((title, rows, keys)) +} + /// Whether a key's value is a credential and must never be shown in full. /// /// N2. The test is the key rather than the value, because a credential that @@ -715,7 +753,7 @@ fn is_credential(path: &str) -> bool { /// /// **There are three substitution forms and not two.** io-harness resolves /// `${env:...}`, `${file:...}` **and** `${cmd:...}` -/// (`substitute`, `io-harness-0.71.0/src/config.rs:2150`, the `cmd` arm at +/// (`substitute`, `io-harness-0.72.0/src/config.rs:2150`, the `cmd` arm at /// `:2241`); this comment claimed two until /// 0.21.0, and the sentence it claimed it in was the argument for which forms /// pass through here. The third is deliberately not one of them: a `${env:}` or @@ -797,6 +835,23 @@ pub fn settings(config: &Config) -> Vec { rows } +/// What one setting is, and which file decided it, as one sentence. +/// +/// **One speller for two doors.** `/config ` in the idle loop and a row +/// chosen on the bare `/config` list while a turn is in flight answer the same +/// question, and since 0.33.0 both can be reached in one session. Two `format!` +/// calls agreeing today is the shape this product has repeatedly found disagreeing +/// later — most recently a guided browser that built a command string by hand and +/// was a second implementation of the parse. +/// +/// A key no file names reads `not set` rather than an empty value, because an +/// empty string is a value an operator can actually write. +#[must_use] +pub fn said(setting: &Setting) -> String { + let what = setting.value.as_deref().unwrap_or("not set"); + format!("{} is {what} ({})", setting.path, setting.decided.word()) +} + /// One key, resolved. pub fn setting(config: &Config, key: &str) -> Setting { let origins = config.origin(key); diff --git a/src/contract.rs b/src/contract.rs index fc901ba..4cd1f92 100644 --- a/src/contract.rs +++ b/src/contract.rs @@ -535,7 +535,7 @@ pub fn skills_dir(config: &Config, capabilities: &Capabilities, root: PathBuf) - /// **The existence test is not caution, it is the whole of what makes this /// default safe.** `Skills::discover` does not return early on a directory that /// is not there — it returns `Error::Config("skills directory … does not exist")` -/// (`io-harness-0.71.0/src/skills.rs`), and `TaskContract::discover_skills` +/// (`io-harness-0.72.0/src/skills.rs`), and `TaskContract::discover_skills` /// propagates it from `run.rs` at run start, before the first completion. A /// contract that named this directory unconditionally would therefore fail every /// turn of every operator who has never made one, which is almost all of them. @@ -559,8 +559,8 @@ fn default_skills() -> Option { /// leading `~` expanded, or io-cli's own home where nothing named one. /// /// **One expansion for two keys, applied after both have had their say.** -/// io-harness substitutes `${env:…}` and `${file:…}` and nothing else -/// (`substitute`, `io-harness-0.71.0/src/config.rs:2150` — there is no tilde +/// io-harness substitutes `${env:…}`, `${file:…}` and `${cmd:…}` and nothing else +/// (`substitute`, `io-harness-0.72.0/src/config.rs:2150` — there is no tilde /// branch anywhere in it, and 0.71.0 narrowed the forms rather than widening /// them: a plugin manifest now refuses all three), so a `~` an operator wrote in /// `[run] skills` or `[app.io-cli] skills` diff --git a/src/edit.rs b/src/edit.rs index 8ad7608..7966778 100644 --- a/src/edit.rs +++ b/src/edit.rs @@ -593,6 +593,41 @@ pub fn sections(text: &str) -> Vec> { .unwrap_or_default() } +/// The keys `[path]` declares directly, sorted. +/// +/// **The read half of [`sections`], one level down.** That answers which tables a +/// document holds; this answers what one of them spells. Keys only and never their +/// values — [`value_at`] is where a value's own bytes come from — so this stays a +/// module that works in bytes and decides nothing about what a setting means. +/// +/// It exists because `src/prices.rs` needed to count the models a file prices and +/// was parsing the whole document to `toml::Value` to do it, which made this +/// module's "the only file permitted to parse TOML" rule false while the gate that +/// enforced it named only two spellings. The honest fix was the accessor rather +/// than an exemption: `prices` never needed a document, only a count of one +/// table's keys, and [`sections`] already exists for exactly this reason one level +/// up. +/// +/// Empty when the document does not parse, when it carries no such header, or when +/// the table is spelled as an inline `name = { … }` — which is a value rather than +/// a region and has no keys of its own to walk. An array-of-tables index is not +/// addressed here; the first region whose path matches answers. +#[must_use] +pub fn keys(text: &str, path: &str) -> Vec { + let Ok(names) = segments(path) else { + return Vec::new(); + }; + let Ok(found) = regions(text) else { + return Vec::new(); + }; + let Some(region) = found.iter().find(|region| region.path == names) else { + return Vec::new(); + }; + let flat: BTreeMap = + toml::from_str(&text[region.body.clone()]).unwrap_or_default(); + flat.into_keys().collect() +} + /// The TOML source of the value at `path`, exactly as the file spells it. /// /// **Quoting, not interpreting.** This returns the bytes between the `=` and the diff --git a/src/events.rs b/src/events.rs index 18ceeb4..e73e342 100644 --- a/src/events.rs +++ b/src/events.rs @@ -3,7 +3,7 @@ //! Three rules shape this module. //! //! **Every kind is triaged, and nothing falls through.** `EventKind` is -//! `#[non_exhaustive]` and has fifty-one variants today, so a wildcard arm is not +//! `#[non_exhaustive]` and has fifty-two variants today, so a wildcard arm is not //! a shortcut here, it is required by the type. Until 0.11.0 that wildcard //! *rendered*, committing the variant's own snake-cased name in a muted line — //! which is why a transcript said `prompt_composed` and `answered` at whoever was @@ -1251,6 +1251,66 @@ impl Events { } lines } + // **A whole ask, and the fact that it *is* one (0.33.0).** io-harness + // 0.72.0 emits this instead of a `QuestionAsked` per question when the + // agent asks several together, and deliberately does not emit both — so + // an interface that knows only the singular arm above renders a batch + // as nothing at all. + // + // The count is committed whatever else is on screen, and that is the + // difference from the arm above rather than an oversight in it. + // `crate::intent::Answerer` implements `Responder::answer` alone, so + // io-harness's `answer_all` walks the batch and the overlay draws one + // question at a time; nothing there says the three arrived together. + // The questions themselves keep the singular's rule and are drawn only + // where no overlay will draw them, because asking an operator the same + // thing twice is the defect 0.32.0 removed and a batch would repeat it + // once per question. + EventKind::QuestionsAsked { questions } => { + let mut lines = self.flush_text(); + let asked = questions.len(); + // A batch of one is a real batch — io-harness rejects an empty + // list and accepts a single-element one — so the sentence is + // written for it rather than saying `1 questions`. + lines.push(theme.notice( + Tone::Accent, + if asked == 1 { + "the agent asks one question".to_string() + } else { + format!("the agent asks {asked} questions together") + }, + )); + if self.plain || !self.answering { + for (index, asked_question) in questions.iter().enumerate() { + lines.push(Line::from(vec![ + Span::styled(leader(separator), theme.style(Tone::Muted)), + // The ordinal is the whole of what makes this legible + // as a batch once the reader is past the heading, and + // it is words rather than a mark so that it survives + // both glyph sets unchanged. + Span::styled( + format!("{} of {asked}", index + 1), + theme.style(Tone::Muted), + ), + Span::styled(separator, theme.style(Tone::Muted)), + Span::styled( + asked_question.question.clone(), + theme.style(Tone::Normal), + ), + ])); + // `Question::choices` is `Vec` from 0.72.0 — the + // label is what an answer is spelled with, and the rest of + // a `Choice` belongs to the overlay that can lay it out. + for choice in &asked_question.choices { + lines.push(Line::from(Span::styled( + format!(" {} {}", theme.glyphs.bullet, choice.label), + theme.style(Tone::Muted), + ))); + } + } + } + lines + } // **`by` is the fact, not the decoration.** "the machine decided" and // "a person decided" are different things to have happened to a run, // and the answer alone cannot tell them apart. diff --git a/src/exec.rs b/src/exec.rs index b2c82e5..e04c668 100644 --- a/src/exec.rs +++ b/src/exec.rs @@ -809,16 +809,37 @@ fn gate_line(standing: &crate::gates::Standing) -> String { /// carried on, so listing them under a heading that means "waiting for you" /// would offer work that does not exist — [`decision_for`] is where an operator /// who names one by hand is told why. +/// +/// **A batched ask is still `waiting_on: "question"` with one id**, because that +/// is what the store holds: io-harness 0.72.0 parks a whole batch as one +/// `pending_questions` row answered through one `question_id`. Renaming it would +/// break every script written against this listing to describe a difference the +/// resume door does not have. What is added instead is `questions` — how many +/// questions that one row is waiting on — because the operator's next command is +/// a single `--answer` and the one thing they cannot see from `waiting_on` alone +/// is how much that one answer has to cover. It is `1` for an ordinary question +/// and `null` for the three pauses that are not questions, which is the shape +/// `id` already uses for the run whose process went away. pub fn listed(run_id: i64, pending: &Pending, json: bool) -> Option { - let (waiting_on, id, step) = match pending { + let (waiting_on, id, step, asked) = match pending { Pending::Question { - question_id, step, .. - } => ("question", Some(*question_id), *step), - Pending::Plan { plan_id, step, .. } => ("plan", Some(*plan_id), *step), + question_id, + step, + questions, + .. + } => ( + "question", + Some(*question_id), + *step, + // `max(1)` rather than the length: a singular ask carries no batch at + // all, and a row waiting on one question is waiting on one question. + Some(questions.len().max(1)), + ), + Pending::Plan { plan_id, step, .. } => ("plan", Some(*plan_id), *step, None), Pending::Recovery { attempt_id, step, .. - } => ("recovery", Some(*attempt_id), *step), - Pending::Died { last_step } => ("died", None, *last_step), + } => ("recovery", Some(*attempt_id), *step, None), + Pending::Died { last_step } => ("died", None, *last_step, None), Pending::Interrupted | Pending::Finished => return None, }; Some(if json { @@ -829,11 +850,19 @@ pub fn listed(run_id: i64, pending: &Pending, json: bool) -> Option { "waiting_on": waiting_on, "id": id, "step": step, + "questions": asked, }) .to_string() } else { + // Only when there is more than one. A `1 question` on every row of a + // listing is the mark nobody reads, and the plain stream is the one being + // skimmed rather than parsed. + let several = match asked { + Some(n) if n > 1 => format!(" {n} questions"), + _ => String::new(), + }; match id { - Some(id) => format!("run {run_id} {waiting_on} {id} step {step}"), + Some(id) => format!("run {run_id} {waiting_on} {id} step {step}{several}"), None => format!("run {run_id} {waiting_on} step {step}"), } }) @@ -850,6 +879,13 @@ pub fn listed(run_id: i64, pending: &Pending, json: bool) -> Option { #[derive(Debug, Clone, PartialEq, Eq)] pub enum Decision { /// Answer the question the run asked. + /// + /// **One id and one text answer a batched ask too**, and that is the store's + /// shape rather than a simplification: io-harness parks a whole batch as one + /// row under one `question_id`, and `resume_with_answer_observed` records one + /// reply against it. So this needs no plural twin — what it needs, and what + /// the refusal sentence gives it, is a line telling the operator their one + /// text has to cover every question in the batch. Answer { /// The row the answer is written against. question_id: i64, @@ -937,6 +973,28 @@ pub fn recovery_for(flag: RecoveryFlag, account: Option<&str>) -> Result String { match pending { + // **A batch is one `--answer`, and this sentence has to say what that + // answer has to be.** io-harness parks the whole ask as one row, and a + // resume records one text against it — `PendingQuestion::answers`, the + // per-question breakdown, is written only by a `Responder` inside the + // running process and stays empty for every answer that arrives this way. + // So the door *can* answer a batch in one invocation; what it cannot do is + // take the questions apart, and an operator who is told only + // `--answer ""` will send one sentence to a five-part ask. + Pending::Question { + question_id, + questions, + .. + } if questions.len() > 1 => format!( + "run {run_id} is waiting on question {question_id}, which is {n} questions the \ + agent asked in one go. One `--answer` answers all {n}: there is no per-question \ + flag, because io-harness parks a batch as a single row and records a single \ + reply against it. Number your answers to match the questions and send them as \ + one text — `io resume {run_id} --answer \"1. <…> 2. <…>\"`. The questions \ + themselves are not on this command line; `io` shows them when you resume the \ + run there", + n = questions.len(), + ), Pending::Question { question_id, .. } => format!( "run {run_id} is waiting on question {question_id}; answer it with \ `io resume {run_id} --answer \"\"`" @@ -1223,12 +1281,12 @@ impl WithProvider for Resuming { // refuses a contained run outright because io-harness publishes no // tree-aware recovery entry point to keep those limits with. // - // **Still true at 0.71.0, and the shape of the gap is worth naming.** Every + // **Still true at 0.72.0, and the shape of the gap is worth naming.** Every // other pause kind has both forms — `resume_tree_with_answer` beside // `resume_with_answer`, `resume_tree_with_plan_decision` beside its flat // one, `resume_tree_with_decision` beside `resume_with_decision` - // (`io-harness-0.71.0/src/run.rs:1769`, `:2088`, `:3002`). Recovery has - // `resume_with_recovery_observed` (`:2550`) and nothing tree-aware, so it + // (`io-harness-0.72.0/src/run.rs:1770`, `:2089`, `:3003`). Recovery has + // `resume_with_recovery_observed` (`:2551`) and nothing tree-aware, so it // is the one pause a contained run cannot be resumed from. Not an oversight // this crate can route around: a fleet's shared ceiling lives in the tree // entry points, and resuming through the flat one would drop it. diff --git a/src/home.rs b/src/home.rs index ab5258a..3f9db21 100644 --- a/src/home.rs +++ b/src/home.rs @@ -9,7 +9,7 @@ //! This module names one. It is deliberately **not** a second configuration //! system: io-harness resolves `$IO_CONFIG`, then `$IO_CONFIG_HOME`, then the //! platform's own place, reading the environment at call time -//! (`io-harness-0.71.0/src/config.rs:1891`, the Windows branch at `:1910`), and +//! (`io-harness-0.72.0/src/config.rs:1891`, the Windows branch at `:1910`), and //! there is no caller-supplied home //! anywhere in `Config`'s public surface — no `discover_in`, no builder. So the //! one lever is `$IO_CONFIG_HOME`, set once, before the first diff --git a/src/import.rs b/src/import.rs index 4cb1446..c2a1dd6 100644 --- a/src/import.rs +++ b/src/import.rs @@ -30,7 +30,7 @@ //! decision="allow")` and Claude's `permissions.ask` says `Bash(cargo yank *)` — //! both of which match a *command line*. io-harness's `Act::Exec` matches a //! **binary name and nothing else**; it has no argument matching at all -//! (`Act::Exec`, `io-harness-0.71.0/src/policy.rs:63`). So the nearest thing to a faithful +//! (`Act::Exec`, `io-harness-0.72.0/src/policy.rs:63`). So the nearest thing to a faithful //! import of `bun install` is a blanket allow on `bun`, which is a wider //! permission than the operator ever granted, written by a tool they were //! trusting to be careful. A boundary half-imported is worse than one left alone, @@ -976,16 +976,20 @@ fn skill_plan(candidates: &[(Source, PathBuf)], home: &Path) -> Vec { let mut incoming = 0usize; for (source, path) in candidates { - let name = skill_name(path); + // What the run will resolve this file to: the frontmatter `name:`, the + // directory for a `SKILL.md`, the file stem otherwise. That last fallback + // used to live here as a wrapper; it is io-harness's own `default_name` + // rule and now lives in `describe`, which is where every surface asking + // this question already asks it. + let (name, _) = crate::skillview::describe(path); // **A frontmatter `name:` is somebody else's text and it is about to - // become a path component.** `skill_name` answers with the declared name - // because that is the name a skill is *addressed* by — but `Path::join` - // treats `../../../x` as an escape and an absolute path as a replacement - // for everything to its left, so the declared name reaching `join` - // unchecked lets a third-party bundle write a file anywhere the process - // can reach. Nothing else on the path catches it: the collision guard - // asks whether the target exists, and creating a *new* file somewhere - // else answers that happily. + // become a path component.** The resolved name is the name a skill is + // *addressed* by — but `Path::join` treats `../../../x` as an escape and + // an absolute path as a replacement for everything to its left, so the + // declared name reaching `join` unchecked lets a third-party bundle write + // a file anywhere the process can reach. Nothing else on the path catches + // it: the collision guard asks whether the target exists, and creating a + // *new* file somewhere else answers that happily. // // Refused rather than sanitised. A name is how the model addresses a // skill, so quietly rewriting it would install a skill under a name the @@ -1071,14 +1075,6 @@ fn skill_plan(candidates: &[(Source, PathBuf)], home: &Path) -> Vec { items } -/// What a skill file resolves to, the way `Skills::discover` will resolve it. -/// -/// [`crate::skillview::describe`] reads the frontmatter `name:` and falls back to -/// the file stem — which is right for a loose `foo.md` and wrong for -/// `foo/SKILL.md`, whose stem is the literal word `SKILL`. io-harness's own -/// `default_name` special-cases exactly that (`skills.rs:345-356`), so this does -/// too; a plan that named three imported skills `SKILL` would collide all three -/// against each other. /// Whether `name` is exactly one ordinary path component. /// /// The question is asked of a name that came out of somebody else's file and is @@ -1092,7 +1088,11 @@ fn skill_plan(candidates: &[(Source, PathBuf)], home: &Path) -> Vec { /// refused, and anything with more than one component is refused. A NUL is /// refused too: it cannot reach the filesystem and would otherwise fail far from /// here with an error naming neither the skill nor the file it came from. -fn one_path_component(name: &str) -> bool { +/// +/// `pub(crate)` because [`crate::skillview::install`] joins a resolved name onto +/// the same directory and so asks the same question. One definition, because a +/// trust rule stated twice is a trust rule that will be right in one place. +pub(crate) fn one_path_component(name: &str) -> bool { if name.is_empty() || name.contains('\0') { return false; } @@ -1100,16 +1100,6 @@ fn one_path_component(name: &str) -> bool { matches!(parts.next(), Some(std::path::Component::Normal(_))) && parts.next().is_none() } -fn skill_name(path: &Path) -> String { - let (name, _) = crate::skillview::describe(path); - if name.eq_ignore_ascii_case("SKILL") { - if let Some(parent) = path.parent().and_then(Path::file_name) { - return parent.to_string_lossy().to_string(); - } - } - name -} - // --------------------------------------------------------------------------- // E. Applying // --------------------------------------------------------------------------- diff --git a/src/intent.rs b/src/intent.rs index e9155dd..ccdd1b0 100644 --- a/src/intent.rs +++ b/src/intent.rs @@ -22,8 +22,54 @@ //! goes once it is given: a live turn is awaiting it on a channel, a stored row //! has nobody awaiting anything, so [`Intent::resolve`] hands it back to the //! caller to deliver with `resume_with_answer_observed`. +//! +//! # Several questions, one overlay (0.33.0) +//! +//! io-harness 0.72.0 gave [`Responder`] a defaulted `answer_all`. Its default body +//! loops [`Responder::answer`] once per question, in order — which is precisely +//! what this interface could always do, and precisely the shape that made five +//! questions arrive as five consecutive overlays, each one hiding the four behind +//! it. [`Answerer`] overrides it so a batch crosses the channel as one +//! [`Questions`] and opens one [`Intent`]. +//! +//! **The harness commits a batch only when every entry is `Some`.** Answer four of +//! five and the whole batch parks for a human — so the overlay does not deliver +//! anything until every question has been *decided*, and declining is a decision. +//! That is why `Esc` on a batch decides the question on screen rather than closing +//! the overlay: `Esc` has never answered anything, and a batch carrying one `None` +//! parks the run exactly as a single declined question does. +//! +//! **One question, unchanged.** A [`Questions`] of one is a delivery of one, the +//! overlay has no batch chrome on it, and every key does what it did in 0.32.0. +//! +//! **A parked batch resumes as a batch.** The two ways in stay one surface: the +//! store parks a whole `ask_questions` as one row whose own columns are a +//! *rendering* of the ask rather than the ask, so [`Intent::resumed`] takes the +//! questions out of `PendingQuestion::questions` and hands them to the same +//! constructor a live batch goes through. Delivery is still singular, because the +//! row is — see [`Intent::resolve`]. +//! +//! # An offer can say more than its label (0.33.0) +//! +//! io-harness 0.72.0 turned a choice from a string into a `Choice`, which may +//! carry a **description** — one sentence saying what taking it means — and a +//! **preview** — a short block showing what taking it would do. Both reached this +//! overlay the moment the harness was pinned, and neither was drawn. +//! +//! They are drawn differently because they are different things. A description is +//! always visible, on a row of its own under the label, because a reader comparing +//! five offers needs all five sentences at once. A preview is a block, and five +//! blocks at once is a wall nobody reads — so it unfolds under the marker, one at +//! a time, and folds when the marker moves. The list builder carries the argument +//! for both, and `quoted` the one for why the block is marked the way the rest of +//! this product marks quoted words. +//! +//! **The harness bounds a preview before it sends one; this overlay does not +//! restate those bounds as its own.** What arrives is drawn. What is bounded here +//! is the *drawing*: the block asks for the rows it wraps to, and the viewport +//! clamps that to what the terminal can spare. -use io_harness::{AnswerFuture, PendingQuestion, Question, Responder}; +use io_harness::{AnswerFuture, AnswersFuture, PendingQuestion, Question, Responder}; use ratatui::layout::Rect; use ratatui::text::Line; use ratatui::widgets::Paragraph; @@ -43,6 +89,42 @@ pub struct Asked { pub answer: oneshot::Sender>, } +/// What one delivery carries: a question, or a batch of them asked together. +/// +/// **The unit of the channel, so that a batch cannot be torn apart in transit.** +/// One `recv` is one overlay — five questions asked as a batch arrive as one +/// value and are answered on one surface, which is the whole reason +/// [`Responder::answer_all`] is overridden below. +/// +/// **Never empty**, and that is a construction-time property rather than a +/// checked one: the only two ways to build one are [`From`](Asked), which +/// carries exactly one, and [`Answerer::answer_all`], which returns early on an +/// empty slice rather than sending. The field is private, so no caller outside +/// this module can make a third. [`Intent`] indexes on that. +#[derive(Debug)] +pub struct Questions { + asked: Vec, +} + +impl Questions { + /// How many questions this delivery is carrying. Never zero. + pub fn len(&self) -> usize { + self.asked.len() + } + + /// Never true. Present because [`Self::len`] exists and a length without an + /// emptiness test is a lint; the honest answer is the invariant above. + pub fn is_empty(&self) -> bool { + self.asked.is_empty() + } +} + +impl From for Questions { + fn from(one: Asked) -> Self { + Self { asked: vec![one] } + } +} + /// The responder handed to a turn's contract. /// /// Unbounded for the reason [`crate::approval`]'s channel is: the alternatives @@ -51,11 +133,11 @@ pub struct Asked { /// moment it asks until the moment it is answered. #[derive(Debug)] pub struct Answerer { - questions: mpsc::UnboundedSender, + questions: mpsc::UnboundedSender, } /// An answerer and the receiver the interface drains. -pub fn channel() -> (Answerer, mpsc::UnboundedReceiver) { +pub fn channel() -> (Answerer, mpsc::UnboundedReceiver) { let (questions, rx) = mpsc::unbounded_channel(); (Answerer { questions }, rx) } @@ -69,10 +151,56 @@ impl Responder for Answerer { // away are the same fact — nobody is going to answer — and both mean // `None`, which pauses the run with the question persisted rather // than failing it. - let _ = self.questions.send(Asked { question, answer }); + let _ = self.questions.send(Asked { question, answer }.into()); reply.await.unwrap_or(None) }) } + + /// A batch crosses as **one** delivery, and one overlay answers all of it. + /// + /// The trait's default body would loop [`Self::answer`], which is what this + /// interface did before io-harness 0.72.0 existed and is not wrong — it is + /// merely one question at a time, and the second question is not even sent + /// until the first has been answered. Overriding it is the only way the + /// operator ever sees that there are five. + /// + /// **A reply channel per question, not one for the batch.** The vector the + /// harness gets back is built by awaiting them in the order they were asked, + /// so the ordering is a property of the construction rather than of anything + /// the overlay remembers to do — and declining stays per question, which is + /// what the harness's own `Vec>` is for. + /// + /// Every failure lands on the same safe answer. A closed channel, an interface + /// that took the batch and went away, and an overlay dropped mid-answer all + /// drop the senders, every `await` resolves `None`, and the run parks with the + /// questions persisted. + fn answer_all<'a>(&'a self, questions: &'a [Question]) -> AnswersFuture<'a> { + Box::pin(async move { + // Nothing to ask. Sending an empty batch would open a modal overlay + // with no question on it and no key that closes it. + if questions.is_empty() { + return Vec::new(); + } + let mut replies = Vec::with_capacity(questions.len()); + let asked: Vec = questions + .iter() + .map(|question| { + let (answer, reply) = oneshot::channel(); + replies.push(reply); + Asked { + question: question.clone(), + answer, + } + }) + .collect(); + let _ = self.questions.send(Questions { asked }); + let mut answers = Vec::with_capacity(replies.len()); + for reply in replies { + answers.push(reply.await.unwrap_or(None)); + } + answers + }) + } } /// Where a decision goes once it is made. @@ -120,9 +248,137 @@ impl Destination { } } +/// A resumed batch's decisions as the one text its row is answered with. +/// +/// **Every answer beside the question it answers**, because a bare list of five +/// sentences is not readable as an answer set and the model would have to re-derive +/// the pairing from position — which is io-harness's own argument for the block it +/// writes when a [`Responder`] answers a batch in the process that asked. This is +/// deliberately the same shape, so the model reads the same thing whether the batch +/// was answered live or a day later through `/resume`. It is spelled out here +/// rather than called because the harness's own `assemble_answers` is private to +/// its run loop; the cost is stated rather than hidden, and it is why the test for +/// this asserts the pairing rather than a literal block. +/// +/// **`None` when anything was left unanswered**, and that is the same rule the +/// harness applies to a live batch: a batch is answered wholly or not at all, so a +/// decline anywhere in it means there is no answer to resume with and the run stays +/// parked on the row it was found on. Assembling a text with a hole in it would +/// resolve the row — one compare-and-swap, no second chance — on an ask the +/// operator did not finish. +fn assembled(batch: &[Question], answers: &[Option]) -> Option { + if answers.len() != batch.len() || answers.iter().any(Option::is_none) { + return None; + } + let mut text = String::new(); + for (at, (question, answer)) in batch.iter().zip(answers).enumerate() { + let answer = answer.as_deref().unwrap_or_default(); + text.push_str(&format!("{}. {}\n {answer}\n", at + 1, question.question)); + } + Some(text.trim_end().to_string()) +} + /// What the last row says. The offers are the agent's words; this one is the /// product's, and it has to read as a peer of them rather than as a way out. -const OWN_WORDS: &str = "answer in your own words"; +/// +/// Public so a test can name the row rather than assert a string it wrote out +/// itself — the row's position is F9's whole claim, and a literal here and there +/// is two spellings of one row. +pub const OWN_WORDS: &str = "answer in your own words"; + +/// What a description is indented by so it sits under the label it explains. +/// +/// Two cells, which is [`crate::glyphs::Glyphs::marker`]'s width in both glyph +/// sets and the width of the blank the picker draws in its place on an unmarked +/// row — so a description starts under its label's first character. On a question +/// that takes several the picker draws a four-cell box before every label and the +/// description stays where it is: it is prose *about* the row above it rather +/// than another row in that column, and the alternative is this file writing out +/// the width of a box that is private to `picker.rs`, which is a second spelling +/// of a number only that file should own. +const EXPLAINED: &str = " "; + +/// The rows the offers occupy, one entry per choice, in the list [`Intent::list`] +/// builds. +/// +/// **Not `0..choices.len()`, and that is the whole reason this exists.** A choice +/// carrying a description takes a second row for it, so every offer after it has +/// moved down. Everything this overlay exchanges with the [`Picker`] — +/// [`Outcome::Chosen`], [`Picker::chosen`], [`Picker::set_unfold`], +/// [`Picker::focus`] — is a **row**; everything it exchanges with io-harness is a +/// **choice**; and the two stopped being the same number the moment a description +/// got a line of its own. Translating in one place is what keeps `Enter` on the +/// fourth offer from answering with the third. +/// +/// A pure function of the question rather than a field, because the layout is a +/// pure function of the question: a copy held in [`Intent`] is a copy that can +/// disagree with the picker it describes. +fn offer_rows(question: &Question) -> Vec { + let mut rows = Vec::with_capacity(question.choices.len()); + let mut at = 0; + for choice in &question.choices { + rows.push(at); + at += 1 + usize::from(explanation(choice).is_some()); + } + rows +} + +/// The description an offer carries, or `None` when it carries nothing worth a +/// row. +/// +/// Blank is `None`. A `Some("")` — or a description of nothing but spaces — +/// would otherwise buy an empty row under the label, which reads as a gap the +/// list has for no reason. +fn explanation(choice: &io_harness::Choice) -> Option<&str> { + choice + .description + .as_deref() + .map(str::trim) + .filter(|description| !description.is_empty()) +} + +/// A preview as the block that is drawn for it. +/// +/// **The quoting vocabulary is the one this product already draws for quoted +/// words**: [`crate::markdown`] renders a model's `> ` blockquote as +/// `theme.glyphs.rule` followed by a space, and this is the same prefix on the +/// same [`Tone::Muted`]. The roadmap wanted the diff renderer's gutter instead; +/// that gutter is not reusable — `diff.rs`'s `GUTTER`, `number`, `blank_gutter`, +/// `unchanged`, `changed` and `spans` are all private and every one of them is +/// shaped around an [`io_harness::Edit`]'s hunks, which a preview is not. Of the +/// two quoting idioms that do exist, the blockquote is the one that *means* +/// "these are somebody else's words", which is exactly what a preview is; the +/// approval overlay's two-space indent means "this is file content" and carries +/// no mark at all, so an indented block with nothing in front of it is +/// indistinguishable from a wrapped row of the list above it. io-harness's own +/// reference responder prefixes with `| `, which is a third spelling this product +/// does not otherwise use — and adopting it would mean either a new +/// [`crate::glyphs::Glyphs`] field or a character chosen here rather than by the +/// glyph set, which is how a terminal that cannot draw something ends up drawing +/// a box. +/// +/// It therefore **differs between the glyph sets** — `─ ` in Unicode and `- ` in +/// ASCII — exactly as the markdown blockquote already does, and both are one cell +/// plus a space so the block occupies the same column either way. +/// +/// **Only the first row of a wrapped line carries the prefix.** The block is +/// drawn by a wrapping `Paragraph`, which is what makes [`crate::rows::wrapped`] +/// the honest measurement of its height; re-marking a continuation row would mean +/// wrapping the text here, which is a second wrapper to disagree with ratatui's. +/// Stated rather than hidden, because it is visible on a narrow terminal. +/// +/// **What arrives is drawn.** io-harness bounds a preview before it is sent; this +/// does not restate those limits as a promise of its own — a number copied here +/// is a number that goes on being asserted after the harness changes it. What is +/// bounded here is the *drawing*: the block asks for the rows it wraps to and +/// [`crate::app::App::viewport_wanted`] clamps that to what the terminal spares, +/// and the picker elides against whatever it is given. +fn quoted(preview: &str, theme: &Theme) -> Vec> { + preview + .lines() + .map(|line| theme.notice(Tone::Muted, format!("{} {line}", theme.glyphs.rule))) + .collect() +} /// The overlay a question is asked through, live or resumed. /// @@ -140,43 +396,219 @@ const OWN_WORDS: &str = "answer in your own words"; /// deliberate: 0.31.0 shipped an entire pipeline reachable from nothing but its own /// tests, and a mode built before anything needs it is the same defect wearing a /// different hat. +/// +/// # A batch is answered in place, and reviewed as it is answered (0.33.0) +/// +/// **The contract's open question was whether a batch needs a separate review +/// surface before it is sent. It does not, and this is the answer that ships.** +/// +/// The overlay shows one question of the batch at a time — its question line, its +/// context, its offers, its free-text row: the same surface a single question has, +/// so nothing about answering one question changes because four others exist. +/// Deciding it moves to the next question that has not been decided; deciding the +/// last one delivers the whole batch. `PgUp` and `PgDn` walk the batch in either +/// direction at any point before that, and a question already decided re-opens +/// with its own answer back in the composer, so an "edit" is the operator retyping +/// or re-sending their own words rather than a second kind of screen with a second +/// set of keys. +/// +/// A separate review pane was rejected for three reasons, in order of weight. +/// **It would be a second surface for the same act** — the answers are already all +/// on the screen the operator typed them into, one page-key apart, and a list of +/// them elsewhere is a second rendering of state that can disagree with the first. +/// **It would need a submit key that answers nothing**, and the one thing this +/// overlay has always been careful about is a reflexive `Enter` meaning something +/// nobody read — F9's marker rule exists for exactly that. **And it would make the +/// simple case pay for the rare one**: a batch of one is overwhelmingly the common +/// delivery, and a review step it must pass through is a keystroke added to every +/// question the agent has ever asked. +/// +/// What the operator is owed instead is knowing where they are and that nothing has +/// been sent yet, and that is two lines of the head: which question of how many, +/// and what this one was already decided as if they have come back to it. +/// +/// **Nothing is delivered until every question is decided**, because io-harness +/// commits a batch only when every entry is `Some` — four answers out of five park +/// the run just as thoroughly as none. Declining is a decision, so `Esc` decides +/// the question on screen and moves on rather than closing the overlay: the run +/// still parks, which is the only thing `Esc` has ever promised. pub struct Intent { - question: Question, - answer: Destination>, + /// Every question in this delivery, in the order the agent asked them. + /// + /// Never empty — see [`Questions`], which can only be built carrying at least + /// one — which is what lets [`Self::current`] index rather than answer + /// `Option`. + batch: Vec, + /// Where each question's answer goes, one per entry of `batch`. + /// + /// A destination *per question* rather than one for the batch, because a live + /// batch is N turns' worth of channels: the harness handed out one reply + /// channel per question and expects each of them to resolve. + answers: Vec>>, + /// What has been decided for each question, `None` until it has been. + /// + /// The outer `Option` is *decided at all*; the inner one is io-harness's own + /// meaning, where `None` is "nobody here can answer this". Collapsing the two + /// would make a declined question indistinguishable from an unvisited one, and + /// the overlay would deliver a batch the operator never finished. + decided: Vec>>, + /// What is in the composer for each question, kept across a move. + /// + /// Half a typed answer is the operator's work, and a page key that threw it + /// away would be the surface losing something nobody could get back. For a + /// decided question this holds the decision, which is what re-opening one puts + /// back in the composer. + drafts: Vec, + /// Which question is on screen. + at: usize, composer: Composer, - /// The offers and the free-text row, as one list. + /// The offers and the free-text row of the question on screen, as one list. + /// + /// Rebuilt on every move rather than kept per question: the picker's state + /// that matters here is the marker, and the marker's position is fixed by F9 + /// on arrival anyway. The cost is stated rather than hidden — the marks on a + /// `multiple` question are not restored when it is re-opened, and the decision + /// they produced is in the composer instead, as the prose it always was. offers: Picker, + /// The tallest unfold `offers` has actually been **told** about. + /// + /// **A preview's height is a function of the width it is drawn at, and the + /// width is not known until something draws.** [`Self::render`] measures every + /// preview through [`crate::rows::wrapped`] and hands each one to + /// [`Picker::set_unfold`] before the picker reserves anything — but the driver + /// reads the demand *before* it draws (`paint_picker` calls + /// [`crate::app::App::viewport_wanted`] and only then `draw`), so on the first + /// frame of a question the picker has been told nothing and its reservation is + /// the composer's row alone. + /// + /// [`Self::rows_wanted`] therefore measures the previews itself and adds + /// whatever the picker is not already reserving — and this is the number it + /// subtracts, so the same total comes out before and after a frame. Without it + /// the demand either lags a frame behind the surface (an overlay that opens + /// too short and grows under the operator's hands) or double-counts the block + /// once render has run. + reserved: u16, } impl Intent { - /// Open on a question. The composer starts empty: an answer pre-filled with - /// one of the agent's own choices is an answer the operator did not give. - pub fn new(asked: Asked) -> Self { - let offers = Self::list(&asked.question); + /// Open on a delivery, live. The composer starts empty: an answer pre-filled + /// with one of the agent's own choices is an answer the operator did not give. + /// + /// Takes anything a delivery can be made from, so a single [`Asked`] still + /// opens an overlay by itself and the driver's select arm hands over whatever + /// the channel gave it. + pub fn new(questions: impl Into) -> Self { + let Questions { asked } = questions.into(); + let mut batch = Vec::with_capacity(asked.len()); + let mut answers = Vec::with_capacity(asked.len()); + for one in asked { + batch.push(one.question); + answers.push(Destination::Turn(one.answer)); + } + Self::opened(batch, answers) + } + + /// The **one** way an overlay is built, whichever way in was taken. + /// + /// The two constructors differ in exactly one thing — where each answer goes — + /// and nothing else about a question overlay may depend on that. Before 0.33.0 + /// [`Self::resumed`] assembled its own struct literal beside this one, and the + /// copy is precisely how a resumed batch came to be drawn as a single question + /// with no offers on it: the stored path built its own `Question` instead of + /// taking the ones the agent asked, and [`Self::list`] never saw them. One + /// construction, so a surface the live path has is a surface the stored path + /// has. + /// + /// `batch` is never empty — [`Questions`] cannot be, and [`Self::resumed`] + /// falls back to the row's own columns when the store holds no batch — which is + /// what lets this index it. + fn opened(batch: Vec, answers: Vec>>) -> Self { + let offers = Self::list(&batch[0]); Self { - question: asked.question, - answer: Destination::Turn(asked.answer), + decided: vec![None; batch.len()], + drafts: vec![String::new(); batch.len()], + at: 0, + batch, + answers, composer: Composer::new(), offers, + reserved: crate::app::COMPOSER_ROWS, } } + /// The question on screen. + /// + /// Indexes rather than answering `Option` because [`Questions`] cannot be + /// empty and [`Self::at`] is only ever set from a position in `batch`. + fn current(&self) -> &Question { + &self.batch[self.at] + } + /// The offers, then the row that is not one. /// /// The free-text row is **last and is a peer**, not a footer: it is chosen the /// way every other row is chosen, and io-harness's own documentation says an /// answer is not obliged to be one of `choices`, so the surface should not - /// imply otherwise. Its index is `choices.len()`, which is what the unfold is - /// keyed on and what [`Intent::key`] focuses when the operator starts typing. + /// imply otherwise. Its index is the **last row's**, which is what the unfold + /// is keyed on and what [`Intent::key`] focuses when the operator starts + /// typing. Not `choices.len()` since 0.33.0 — a described offer takes a row + /// for its description, so the two numbers part company the first time an + /// agent explains one of its own offers; see [`offer_rows`]. + /// + /// **A description is a row of its own, and a heading row.** The agent's + /// sentence about an offer is not a second thing to choose, so it must not be + /// selectable, must not take the marker on the way past, and must not be + /// answerable — which is exactly what [`Row::heading`] already is, and the + /// reason this needs no new picker mechanic. Folding it into the row's + /// `detail` instead would put it on the same line, where the picker fits it + /// into whatever the label leaves and drops it altogether on a narrow + /// terminal: the first thing cut would be the explanation of the choice being + /// made, which is the opposite of what it is for. + /// + /// **A description is always visible and a preview is not**, and the + /// difference is what each of them is. A description is one sentence saying + /// what taking the offer *means*, and a reader comparing five offers needs all + /// five of those at once. A preview is a block showing what taking it would + /// *do*; five of those on screen together is a wall nobody reads, so it + /// unfolds under the marker instead, one at a time, which is the mechanic + /// [`Picker::set_unfold`] exists for. + /// + /// **The cost, stated rather than hidden:** the picker opens a block directly + /// beneath the row that owns it, so on an offer carrying *both* a description + /// and a preview the open preview sits between the label and its description + /// rather than after it. Nothing is lost and nothing overlaps — the + /// description is still on the screen, one block lower, and only while that + /// offer holds the marker. Putting it back in its place would mean the picker + /// knowing that some rows belong to the row above them, which is a second + /// grouping mechanic bought for one arrangement of two optional fields. + /// + /// **The spacebar is spent only on a question that says it takes several.** + /// [`Picker::accepting_several`] costs a picker its space key, and a question + /// answered with one choice — every question written before io-harness 0.72.0, + /// and most written since — is answered in prose with spaces in it. Opting in + /// unconditionally would take the spacebar from every two-word answer in the + /// product to buy a mark nobody can make. fn list(question: &Question) -> Picker { - let mut rows: Vec = question - .choices - .iter() - .map(|choice| Row::new(choice.clone())) - .collect(); + let mut rows: Vec = Vec::with_capacity(question.choices.len() + 1); + for choice in &question.choices { + rows.push(Row::new(choice.label.clone())); + if let Some(description) = explanation(choice) { + rows.push(Row::heading(format!("{EXPLAINED}{description}"))); + } + } rows.push(Row::new(OWN_WORDS)); let free = rows.len() - 1; - let mut offers = Picker::new("choose an answer, or write one", rows); + let mut offers = Picker::new( + if question.multiple { + "space marks several, or write an answer" + } else { + "choose an answer, or write one" + }, + rows, + ); + if question.multiple { + offers = offers.accepting_several(); + } offers.set_unfold(free, crate::app::COMPOSER_ROWS); // **The marker opens on the free-text row, never on the agent's first // offer**, and that is a safety property rather than a preference. An @@ -200,33 +632,188 @@ impl Intent { /// so neither reaches the overlay — an answered row is not something to ask /// again, and refusing to open on one is the caller's job, which is the only /// place that knows whether it is resuming or replaying. + /// + /// # A parked batch is a batch, and the row's own columns are not it (0.33.0) + /// + /// **`questions` is read first, and it is the only place a parked batch + /// survives.** io-harness 0.72.0 parks a whole `ask_questions` as *one* + /// `pending_questions` row, and that row's columns are a rendering rather than + /// the ask: `question` is the batch as numbered prose (`"1. …\n2. …"`), + /// `context` is the **first** question's context, and `choices` is empty + /// because the synthesised row question has none. Building from those three + /// gave a resumed batch one accent line holding embedded newlines — which + /// ratatui does not break a `Line` on, so every question ran together into one + /// wrapped paragraph — question one's context presented as everyone's, a picker + /// with no offers at all, and `multiple` defaulted to `false`, which is no + /// marks and no [`Question::answer_of`]. Everything the operator needed was in + /// `questions` and nothing read it. + /// + /// It is reachable through this release's own flow rather than at an edge: the + /// batch overlay's `Esc` decides a question as unanswered and moves on, the + /// harness commits a batch only when every entry is `Some`, so the run parks — + /// and `/resume` opens it here. `io exec` reaches it on the first ask, having + /// no [`Responder`] at all. + /// + /// **Empty `questions` is the singular ask and stays exactly as it was**, which + /// is not a fallback so much as the other half of the store's shape: + /// `Store::put_question` writes no `questions` value, and a row written by + /// 0.71.0 has no such column to write. Those rows are built from `question`, + /// `context` and `choices` as they always were. pub fn resumed(pending: &PendingQuestion) -> Self { - let question = Question { - question: pending.question.clone(), - context: pending.context.clone(), - choices: pending.choices.clone(), + let batch = if pending.questions.is_empty() { + let mut question = + Question::new(pending.question.clone()).with_choices(pending.choices.clone()); + if let Some(context) = pending.context.clone() { + question = question.with_context(context); + } + vec![question] + } else { + pending.questions.clone() }; - let offers = Self::list(&question); - Self { - question, - answer: Destination::Stored, - composer: Composer::new(), - offers, - } + // One destination per question, as the live path has, so `record` and + // `key` cannot tell the two apart. What differs is delivery: the row is + // one row however many questions it parked, and `resolve` says so. + let answers = batch.iter().map(|_| Destination::Stored).collect(); + Self::opened(batch, answers) } /// The question on screen, for whatever has to show or assert it. pub fn question(&self) -> &Question { - &self.question + self.current() + } + + /// The offers and the free-text row of the question on screen. + /// + /// Read by the tests, which assert F9 — that the free-text row is last and + /// holds the marker — **by index**. A screen assertion cannot see which row is + /// focused, and the marker's position is the whole of that decision: an + /// overlay opening on the agent's first offer turns a reflexive `Enter` into + /// agreement with a suggestion nobody read. + pub fn offers(&self) -> &Picker { + &self.offers } /// A keystroke while the overlay is up. /// - /// `Some` closes it: `Some(Some(text))` answers, `Some(None)` declines. An - /// empty prompt submits nothing at all — `Enter` on an empty line is a + /// `Some` closes it, and carries **one entry per question of the delivery, in + /// the order the agent asked them** — `Some(text)` answers, `None` declines. + /// A single question is a vector of one, which is every delivery this overlay + /// saw before 0.33.0. + /// + /// `None` means the overlay stays up, which now covers one more case than it + /// did: a question of a batch that was just decided while others have not + /// been. Nothing is delivered until every one of them has been, because + /// io-harness commits a batch only when every entry is `Some` and a half + /// answered batch parks the run with no more information than an empty one. + /// + /// An empty prompt submits nothing at all — `Enter` on an empty line is a /// mis-key, and answering the agent with an empty string would send it back /// to work with no more information than it had. - pub fn key(&mut self, key: crossterm::event::KeyEvent) -> Option> { + pub fn key(&mut self, key: crossterm::event::KeyEvent) -> Option>> { + use crossterm::event::KeyCode; + + // **The page keys walk the batch, and only when there is a batch.** On a + // single question they fall through to the composer exactly as they did + // before, which is the 0.32.0 surface and is not being reopened. + if self.batch.len() > 1 && matches!(key.code, KeyCode::PageUp | KeyCode::PageDown) { + let last = self.batch.len() - 1; + let to = if key.code == KeyCode::PageUp { + self.at.saturating_sub(1) + } else { + self.at.saturating_add(1).min(last) + }; + self.go(to); + return None; + } + let decision = self.decision(key)?; + self.record(decision) + } + + /// Store what was decided for the question on screen, then move on — or + /// deliver, when there is nothing left to move to. + /// + /// **The search wraps**, because the operator can have paged backwards: after + /// deciding question two of five with four still open, the next thing to ask is + /// three, and after deciding five it is whatever was left behind. + fn record(&mut self, decision: Option) -> Option>> { + if let Some(text) = &decision { + // Held as this question's draft as well as its decision, so re-opening + // it puts the operator's own words back rather than an empty line. + self.drafts[self.at] = text.clone(); + } + self.decided[self.at] = Some(decision); + let count = self.batch.len(); + let next = (1..=count) + .map(|step| (self.at + step) % count) + .find(|at| self.decided[*at].is_none()); + match next { + Some(next) => { + self.go(next); + None + } + // Every question decided. The outer `Option` is `Some` on every entry + // by construction here, so flattening cannot invent an answer. + None => Some( + self.decided + .iter() + .map(|decided| decided.clone().flatten()) + .collect(), + ), + } + } + + /// Put a different question of the batch on screen. + /// + /// The draft is saved only for a question that has **not** been decided: + /// `Composer::key` clears the composer as it submits, so a decided question's + /// composer is empty and saving it would wipe the decision that was just + /// written into the same slot. + fn go(&mut self, to: usize) { + if to == self.at { + return; + } + if self.decided[self.at].is_none() { + self.drafts[self.at] = self.composer.text(); + } + self.at = to; + self.offers = Self::list(&self.batch[to]); + // A fresh list has been told about the composer's row and nothing else, + // and the next question's previews are a different set of blocks at a + // width nothing has measured yet. + self.reserved = crate::app::COMPOSER_ROWS; + self.composer = Composer::new(); + self.composer.set(&self.drafts[to]); + } + + /// The row that takes prose, which is the last one. + /// + /// Read off the picker rather than computed from `choices.len()`: a described + /// offer takes a row for its description, so the count of choices stopped + /// being the count of rows before it in 0.33.0. [`offer_rows`] says the rest. + fn free_row(&self) -> usize { + self.offers.rows().len().saturating_sub(1) + } + + /// Whether the marker is on the free-text row, which is where the composer is + /// unfolded. + /// + /// **Not [`Picker::unfolded_now`], and the difference is new in 0.33.0.** Until + /// an offer could carry a preview the composer's row was the only row that + /// unfolded anything, so "something is open" and "the operator is writing" + /// were the same question. They are not: an offer with a preview unfolds too, + /// and routing `Enter` by the old test would have sent it to the composer — + /// so `Enter` on an offer whose preview is open would have answered with + /// whatever was typed for a *different* row, or with nothing at all. + fn writing(&self) -> bool { + self.offers.selection() == Some(self.free_row()) + } + + /// What this keystroke decides about the question on screen, if anything. + /// + /// `Some(Some(text))` answers it, `Some(None)` declines it, `None` is every + /// key that moves, types or does nothing. This is the whole of the 0.32.0 + /// surface: one question, one list, one composer. + fn decision(&mut self, key: crossterm::event::KeyEvent) -> Option> { use crossterm::event::KeyCode; if key.code == KeyCode::Esc { @@ -245,6 +832,23 @@ impl Intent { return None; } + // **The spacebar marks, on a question that takes several and while the + // marker is on an offer.** It sits above the printable arm rather than + // inside it, so on a question that takes one answer a space is a space and + // reaches the composer exactly as every other character does — which is + // what makes `multiple` invisible to every question written before it + // existed. With the composer unfolded under the marker the space is prose + // again: the free-text row is not an offer and cannot be marked. + if key.code == KeyCode::Char(' ') + && self.current().multiple + && !self.writing() + && !modifiers.contains(crossterm::event::KeyModifiers::CONTROL) + && !modifiers.contains(crossterm::event::KeyModifiers::ALT) + { + self.offers.key(key); + return None; + } + // **Typing is always the answer, and the marker follows it.** This is the // one place the surface differs from every other `Picker` in the product, // and it is deliberate: a picker's printable keys filter its rows, which is @@ -259,25 +863,20 @@ impl Intent { && !modifiers.contains(crossterm::event::KeyModifiers::CONTROL) && !modifiers.contains(crossterm::event::KeyModifiers::ALT) { - self.offers.focus(self.question.choices.len()); + let free = self.free_row(); + self.offers.focus(free); return self.typed(key); } // `Enter` and `Tab` take what is under the marker. On the free-text row - // that is whatever has been typed into it; on an offer it is the offer. - if self.offers.unfolded_now() { + // that is whatever has been typed into it; on an offer it is the offer — + // **including an offer whose preview is unfolded**, which is why the test + // is which row holds the marker and no longer "is anything unfolded". + if self.writing() { return self.typed(key); } match self.offers.key(key) { - // The offer verbatim. Not the row's label re-read off the screen and - // not a fitted copy of it: the string the agent sent is the string it - // gets back, which is the whole reason `Outcome::Chosen` indexes the - // caller's own unfiltered rows. - Outcome::Chosen(index) => self - .question - .choices - .get(index) - .map(|choice| Some(choice.clone())), + Outcome::Chosen(index) => self.taken(index), // **`Cancelled` cannot arrive, and saying so is the point.** The only // key a `Picker` cancels on is `Ctrl+C`, and `App::key` takes that // before this overlay ever sees it — its own comment settles the @@ -288,6 +887,51 @@ impl Intent { } } + /// What `Enter` on the offers answers with. + /// + /// The offer verbatim. Not the row's label re-read off the screen and not a + /// fitted copy of it: the string the agent sent is the string it gets back, + /// which is the whole reason `Outcome::Chosen` indexes the caller's own + /// unfiltered rows. + /// + /// **A `multiple` question is spelled by [`Question::answer_of`] and never + /// here.** The harness owns that joiner precisely so two interfaces answering + /// the same question produce the same text; a `", "` written out in io-cli + /// would be a second spelling that agrees today and drifts the first time the + /// harness changes its mind. + /// + /// Read through [`Picker::chosen`], which is "the marks, or the row under the + /// marker when nothing is marked" — so `Enter` on a plural question with + /// nothing marked still sends the offer the operator is looking at, rather + /// than the empty string `answer_of` would make of an empty list. An empty + /// answer is not an answer; it is information the agent did not have and + /// would now believe. + fn taken(&self, row: usize) -> Option> { + let question = self.current(); + // **A row, translated to a choice, and never used as one.** Through + // 0.32.0 the two were the same number; a described offer takes a row for + // its description, so from 0.33.0 indexing `choices` with a row answers + // the agent with a *different* offer than the one under the marker — and + // silently, because both are real offers of the same question. + let rows = offer_rows(question); + let choice = |row: usize| { + rows.iter() + .position(|at| *at == row) + .and_then(|choice| question.choices.get(choice)) + }; + if question.multiple { + let labels: Vec = self + .offers + .chosen() + .into_iter() + .filter_map(choice) + .map(|choice| choice.label.clone()) + .collect(); + return (!labels.is_empty()).then(|| Some(Question::answer_of(labels))); + } + choice(row).map(|choice| Some(choice.label.clone())) + } + /// A keystroke that belongs to the composer. /// /// An empty prompt submits nothing at all — `Enter` on an empty line is a @@ -300,17 +944,44 @@ impl Intent { } } - /// Resolve the question. Consumes the overlay, because a question answered - /// twice is a run that receives an answer nobody typed. + /// Resolve every question of the delivery. Consumes the overlay, because a + /// question answered twice is a run that receives an answer nobody typed. + /// + /// `answers` is positional: entry `n` goes to question `n`, which is the order + /// [`Self::key`] hands them back in and the order io-harness expects. An + /// `answers` shorter than the batch leaves the rest of the reply channels to + /// drop, and a dropped channel is `None` — the run parks with those questions + /// persisted, which is the safe direction and the only one available. /// - /// Returns `None` when the answer has been delivered — a live turn was - /// awaiting it and now has it. Returns `Some(answer)` when this overlay was - /// opened by [`Self::resumed`]: there was no turn to send to, so the answer - /// comes back out here and the caller delivers it with + /// Returns `None` when everything has been delivered — a live turn was + /// awaiting each answer and now has it. Returns `Some(answer)` when this + /// overlay was opened by [`Self::resumed`]: there was no turn to send to, so + /// the answer comes back out here and the caller delivers it with /// `io_harness::resume_with_answer_observed`. Dropping that value drops the /// operator's answer, which is why it is a return rather than a side effect. - pub fn resolve(self, answer: Option) -> Option> { - self.answer.deliver(answer) + /// + /// **A resumed batch is still one answer, because it is still one row.** The + /// store parks a whole `ask_questions` under a single `question_id` and + /// `answer_question` is a single compare-and-swap, so there is exactly one text + /// to hand back however many questions the operator just worked through — + /// which is why this is a single `Option` and not a vector, and why there is no + /// loop resuming the run once per question. The text pairs every answer with + /// the question it answers, and a batch with anything left unanswered has no + /// text at all: it comes back `Some(None)` and the run stays parked. + pub fn resolve(self, answers: Vec>) -> Option> { + // A stored batch: many decisions, one row, one text. Live batches fall + // through — each of their questions has a channel of its own that the + // harness is awaiting, and joining those would answer none of them. + if self.batch.len() > 1 && self.answers.iter().all(Destination::parked) { + return Some(assembled(&self.batch, &answers)); + } + let mut undelivered = None; + for (destination, answer) in self.answers.into_iter().zip(answers) { + if let Some(kept) = destination.deliver(answer) { + undelivered = Some(kept); + } + } + undelivered } /// The question, its context, the choices offered, and the prompt. @@ -318,12 +989,24 @@ impl Intent { /// The whole viewport, like an approval: the run is stopped, so there is /// nothing behind this worth half a screen. /// - /// One line of it depends on which way in was taken, and it is the only one: - /// declining a live question defers it *within* a turn that is still running - /// and will carry on the moment it is answered, while declining a resumed one - /// leaves the run parked exactly as it was found — the operator opened it, so - /// "for later" would be a promise nothing behind the screen is keeping. That - /// difference is a word chosen here, not a second `render`. + /// One line of it depends on which way in was taken: declining a live question + /// defers it *within* a turn that is still running and will carry on the moment + /// it is answered, while declining a resumed one leaves the run parked exactly + /// as it was found — the operator opened it, so "for later" would be a promise + /// nothing behind the screen is keeping. That difference is a word chosen here, + /// not a second `render`. + /// + /// A batch adds two more lines and takes that word for a third: where in the + /// batch this question is, what it was already decided as if the operator has + /// come back to it, and the page keys. On a single question none of them are + /// drawn, so the surface is 0.32.0's to the row. + /// + /// The list carries a described offer's sentence on a row of its own, always, + /// and the row under the marker opens one block beneath itself: the composer + /// on the free-text row, and that offer's preview on an offer that has one. + /// Every preview is measured here, at this width, before the picker reserves + /// anything — the reservation is blank rows, and a block measured after them + /// would be drawn over the list. pub fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) { if area.height == 0 || area.width == 0 { return; @@ -358,54 +1041,189 @@ impl Intent { height: rest, ..area }; + // **Measured, then reserved, then drawn — in that order, on the same + // width.** The picker reserves an unfold out of the rows it was given + // *while it renders*, so a height that arrives after `Picker::render` is a + // height that reserved nothing this frame: the block would be drawn over + // the list rather than into rows kept blank for it. The width is + // `below.width`, which is `area.width`, which is the width the block's own + // rectangle gets — so the number measured and the number drawn against are + // the same number rather than two that agree today. + let previews = self.previews(below.width, theme); + self.reserved = previews + .iter() + .map(|(_, height)| *height) + .max() + .unwrap_or(0) + .max(crate::app::COMPOSER_ROWS); + for (row, height) in previews { + self.offers.set_unfold(row, height); + } self.offers.render(frame, below, theme); + // One block is open at a time and the picker says which — it is the row + // under the marker, and there is only ever one of those. Whose block it is + // decides what goes in it: the free-text row's is the composer, an offer's + // is its preview. An offer with neither reserves nothing, so `opened` is + // `None` and nothing is drawn under it at all. + let block = self.open_preview(theme); if let Some(open) = self.offers.opened() { - self.composer.render(frame, open, theme); + match block { + Some(lines) => frame.render_widget( + Paragraph::new(lines).wrap(ratatui::widgets::Wrap { trim: false }), + open, + ), + None => self.composer.render(frame, open, theme), + } } } + /// Every offer that carries a preview: the row it sits on, and the rows its + /// quoted block wraps to at `width`. + /// + /// **Wrapped, never counted.** A preview is drawn by a `Paragraph` with + /// wrapping on, so its height is a function of the width and `lines.len()` is + /// not it — the measurement that has already cost this product two defects, + /// both of them one surface painting over rows another had been promised. + /// [`crate::rows::wrapped`] asks ratatui's own wrapper, so the reservation and + /// the drawing cannot disagree. + fn previews(&self, width: u16, theme: &Theme) -> Vec<(usize, u16)> { + let question = self.current(); + offer_rows(question) + .into_iter() + .zip(&question.choices) + .filter_map(|(row, choice)| { + let lines = quoted(choice.preview.as_deref()?, theme); + (!lines.is_empty()).then(|| (row, crate::rows::wrapped(&lines, width))) + }) + .collect() + } + + /// The block belonging to the row under the marker, when that row is an offer + /// with a preview. + /// + /// `None` on the free-text row — where the block is the composer — and on an + /// offer with nothing to show, which reserves no block at all. + fn open_preview(&self, theme: &Theme) -> Option>> { + let question = self.current(); + let row = self.offers.selection()?; + let at = offer_rows(question) + .iter() + .position(|offer| *offer == row)?; + let lines = quoted(question.choices.get(at)?.preview.as_deref()?, theme); + (!lines.is_empty()).then_some(lines) + } + /// The question, its context, and the line naming the keys. /// - /// One line of it depends on which way in was taken, and it is the only one: - /// declining a live question defers it *within* a turn that is still running - /// and will carry on the moment it is answered, while declining a resumed one - /// leaves the run parked exactly as it was found — the operator opened it, so - /// "for later" would be a promise nothing behind the screen is keeping. That - /// difference is a word chosen here, not a second `render`. + /// One line of it depends on which way in was taken: declining a live question + /// defers it *within* a turn that is still running and will carry on the moment + /// it is answered, while declining a resumed one leaves the run parked exactly + /// as it was found — the operator opened it, so "for later" would be a promise + /// nothing behind the screen is keeping. That difference is a word chosen here, + /// not a second `render`. + /// + /// A batch adds two more lines and takes that word for a third: where in the + /// batch this question is, what it was already decided as if the operator has + /// come back to it, and the page keys. On a single question none of them are + /// drawn, so the surface is 0.32.0's to the row. fn head(&self, theme: &Theme) -> Vec> { let mut lines: Vec> = Vec::new(); + let dash = theme.glyphs.dash; + // **Where in the batch, above the question rather than below it**, and + // only when there is a batch: on the single question that is nearly every + // delivery this line says nothing and the surface is 0.32.0's exactly. + if self.batch.len() > 1 { + lines.push(theme.notice( + Tone::Muted, + format!( + "question {} of {} {dash} nothing is sent until every one is decided", + self.at + 1, + self.batch.len() + ), + )); + // Coming back to a question that was already decided says so, because + // the composer holding the operator's own words back is otherwise + // indistinguishable from a draft they abandoned. + if let Some(decided) = &self.decided[self.at] { + lines.push(theme.notice( + Tone::Muted, + match decided { + Some(text) => format!("already answered {dash} {text}"), + None => format!("already left unanswered {dash} answering replaces that"), + }, + )); + } + } // **`Tone::Accent`, not `Tone::Warning`.** `Tone::Warning`'s word is // literally `warning`, so every question this agent asked was prefixed // with it — and `Tone::Refused`'s own doc keeps the vocabulary honest: // these tones mean something, and a question is not a warning. It is the // product's own colour because it is the product asking for the operator's // attention, which is exactly what the prompt marker uses it for. - lines.push(theme.notice(Tone::Accent, self.question.question.clone())); - if let Some(context) = &self.question.context { + lines.push(theme.notice(Tone::Accent, self.current().question.clone())); + if let Some(context) = &self.current().context { lines.push(theme.notice(Tone::Muted, context.clone())); } - let leaves = if self.answer.parked() { + // On a batch `Esc` decides *this* question and moves on, so it cannot be + // described as leaving the overlay. The run still parks — a batch carrying + // one `None` is not committed — which is what `Esc` has always promised + // and the reason the word survives the change. + let leaves = if self.batch.len() > 1 { + "Esc leaves this one unanswered" + } else if self.answers.iter().any(Destination::parked) { "Esc leaves the run parked" } else { "Esc leaves it for later" }; lines.push(theme.notice( Tone::Muted, - format!( - "Enter sends the marked row {} Tab too {} {leaves}", - theme.glyphs.dash, theme.glyphs.dash - ), + format!("Enter sends the marked row {dash} Tab too {dash} {leaves}"), )); + if self.batch.len() > 1 { + lines.push(theme.notice( + Tone::Muted, + format!( + "PgUp and PgDn move between the questions {dash} an answered one \ + re-opens to be changed" + ), + )); + } lines } /// Rows this overlay would like the viewport to be. /// - /// Its head as it will actually wrap, every offer, the free-text row, the - /// composer unfolded beneath it, and the picker's own head row. A request: + /// Its head as it will actually wrap, every offer, a row for each offer the + /// agent explained, the free-text row, the tallest block any row unfolds — + /// the composer, or a preview measured at `width` — and the picker's own head + /// row. A request: /// [`crate::app::App::viewport_wanted`] clamps it to what the terminal can /// spare, and [`Self::render`] degrades against whatever it is given. pub fn rows_wanted(&self, width: u16, theme: &Theme) -> u16 { - crate::rows::wrapped(&self.head(theme), width).saturating_add(self.offers.rows_wanted()) + // **The tallest preview, never the focused one**, which is the property + // `Picker::rows_wanted` was rebuilt around in 0.33.0 and this must not + // undo: a demand that followed the marker would change by the difference + // between a one-row preview and a twelve-row one on every arrow key, and + // the driver re-places the viewport whenever the demand changes — a + // terminal tear-down and a cursor query per keystroke, on a surface that + // is open while a turn is in flight. + // + // Added on top of what the picker already reserves rather than instead of + // it, and net of `reserved`, because the picker cannot measure this + // itself: the width is not known until something draws, and the driver + // reads the demand before it draws — see the `reserved` field. After a frame + // the picker has been told and this term is zero; before one it is the + // whole block, and the total is the same number either way — which is what + // stops the overlay opening a block too short and growing under the + // operator's hands. + let tallest = self + .previews(width, theme) + .into_iter() + .map(|(_, height)| height) + .max() + .unwrap_or(0); + crate::rows::wrapped(&self.head(theme), width) + .saturating_add(self.offers.rows_wanted()) + .saturating_add(tallest.saturating_sub(self.reserved)) } } diff --git a/src/main.rs b/src/main.rs index c37c3a2..dcc84b0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1230,26 +1230,91 @@ async fn loop_over Result>( // picker stays a generic list and the nine other call sites keep the // keyboard they had. if let Some((open, Pick::Config(paths))) = picker.as_mut() { - let step = match key.code { - KeyCode::Right => Some(true), - KeyCode::Left => Some(false), - _ => None, - }; - if let Some(forward) = step { + // One key, not two directions. Until 0.33.0 the direction chose the + // next value; an arrow now opens the list, and a list has no + // direction to open in — so `Left` and `Right` are the same + // keystroke and are written as one. + let arrow = matches!(key.code, KeyCode::Right | KeyCode::Left); + // **An arrow opens the values; it no longer writes one.** + // + // Until 0.33.0 `Left`/`Right` here called `cycle_setting`, which wrote + // the scope file and reassigned `config` on the keystroke — the only + // unconfirmed write in this product reachable from a bare arrow key, + // and the reason the sentence "the bare `/config` list reports" could + // not be said. Every other managed surface writes one descent below a + // list, with the do-nothing row at index 0; this one keystroke was the + // exception. + // + // The affordance survives: an arrow on a setting still means "show me + // what this can be", and it opens on the value in force so the operator + // can see where they are before they move. `Enter` on a value is the + // confirmation, exactly as it is when the row is chosen rather than + // arrowed at. `Up`/`Down` move inside the descent. + // + // **What `Left`/`Right` do inside a descent depends on which descent + // it is.** A values descent is a `Pick::ConfigValue`, so they miss this + // interception entirely and end at `Picker::key`'s `_ => Outcome::Idle` + // like every other surface. `prices.as_of`'s act descent is another + // `Pick::Config`, so an arrow there re-enters *this* arm — and what + // makes that harmless is the sentinel arm below, which names both of + // its rows and does nothing for either, rather than any property of + // `value_rows`. + if arrow { let row = open.selection(); let chosen = row.and_then(|row| paths.get(row)).cloned(); - if let Some(key_name) = - chosen.filter(|name| name.as_str() != io_cli::configure::REFRESH_PRICES) - { - cycle_setting( - session.root(), - &mut config, - &mut app, - &key_name, - forward, - open, - row.unwrap_or(0), - ); + match chosen.as_deref() { + // A descent's rows are acts, not keys: row 0 declines and the + // other one re-reads the price catalogue. Neither has values to + // open and neither is a key to say anything about. + None + | Some(io_cli::configure::DECLINE) + | Some(io_cli::configure::REFRESH_PRICES) => {} + Some(key_name) => match value_rows(session.root(), &config, key_name) { + Some((mut values, kind)) => { + // The value in force, found by label rather than by an + // index into the row list — the rows carry `leave it`, + // `unset it` and sometimes `elsewhere`, and their + // positions are `value_rows`' business rather than this + // arm's. + let setting = io_cli::configure::setting(&config, key_name); + let bare = setting + .value + .as_deref() + .map(|value| value.trim().trim_matches('"').to_string()); + if let Some(current) = bare { + if let Some(at) = values + .rows() + .iter() + .position(|candidate| candidate.label == current) + { + values.focus(at); + } + } + picker = Some((values, kind)); + } + // **A key whose values cannot be offered still answers the + // keystroke.** `value_rows` says `None` for a typed value + // and for every key the catalogue does not name — which is + // every operator-written key `settings()` sweeps in from + // `config.origins()`. Silence would read as a broken key, + // so it says what to do instead, and `record` rather than + // `say` because the picker stays open over the footer the + // next keystroke would take back anyway. + None => { + let said = if io_cli::configure::kind_of(key_name).is_none() { + format!( + "{key_name} is not a key io-cli knows the values of; press \ + Enter to type one" + ) + } else { + format!( + "{key_name} takes a value no list can hold; press Enter and \ + the row says the shape it wants" + ) + }; + app.record(Tone::Muted, said); + } + }, } paint(screen, &mut app)?; continue; @@ -3257,11 +3322,29 @@ async fn loop_over Result>( } } Pick::Config(paths) => match paths.get(index).map(String::as_str) { + // **Row 0 of a descent, and it must be matched before + // the key arm below.** Since 0.33.0 this same `Pick` + // carries the rows of `prices.as_of`'s descent, whose + // first row is the do-nothing one every confirmation in + // this product opens on. Without this arm it falls into + // `Some(key)` and the surface opens a scope picker for a + // setting called "leave it". + // + // The key is `configure::DECLINE` (`!leave-it`) rather + // than the label the operator reads: TOML admits + // `"leave it" = true` as a quoted bare key, and + // `settings` sweeps every key out of `config.origins`, + // so the bare string would have been a sentinel inside + // a space that admits real keys. `REFRESH_PRICES` + // carries a `!` for the same reason. + Some(io_cli::configure::DECLINE) => {} // The one row on this surface that acts rather than // naming something. It re-reads the catalogue the // operator's provider serves and writes what moved // into the scope that already declares the prices — - // or the user scope, for a first fill. + // or the user scope, for a first fill. It is reached + // one descent below the date it writes, never from the + // bare list. Some(io_cli::configure::REFRESH_PRICES) => { refresh_prices(screen, &mut app, &config, &spec, session.root()) .await?; @@ -4418,8 +4501,26 @@ async fn loop_over Result>( Action::Manage(line) => { let root = session.root().to_path_buf(); let tokens = io_cli::manage::tokens(&line); + // **The declared bundles are handed in rather than read here.** + // `plugin remove ` resolves a word against what is + // declared, and the read that answers that is + // `Config::plugins()` — a full re-parse of every manifest, + // confined to `src/resolved.rs` by `tests/dependencies.rs`. + // This session already resolved it once; passing it keeps the + // parse where the release put it. + // + // **Re-resolved first when the files moved, because this door + // is reached without a turn in between.** `holdings` is + // otherwise refreshed at the turn boundary alone, so `/plugin + // add ./bundles/x` followed by `/plugin remove x` resolved the + // name against the set from *before* the add and refused a + // bundle that had just been declared — while `io plugin remove + // x` in a shell worked, because the argument door loads fresh. + refresh_holdings(&mut holdings, &config); + let declared = + io_cli::pluginview::ids(&io_cli::pluginview::view(holdings.loaded())); match io_cli::manage::parse(&tokens).and_then(|request| { - io_cli::manage::plan(&root, &request).map(|plan| (request, plan)) + io_cli::manage::plan(&root, &request, &declared).map(|plan| (request, plan)) }) { // The refusals are finished sentences naming what was // wrong and what is accepted, so they are printed as they @@ -5456,64 +5557,24 @@ async fn loop_over Result>( ); } Action::Config(None) => { - // **What the routing rules say, and where they will not fire**, - // said on the surface that edits them. `/config` is where an - // operator meets `[app.io-cli.routing]`, and a section they can - // change here without being told it is inert for their session - // is the defect this release exists partly to avoid. Read from - // the configuration in force rather than the startup snapshot, - // so an edit made this session is what is described. - if let Some(routing) = io_cli::settings::stored(&config) - .0 - .and_then(|stored| stored.routing) - { - if let Some(said) = io_cli::routing::describe(&routing) { - app.record(Tone::Muted, said); - } - // **Why the section is not routing anything, if it is - // not.** The pair of `contract::gate_notice`: this - // surface lists the four routing keys, so a section - // that is plainly in the operator's file and doing - // nothing has to say why here or nowhere. - if let Some(why) = io_cli::routing::notice(&routing) { - app.record(Tone::Refused, why); - } - // `contained` and not `containment.is_some()`: `/contain off` - // puts the session back on the flat loop, where the rules do - // fire, so a warning keyed on the configuration would go on - // being shown to an operator it no longer applies to. - if let Some(notice) = - io_cli::routing::inert_under_containment(&routing, contained) - { - app.record(Tone::Warning, notice); - } - } + routing_notices(&mut app, &config, contained); let settings = io_cli::configure::settings(&config); - let mut paths: Vec = settings.iter().map(|s| s.path.clone()).collect(); - let mut rows = io_cli::configure::rows(&settings); - // **Last, because it is the one row that acts on its own.** - // Every other row names a setting and descends into its - // values — since 0.28.0 it is only the four keys no menu can - // hold that still reach the composer, and this row reaches - // neither. A reader - // scanning for `policy.defaults.write` should not have to step - // over something that does work on the way there. - rows.push(io_cli::configure::refresh_row(&io_cli::configure::setting( - &config, - "prices.as_of", - ))); - paths.push(io_cli::configure::REFRESH_PRICES.to_string()); + let paths: Vec = settings.iter().map(|s| s.path.clone()).collect(); + let rows = io_cli::configure::rows(&settings); + // **Every row here names a setting, and none of them acts.** + // Until 0.33.0 the price refresh sat at the end of this list, + // which made the bare surface a place where a keystroke could + // read the network and write a file — and that is why `/config` + // could not run while a turn was in flight. It now lives one + // descent below `prices.as_of`, the date it writes, reached the + // way every other write in this product is reached. picker = Some((Picker::new("Which setting?", rows), Pick::Config(paths))); } // A key with no value is a question. Naming what is in force and // which file decided it is the answer, and nothing is written. Action::Config(Some((key, value))) if value.is_empty() => { let setting = io_cli::configure::setting(&config, &key); - let what = setting.value.as_deref().unwrap_or("not set"); - app.record( - Tone::Muted, - format!("{key} is {what} ({})", setting.decided.word()), - ); + app.record(Tone::Muted, io_cli::configure::said(&setting)); } // **`/mcp`'s edit verb comes back through here, and it comes back // addressed by the server's own id.** The composer line `/mcp` @@ -5609,8 +5670,8 @@ async fn loop_over Result>( app.record( Tone::Refused, format!( - "{key} is written by the price refresh rather than typed; the last row \ - of `/config` re-reads the catalogue and dates it" + "{key} is written by the price refresh rather than typed; choose it \ + on `/config` and the refresh is the row after `leave it`" ), ); } @@ -6511,11 +6572,7 @@ async fn loop_over Result>( // window shows on the next message — and `/plugin` and // `/skills` read whatever this left, which is why opening // them costs no resolution at all when nothing has changed. - if tokio::task::block_in_place(|| holdings.stale(&config)) { - holdings = tokio::task::block_in_place(|| { - io_cli::resolved::Resolved::load(&config) - }); - } + refresh_holdings(&mut holdings, &config); skills = commands::skills( &io_cli::home::authored().unwrap_or_default(), io_cli::contract::skills_dir( @@ -6801,19 +6858,70 @@ fn forms(app: &App) -> (bool, io_cli::term::Graphics) { } } -/// **The agent's own look, committed where it looked.** +/// **What the routing rules say, and where they will not fire**, said on the +/// surface that edits them. /// -/// A wrapper over [`io_cli::attach::viewed`], which is where every decision +/// `/config` is where an operator meets `[app.io-cli.routing]`, and a section they +/// can change here without being told it is inert for their session is the defect +/// this release exists partly to avoid. Read from the configuration in force +/// rather than the startup snapshot, so an edit made this session is what is +/// described. +/// +/// **One speller for two doors**, which is why it is a function rather than the +/// block it was until 0.33.0: `/config` bare opens the same list at an idle prompt +/// and while a turn is in flight, and this product's commonest defect is a surface +/// answered one way by one door and another way by the other. +/// +/// `contained` is the session's switch and not `containment.is_some()`: `/contain +/// off` puts the session back on the flat loop, where the rules do fire, so a +/// warning keyed on the configuration alone would go on being shown to an operator +/// it no longer applies to. Inside `turn()` the two are the same fact — the caller +/// hands the turn its caps as `contained.then_some(…)` — so the mid-turn door +/// passes `containment.is_some()` and says exactly what the idle one says. +fn routing_notices(app: &mut App, config: &Config, contained: bool) { + let Some(routing) = io_cli::settings::stored(config) + .0 + .and_then(|stored| stored.routing) + else { + return; + }; + if let Some(said) = io_cli::routing::describe(&routing) { + app.record(Tone::Muted, said); + } + // **Why the section is not routing anything, if it is not.** The pair of + // `contract::gate_notice`: this surface lists the four routing keys, so a + // section that is plainly in the operator's file and doing nothing has to say + // why here or nowhere. + if let Some(why) = io_cli::routing::notice(&routing) { + app.record(Tone::Refused, why); + } + if let Some(notice) = io_cli::routing::inert_under_containment(&routing, contained) { + app.record(Tone::Warning, notice); + } +} + /// A keystroke while a picker is open on top of a running turn. /// -/// **Only the three kinds `turn()` can open reach here**, and that is the whole +/// *(Two orphaned lines opened this block until 0.33.0 — the first half of +/// [`commit_viewed`]'s doc, which had been left stranded above the wrong item and +/// has since been put back on it. Nothing in the suite can see a rustdoc summary +/// attached to the wrong item, so it survived every gate.)* +/// +/// **Only the four kinds `turn()` can open reach here**, and that is the whole /// safety argument: the palette and path completion set or paste into the -/// composer, and `/theme` resolves a theme. None of them writes a file, touches -/// the store, or reassigns anything the running turn is holding — which is the -/// property that lets a picker be open at all while a turn owns the session. +/// composer, `/theme` resolves a theme, and `/config` bare reports a setting. +/// None of them writes a file, touches the store, or reassigns anything the +/// running turn is holding — which is the property that lets a picker be open at +/// all while a turn owns the session. /// /// It lives beside the loop rather than inside it so the `select!` arm stays /// readable; every decision in it is a library call. +/// +/// The argument list is long because this function is the wiring: each of these is +/// something the running turn already holds and this function only reads. Bundling +/// them into a struct would put a type between the loop and the borrow checker for +/// no gain, which is the shape `cycle_setting` carried before it was deleted. +#[allow(clippy::too_many_arguments)] fn mid_turn_picker( picker: &mut Option<(Picker, Pick)>, app: &mut App, @@ -6822,6 +6930,14 @@ fn mid_turn_picker( policy: &Policy, templates: &io_harness::Templates, skills: &[io_cli::skillview::Listed], + // **Shared, never owned**, and it decides the *origin* rather than the value. + // `configure::setting` uses this in-memory configuration only to find which + // file decided a key, then reads that key's value off the disk at call time — + // long-standing behaviour, shared with the idle door. So a report mid-turn is + // a mixed reading: the origin the turn was built with, the value as the file + // stands now. A key deleted underneath the turn therefore reads as "not set" + // with a scope still named beside it. + config: &io_harness::config::Config, key: crossterm::event::KeyEvent, ) { let Some((open, kind)) = picker.as_mut() else { @@ -6877,6 +6993,25 @@ fn mid_turn_picker( "that row is the note, not a file — the rest of the listing is not shown", ), }, + // **`/config` bare, and it reports.** Choosing a row mid-turn says + // what is in force and which file decided it — the same sentence + // `Action::Config(Some((key, "")))` produces in the idle loop — + // rather than descending toward a write. The descent exists and is + // reachable when no turn is running; here the surface is a reader. + // + // Nothing needs guarding beyond this arm. `picker` is a local of + // `turn()`, so a picker opened here is dropped when the turn ends + // and can never reach the idle loop's write-capable arm; and the + // arrow that opens a key's values is intercepted in the idle loop + // alone — there is no such interception in this function, so + // `Left`/`Right` do reach `Picker::key` here and end at its + // `_ => Outcome::Idle`. + Pick::Config(paths) => { + if let Some(key) = paths.get(index) { + let setting = io_cli::configure::setting(config, key); + app.record(Tone::Muted, io_cli::configure::said(&setting)); + } + } // `turn()` opens no other kind. An assertion rather than a // silence, so opening one without wiring it here is caught in a // debug build. @@ -6941,6 +7076,9 @@ fn commit_image( Ok(()) } +/// **The agent's own look, committed where it looked.** +/// +/// A wrapper over [`io_cli::attach::viewed`], which is where every decision /// lives. Nothing is decided here, deliberately: `src/main.rs` is linked by no /// integration test, so a branch written here could not be sabotaged and would /// not be covered. @@ -7389,6 +7527,7 @@ async fn turn( policy, templates, skills, + config, key, ); Command::None @@ -7739,6 +7878,37 @@ async fn turn( Pick::Theme, )); } + // **`/config` bare, and only bare.** + // `runs_mid_turn` refuses every argued form, so + // the `Some(..)` arms — which descend toward a + // write, or are one — cannot reach this loop at + // all. What opens here is the list of settings + // with no acting row on it, and choosing one + // reports through `mid_turn_picker`. + // + // The picker is a local of `turn()`, so it is + // dropped when the turn ends and can never be + // inherited by the idle loop's write-capable + // arm. + Action::Config(None) => { + // The same prose the idle door says before + // it opens the same list — see + // `routing_notices`. `containment.is_some()` + // *is* the session's `contained` in here: + // the caps reach a turn only through + // `contained.then_some(…)`. + routing_notices(app, config, containment.is_some()); + let settings = io_cli::configure::settings(config); + let paths: Vec = + settings.iter().map(|s| s.path.clone()).collect(); + picker = Some(( + Picker::new( + "Which setting?", + io_cli::configure::rows(&settings), + ), + Pick::Config(paths), + )); + } // `runs_mid_turn` admits exactly the words // above, so nothing else can reach here. It // is an assertion rather than a wildcard so @@ -8430,13 +8600,29 @@ fn observing( Ok(out) } -/// The last run of this session, if it has had one. +/// Re-resolve the declared bundles, but only if the disk has moved under them. +/// +/// **One function because there are two moments, and they must not drift.** The +/// turn boundary refreshes so an edit made in another window shows on the next +/// message; `/plugin` and `/mcp` refresh because a bundle added earlier in the +/// same idle stretch has to be removable by name from the session, which it was +/// not until 0.33.0 — `io plugin remove ` worked from a shell and the +/// session answered that no bundle by that name was declared. Two doors, one +/// answer, and this product's commonest defect is the release where only one of +/// them got the fix. /// -/// **Its doc comment was taken by an insertion in this release** — `undo_whole_turn` -/// and `observing` were added directly beneath it, so this sentence became their -/// summary line and this function was left with none. Found by the adversarial -/// review, and worth noting because nothing in the suite can see a rustdoc -/// summary attached to the wrong item. +/// `stale` stats the declared manifests and the configuration files and parses +/// nothing, so the once-per-session resolution `src/resolved.rs` exists for is +/// kept: a session that changes no file still resolves exactly once. +/// `tests/dependencies.rs` counts the `Resolved::load` sites on the interactive +/// path and holds them at two — this is one of them, and adding a third would be +/// a resolution somebody put back on a per-turn path. +fn refresh_holdings(holdings: &mut io_cli::resolved::Resolved, config: &Config) { + if tokio::task::block_in_place(|| holdings.stale(config)) { + *holdings = tokio::task::block_in_place(|| io_cli::resolved::Resolved::load(config)); + } +} + /// The session facts a mid-turn report needs, read before the turn borrows the /// session. /// @@ -8449,6 +8635,13 @@ struct TurnFacts { last: Option, } +/// The last run of this session, if it has had one. +/// +/// **This summary was attached to the wrong item for two releases.** An insertion +/// put `TurnFacts` between the sentence and the function it describes, so +/// `TurnFacts` opened with two summary lines and this function had none. Nothing +/// in the suite can see a rustdoc summary attached to the wrong item, which is why +/// it took a reading pass to find and is worth a note rather than a silent fix. fn last_run(session: &Session, store: &Store) -> Option { session .transcript(store) @@ -9568,7 +9761,20 @@ async fn manage_main( } _ => {} } - let Some(plan) = io_cli::manage::plan(root, &request)? else { + // **Resolved only for the one verb that needs it.** `Config::plugins()` is a + // full re-parse of every declared manifest, so building a view for every + // `io mcp …` and `io config …` would make this door pay a plugin resolve to + // edit a server. Every other request takes the empty slice, which the + // parameter's own documentation says is correct for it. + let declared = match &request { + io_cli::manage::Request::Plugin(io_cli::manage::PluginVerb::Remove { .. }) => { + io_cli::pluginview::ids(&io_cli::pluginview::view( + io_cli::resolved::Resolved::load(config).loaded(), + )) + } + _ => Vec::new(), + }; + let Some(plan) = io_cli::manage::plan(root, &request, &declared)? else { return Ok(io_cli::exec::OK); }; // **The disclosure comes before the write on this door too, and the typed @@ -9603,9 +9809,13 @@ async fn manage_main( for line in disclosure.said(&io_cli::glyphs::ASCII) { eprintln!("{line}"); } + // **The spelling is this door's own.** An operator reading this is standing + // in a shell, not in a session, so the command offered is the one they can + // type where they are — and since 0.33.0 it resolves a bundle's name, which + // is what this sentence promised and could not deliver before. eprintln!( "installing it now: naming a bundle on this door is the consent, and there is \ - nobody here to ask. `/plugin remove {}` takes it back out.", + nobody here to ask. `io plugin remove {}` takes it back out.", disclosure.id, ); } @@ -9803,6 +10013,14 @@ fn value_rows( config: &io_harness::config::Config, key: &str, ) -> Option<(Picker, Pick)> { + // **Before `kind_of`, deliberately.** A key whose value is machine-written has + // no `Kind` and would leave by the `?` below — which is right for typing one + // and wrong for the act that writes it. `prices.as_of` is the only key with a + // descent of acts rather than values, and `configure::descent` answers for it + // alone, so a machine-written key added later inherits nothing. + if let Some((title, rows, keys)) = io_cli::configure::descent(config, key) { + return Some((Picker::new(title, rows), Pick::Config(keys))); + } let kind = io_cli::configure::kind_of(key)?; let setting = io_cli::configure::setting(config, key); let current = setting.value.clone(); @@ -9896,107 +10114,6 @@ fn value_rows( )) } -/// Cycle one `/config` row's value where it stands, and redraw the list around it. -/// -/// **Every decision here is a library call**; what is in this function is the -/// wiring, which is all this file may hold. `configure::cycled` decides whether -/// the kind can be cycled at all and what the next value is, `configure:: -/// destination` decides which file it lands in, `configure::spell_value` decides -/// how it is written, and `configure::widens_project` decides whether that file -/// will accept it. -/// -/// The row is rebuilt from a re-read configuration rather than patched in place, -/// so the value and the origin column on screen are the file's own answer and not -/// this function's account of what it just did. `selecting` keeps the marker on -/// the row the operator is holding an arrow on. -#[allow(clippy::too_many_arguments)] -fn cycle_setting( - root: &std::path::Path, - config: &mut io_harness::config::Config, - app: &mut io_cli::app::App, - key: &str, - forward: bool, - open: &mut Picker, - selecting: usize, -) { - let Some(kind) = io_cli::configure::kind_of(key) else { - // A key the catalogue does not name has no kind and cannot be cycled. - // Silence would read as a broken key, so it says what to do instead. - app.record( - Tone::Muted, - format!("{key} is not a key io-cli knows the values of; press Enter to type one"), - ); - return; - }; - let setting = io_cli::configure::setting(config, key); - let Some(next) = io_cli::configure::cycled(&kind, setting.value.as_deref(), forward) else { - app.record( - Tone::Muted, - format!("{key} is chosen from a list rather than cycled; press Enter to open it"), - ); - return; - }; - let (scope, inherited) = io_cli::configure::destination(config, key); - // **Said before it is attempted, because the cost is not one key.** - // `refuse_widening` runs before deserialization, so a widening value in a - // committed file makes the whole file stop parsing rather than that setting - // being rejected. `write` would catch it and report io-harness's own sentence - // — this reports it without touching the file at all. - if scope == io_harness::config::Scope::Project && io_cli::configure::widens_project(key, &next) - { - app.record( - Tone::Refused, - format!( - "{key} is decided by the project file, and a committed file may not set it to \ - {next} — io-harness refuses the whole file for it, not just the key. Choose \ - another value, or move the key to another file with `/config {key} {next}`." - ), - ); - return; - } - let was = setting - .value - .clone() - .unwrap_or_else(|| "default".to_string()); - let edit = io_cli::edit::Edit::set(key, io_cli::configure::spell_value(&kind, &next)); - match io_cli::configure::write(root, scope, &[edit]) { - Err(refusal) => app.record(Tone::Refused, refusal), - Ok(()) => { - match io_cli::configure::reload(root) { - Ok((fresh, _)) => *config = fresh, - Err(error) => app.record(Tone::Error, error), - } - let settings = io_cli::configure::settings(config); - let mut rows = io_cli::configure::rows(&settings); - rows.push(io_cli::configure::refresh_row(&io_cli::configure::setting( - config, - "prices.as_of", - ))); - open.set_rows(rows, selecting); - // **`record`, not `say`.** A one-slot footer notice is overwritten by - // the next arrow press, and an operator cycling through four values - // would end with three of the four changes unrecorded anywhere. This - // is the rule 0.26.0 wrote down and 0.27.0 shipped a violation of. - app.record( - Tone::Success, - format!( - "{key}: {was} → {next}, in the {} scope{}", - io_cli::configure::Decided::File { - scope, - path: Default::default(), - } - .word(), - if inherited { - ", which already decided it" - } else { - ", which is where a key no file named goes" - } - ), - ); - } - } -} - fn write_where(root: &std::path::Path, key: String, value: String) -> (Picker, Pick) { let paths: Vec<(io_harness::config::Scope, std::path::PathBuf)> = [ io_harness::config::Scope::User, diff --git a/src/manage.rs b/src/manage.rs index 1dd8ac4..f58608b 100644 --- a/src/manage.rs +++ b/src/manage.rs @@ -186,9 +186,17 @@ pub enum PluginVerb { /// Every bundle in every added marketplace whose name or description carries /// `text`. A read: it opens no file and writes none. Search { text: String }, - /// Undeclare a bundle. No scope: the file that named it is the file the + /// Undeclare a bundle, named **by the directory it lives in or by the id its + /// manifest carries**. No scope: the file that named it is the file the /// removal has to go to, and [`plan`] finds it. - Remove { path: PathBuf }, + /// + /// A `String` and not a `PathBuf`, because a path is only the first of the two + /// readings and the second one is a name. Which it was is decided against the + /// disk in [`plan`] and never here — the path first, always — for + /// [`crate::marketplace::chosen`]'s reason: a parse that judged the *shape* of + /// the word would make one word mean a directory on a machine that has one and + /// a bundle's name on a machine that does not. + Remove { word: String }, /// The repositories bundles are fetched from. /// /// **A nested enum rather than three more variants here, and the argument is @@ -490,7 +498,10 @@ pub fn parse(tokens: &[String]) -> Result { args.no_scope("plugin remove")?; args.only("plugin remove", &[])?; Ok(Request::Plugin(PluginVerb::Remove { - path: PathBuf::from(args.one_word("plugin remove", "the directory of a bundle")?), + word: args.one_word( + "plugin remove", + "the directory of a bundle, or the name of a bundle that is declared", + )?, })) } ("config", Some("get")) => { @@ -542,7 +553,14 @@ pub fn parse(tokens: &[String]) -> Result { fn verbs(surface: &str) -> &'static str { match surface { "mcp" => "`add`, `list`, `get`, `edit`, `enable`, `disable`, `probe` and `remove`", - "plugin" => "`add` (also spelled `install`), `list`, `search`, `remove` and `marketplace`", + // `remove` takes the same two readings `add` does — a directory, or the + // name of a bundle — and says so here, because an operator who was refused + // is being told what to type next and `remove ` alone would send the + // one holding a name to the documentation. + "plugin" => { + "`add ` (also spelled `install`), `list`, `search `, \ + `remove ` and `marketplace`" + } "skill" => "`add `, `list` and `remove `", _ => "`get`, `set`, `unset` and `list`", } @@ -616,6 +634,24 @@ fn judged(text: &str) -> Result { }) } +/// Which file a `config` write lands in: the one `--scope` named, or the one +/// already deciding the key. +/// +/// **The same answer `/config`'s descent gives**, through the same function, so +/// the two entry paths cannot disagree about where a key lives. A configuration +/// that cannot be discovered at all is not a reason to refuse the write — the +/// caller's own `configure::write` reports that far better than a guess here +/// would — so it falls back to the user scope, which is where a key nothing +/// decides goes anyway. +fn decided_scope(root: &Path, key: &str, asked: Option) -> Scope { + if let Some(scope) = asked { + return scope; + } + io_harness::config::Config::discover(root) + .map(|config| crate::configure::destination(&config, key).0) + .unwrap_or(Scope::User) +} + /// Turn a request into the file and the edits that carry it out. /// /// `Ok(None)` is a **read** — `list`, `get` — which writes nothing and is @@ -646,25 +682,23 @@ fn judged(text: &str) -> Result { /// [`crate::servers::declared_in`] and [`crate::pluginview::declared_at`]. A /// `--scope` there is refused at parse rather than honoured, because honouring it /// would aim an index counted in one file's array at a different file's. -/// Which file a `config` write lands in: the one `--scope` named, or the one -/// already deciding the key. /// -/// **The same answer `/config`'s descent gives**, through the same function, so -/// the two entry paths cannot disagree about where a key lives. A configuration -/// that cannot be discovered at all is not a reason to refuse the write — the -/// caller's own `configure::write` reports that far better than a guess here -/// would — so it falls back to the user scope, which is where a key nothing -/// decides goes anyway. -fn decided_scope(root: &Path, key: &str, asked: Option) -> Scope { - if let Some(scope) = asked { - return scope; - } - io_harness::config::Config::discover(root) - .map(|config| crate::configure::destination(&config, key).0) - .unwrap_or(Scope::User) -} - -pub fn plan(root: &Path, request: &Request) -> Result, String> { +/// # `declared`, and why it is handed in rather than read +/// +/// Every bundle the caller's already-resolved view names, as `(id, directory)` +/// pairs — [`crate::pluginview::ids`] builds it — and **the `plugin remove` arm is +/// the only thing in this function that reads it**. It is a parameter because +/// reading it here would mean calling `Config::plugins()`, which is not an +/// accessor: it re-reads, re-parses and re-trust-checks every declared manifest +/// off the disk on every call, and `tests/dependencies.rs` confines it by exact +/// path to `src/resolved.rs`. Both doors already hold the view, so nothing new is +/// computed by passing it; a door planning any other request may pass an empty +/// slice. +pub fn plan( + root: &Path, + request: &Request, + declared: &[(String, PathBuf)], +) -> Result, String> { let plan = match request { // **No skill verb plans an `Edit`, and none ever will.** A skill is a // markdown file in io-cli's own home; no configuration file declares one, @@ -789,15 +823,8 @@ pub fn plan(root: &Path, request: &Request) -> Result, String> { disclosure, } } - Request::Plugin(PluginVerb::Remove { path }) => { - let dir = resolve(root, path); - let (scope, index) = crate::pluginview::declared_at(root, &dir).ok_or_else(|| { - format!( - "no configuration file declares {}, so there is no `[[plugin]]` entry to \ - remove; `plugin list` shows what is declared", - dir.display() - ) - })?; + Request::Plugin(PluginVerb::Remove { word }) => { + let (scope, index) = removal(root, word, declared)?; Plan { scope, edits: vec![crate::pluginview::remove(index)], @@ -885,6 +912,80 @@ fn declared_server(root: &Path, id: &str) -> Result }) } +/// Which `[[plugin]]` entry `plugin remove ` means: the directory, or the +/// bundle of that name. +/// +/// **The path is read first and the disk is what answers**, which is +/// [`crate::marketplace::chosen`]'s standing rule for the same word on `plugin +/// add` and is deliberately the same rule here. Nothing about the *shape* of the +/// word is read — no rule about a `/`, a leading `.` or an extension — so +/// `io plugin remove ./bundles/rust-review` keeps meaning exactly what +/// `docs/guide/headless.md` says it means, and a directory that is declared is +/// always removed as one. +/// +/// Only when no configuration file declares that directory is the word read as a +/// bundle's id, over the set the caller already resolved. **Every hit is collected +/// and the first is never taken.** [`crate::pluginview::Listed::id`] is unique +/// among the bundles io-harness *loaded* — two declared `enabled = false` may +/// share one, which is the swap the flag exists for — so taking one of them +/// deletes a `[[plugin]]` entry the operator never pointed at, silently, and they +/// find out when a bundle's skills stop being offered. Two candidates are +/// therefore refused with **both directories named**: the directory is the +/// spelling that tells them apart, every listing already prints it, and it is the +/// spelling that resolves through the path reading above. +/// +/// The refused and dropped entries are in `declared` too, and they are exactly the +/// entries an operator most wants gone — a bundle whose manifest will not parse is +/// one they cannot fix from the manifest. +fn removal( + root: &Path, + word: &str, + declared: &[(String, PathBuf)], +) -> Result<(Scope, usize), String> { + let typed = resolve(root, Path::new(word)); + if let Some(at) = crate::pluginview::declared_at(root, &typed) { + return Ok(at); + } + let hits = crate::pluginview::by_id(declared, word); + let dir = match hits.as_slice() { + [only] => *only, + [] => { + return Err(format!( + "no configuration file declares {}, and no bundle one declares is called \ + `{word}`, so there is no `[[plugin]]` entry to remove; `plugin list` shows what \ + is declared", + typed.display() + )) + } + several => { + let spellings = several + .iter() + .map(|dir| format!("`{}`", dir.display())) + .collect::>() + .join(" and "); + return Err(format!( + "{} declared bundles are called `{word}`, and removing whichever was found first \ + would take away an entry you did not choose; say which one by its directory: \ + {spellings}", + several.len() + )); + } + }; + // A bundle that is in the resolved view was declared by *some* file, so this + // is not the ordinary miss above: it is a declaration whose written path does + // not match the directory io-harness read it from — a symlinked root, most + // likely — and saying which directory was looked for is the whole of what an + // operator can act on. + crate::pluginview::declared_at(root, dir).ok_or_else(|| { + format!( + "the bundle called `{word}` was read from {}, and no configuration file declares that \ + directory, so there is no `[[plugin]]` entry to remove; `plugin list` shows what is \ + declared", + dir.display() + ) + }) +} + /// A typed path, against the discovery root when it is relative. fn resolve(root: &Path, path: &Path) -> PathBuf { if path.is_absolute() { @@ -1271,7 +1372,7 @@ fn mcp_add(args: &Args) -> Result { // Built by hand rather than through `McpServer::stdio(…).with_args(…)`, // because `env` and `headers` have no builder at all and `with_args` is a - // silent no-op on an HTTP server (`io-harness-0.71.0/src/mcp.rs:439-447`: the + // silent no-op on an HTTP server (`io-harness-0.72.0/src/mcp.rs:439-447`: the // body writes only into the `Stdio` arm) — a constructor chain here would drop // the arguments of half the servers it was handed and say nothing. // Asked of the harness rather than written as literals, the way `servers::add` diff --git a/src/memory.rs b/src/memory.rs index b58e89f..39bce26 100644 --- a/src/memory.rs +++ b/src/memory.rs @@ -27,7 +27,7 @@ //! //! Worth knowing before a surface promises otherwise. `read_instructions` joins //! each name in `[instructions] files` to the **discovery root** -//! (`io-harness-0.71.0/src/config.rs:2111`), and the default list is exactly +//! (`io-harness-0.72.0/src/config.rs:2111`), and the default list is exactly //! `["AGENTS.md"]` (`DEFAULT_INSTRUCTIONS`, `config.rs:159`). 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 @@ -464,7 +464,7 @@ fn bullet(line: &str) -> Option { /// The order the three files are named in, and it is the order they reach the /// model in: `read_instructions` pushes one constraint per name in list order -/// (`read_instructions`, `io-harness-0.71.0/src/config.rs:2111`). +/// (`read_instructions`, `io-harness-0.72.0/src/config.rs:2111`). /// /// Project, then local, then user — widest audience first, so the operator's own /// standing note is the last thing said. It also matches the order the three diff --git a/src/picker.rs b/src/picker.rs index b6a2599..1b940d1 100644 --- a/src/picker.rs +++ b/src/picker.rs @@ -21,6 +21,15 @@ //! choice is load-bearing rather than cosmetic. What leaves this widget is //! unaffected: [`Outcome::Chosen`] and [`Picker::selected`] are indices into the //! caller's own row list at every point, filtered or not. +//! +//! **From 0.33.0 it can also take several answers, and only when it is asked +//! to.** [`Picker::accepting_several`] gives the spacebar to a marked set that +//! [`Picker::marked`] reads back — in the same unfiltered numbering as everything +//! else here, and surviving the query that hid a marked row. A picker that is not +//! told this treats a space as the query character it has always been, which is +//! what leaves every existing caller alone. + +use std::collections::{BTreeMap, BTreeSet}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::layout::{Position, Rect}; @@ -42,6 +51,22 @@ use crate::theme::{Theme, Tone}; /// is drawn *instead of* the composer, so the two never appear at once. const UNMARKED: &str = " "; +/// The box a plural picker draws before every markable row, checked and unchecked. +/// +/// **Spelled in ASCII in both glyph sets, and not taken off [`Glyphs`] at all.** +/// The two sets exist so a terminal that cannot draw a character still gets a +/// column of the same width; `[x] ` and `[ ] ` are four cells in either, and a +/// box drawn out of the box-drawing range would have to be paired with an ASCII +/// fallback that says exactly this. The `x` rather than a tick for the same +/// reason — a tick is not in the set every terminal has. +/// +/// It is drawn **only on a picker told it accepts several**, so a picker that +/// takes one answer looks exactly as it did before [`Picker::accepting_several`] +/// existed: same columns, same marker, same everything. +const CHECKED: &str = "[x] "; +/// The unchecked half of [`CHECKED`], the same width so the labels line up. +const UNCHECKED: &str = "[ ] "; + /// One choice. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Row { @@ -161,20 +186,47 @@ pub struct Picker { intent: Option, /// The first row currently drawn, so a long list scrolls instead of being cut. offset: usize, - /// A row that opens a block beneath itself while it holds the marker, and the - /// rows that block wants. + /// Whether the spacebar marks rows on this picker, set by + /// [`Picker::accepting_several`] and false everywhere else. + /// + /// **Opt-in rather than always on**, because marking costs the spacebar: on a + /// plural picker a space is a toggle and no longer a query character. Every + /// picker in the product filters as it is typed and several of them filter + /// over labels with spaces in them — `Any OpenAI-compatible endpoint`, a + /// session's `~/code/io-cli 1 turn` — so a spacebar taken from every + /// picker at once would be a filter that silently cannot express half the + /// rows it is filtering. + plural: bool, + /// Which rows are marked, **as indices into `rows`**, exactly like + /// [`Self::intent`] and never like [`Self::cursor`]. + /// + /// Empty on a picker that was never told it accepts several, which is what + /// makes this invisible to every existing caller: nothing can put an index in + /// here but the spacebar, and the spacebar does not mark unless `plural`. /// - /// **The index is into `rows`, like [`Self::intent`] and never like - /// [`Self::cursor`]**, so a query that reorders the list keeps the unfold on + /// A `BTreeSet` so the set comes back in the caller's own row order rather + /// than in the order the operator happened to press space, which is the order + /// a list of chosen rows has to be reported and applied in. + marks: BTreeSet, + /// Which rows open a block beneath themselves while they hold the marker, and + /// how many rows each of those blocks wants. + /// + /// **The keys are indices into `rows`, like [`Self::intent`] and never like + /// [`Self::cursor`]**, so a query that reorders the list keeps each unfold on /// the row it belongs to rather than on whatever ranked into that position. /// - /// Only one row can carry it, which is the whole mechanic: an accordion with - /// two panels open is two things competing for the rows beneath one marker. - /// 0.32.0 ships it with exactly one caller — the question overlay's free-text - /// row, which unfolds a composer — because a mechanic built without one is a - /// mode nobody can see, and this product shipped a whole pipeline reachable - /// only from its own tests one release ago. - unfold: Option<(usize, u16)>, + /// **One height per row rather than one height for the picker**, from 0.33.0. + /// The block a row opens is a preview of *that* row, and a preview wraps to a + /// different number of rows for every choice — a single height would be right + /// for one row and wrong for the rest, which on a `Paragraph` means either + /// blank rows under a short preview or a long one silently clipped. + /// + /// Only one block is ever **open**, which is the whole mechanic: an accordion + /// with two panels open is two things competing for the rows beneath one + /// marker. That is still true — the open block is the one belonging to the row + /// under the marker, and there is only ever one of those. What is reserved is + /// another question; see [`Self::rows_wanted`]. + unfolds: BTreeMap, /// Where the unfolded block was drawn by the last [`Self::render`], for the /// caller to draw into. /// @@ -194,7 +246,9 @@ impl Picker { query: String::new(), cursor: 0, offset: 0, - unfold: None, + plural: false, + marks: BTreeSet::new(), + unfolds: BTreeMap::new(), opened: None, }; // A grouped list opens with a heading in the first slot, and the marker @@ -219,6 +273,40 @@ impl Picker { self } + /// Take several answers: the spacebar marks and unmarks the row under the + /// marker, and [`Picker::marked`] is what the operator marked. + /// + /// **Opt-in, and every picker in the product is unchanged without it.** A + /// plural picker spends the spacebar on the toggle, so a space is no longer a + /// query character on it — and half the labels this widget filters over have + /// spaces in them, so a picker that took the spacebar from everybody would be + /// a filter that cannot spell the rows it is filtering. It also draws a box + /// before each row, which is a column the single-answer pickers do not pay + /// for. + /// + /// **The marked set survives the query, and that is the decision.** It is held + /// against the caller's own row indices rather than against the current match + /// set, so a row marked before a keystroke that hides it is still marked after + /// it, and still marked when a backspace brings it back. The alternative — + /// dropping a mark when the query stops admitting its row — makes the filter + /// destructive: an operator marking five rows out of four hundred narrows the + /// list to find each one, and every narrowing would throw away the marks made + /// under the last. It is the same defect the remembered marker row exists for + /// — the one a narrowing query used to destroy, so that `/fork` branched from + /// turn 0 — one state along, and it is answered the same way. That is why + /// marks *have* to be unfiltered indices rather than merely why it is tidy + /// that they are. + /// + /// The cost is stated rather than hidden: a marked row can be off the screen + /// while it is marked, so a caller acting on [`Picker::marked`] is acting on + /// rows the operator may not be able to see at that moment. Showing the count + /// is the caller's job — this widget owns one line and the query is already + /// on it. + pub fn accepting_several(mut self) -> Self { + self.plural = true; + self + } + /// Replace every row, keeping what has been typed and aiming at `selecting`. /// /// For a caller whose rows arrive after the picker is already on the screen — @@ -226,8 +314,15 @@ impl Picker { /// request is in flight. Building a fresh `Picker` instead would throw away /// whatever was typed during the wait, which on a four-hundred-model list is /// the only thing anybody does. + /// **The marks go with the rows they addressed.** They are indices into the + /// list being replaced, and the replacement is a different list — the model + /// step's four hundred models arriving over a one-row placeholder — so + /// carrying them would mark whatever now sits at those positions. The query + /// survives because it is text the operator typed; a mark is a row, and the + /// row is gone. pub fn set_rows(&mut self, rows: Vec, selecting: usize) { self.rows = rows; + self.marks.clear(); self.aim(selecting); } @@ -276,6 +371,44 @@ impl Picker { self.matches.get(self.cursor).copied() } + /// Which rows the spacebar has marked, as indices into [`Picker::rows`], in + /// the caller's own row order. + /// + /// The same numbering as [`Outcome::Chosen`], [`Picker::selected`] and + /// [`Picker::selection`], and it has to be: three call sites index a slice raw + /// with what this widget hands back, and a plural answer carrying filtered + /// positions would panic at those and act on the wrong rows at the rest — the + /// single-answer defect, once per marked row. + /// + /// **A marked row that the query no longer admits is still in here.** See + /// [`Picker::accepting_several`] for why that is the answer rather than an + /// oversight; the short of it is that a filter an operator uses to *find* the + /// rows to mark cannot also be what un-marks them. + /// + /// Always empty on a picker that was not told it accepts several. + pub fn marked(&self) -> Vec { + self.marks.iter().copied().collect() + } + + /// What this picker is answering with: every marked row, or — with nothing + /// marked — the row under the marker, if there is one. + /// + /// The plural [`Picker::selection`], and the reason it lives here rather than + /// at each call site: "the marks, unless there are none, in which case the + /// marker" is a rule that has to be spelled identically everywhere or an + /// operator who marked nothing and pressed Enter gets nothing at all. Written + /// once, it cannot be spelled differently in the second caller. + /// + /// Empty only when nothing is marked *and* nothing is under the marker: an + /// empty picker, or a query that admits nothing. + pub fn chosen(&self) -> Vec { + if self.marks.is_empty() { + self.selection().into_iter().collect() + } else { + self.marked() + } + } + /// What has been typed so far, which is what the top line shows. pub fn query(&self) -> &str { &self.query @@ -362,8 +495,25 @@ impl Picker { /// it holds the marker. /// /// `row` indexes the caller's own `rows`, the same as [`Outcome::Chosen`]. + /// + /// **Called once per unfolding row, not once per picker.** Each call records + /// that row's own height, and calling it again for the same row replaces that + /// height — which is what a caller re-measuring a preview after a resize does. + /// Through 0.32.0 the second call moved the one unfold onto the second row; + /// from 0.33.0 both rows keep theirs. + /// + /// **`height` is a measurement, and the only honest way to get it is + /// [`crate::rows::wrapped`].** A preview is text in a `Paragraph` with + /// wrapping on, so its height is a function of the width it will be drawn at + /// and `lines.len()` is not it — the count that has already cost this product + /// two defects, both of them content painted over rows something else had been + /// promised. A caller measures what it is about to draw, at the width it will + /// be drawn at, and hands the answer here. + /// + /// A `height` of zero is a row that unfolds nothing, which is how a caller + /// says "not this one" without a second spelling. pub fn set_unfold(&mut self, row: usize, height: u16) { - self.unfold = Some((row, height)); + self.unfolds.insert(row, height); } /// Put the marker on `row`, an index into the caller's own `rows`. @@ -383,14 +533,34 @@ impl Picker { /// Whether the unfolding row is the one under the marker right now. /// - /// The caller asks this to decide where a keystroke goes: with the block open - /// it belongs to whatever is drawn in it, and with it shut the picker filters - /// as it always has. + /// **Do not route a keystroke on this.** It said so until 0.33.0, and the + /// advice was safe only while exactly one row could unfold. Once several can — + /// an offer opening its preview beside the free-text row opening a composer — + /// the predicate is true over both, and `Enter` on an open preview goes to + /// whatever the *other* block holds. Ask the caller's own question instead: + /// `Intent::writing` asks whether the marker is on the row that takes typing, + /// which is what that routing ever meant. + /// + /// It remains as what it says: whether the block under the marker is open. + /// `src/` has no caller today — the one it had was the mistake above. pub fn unfolded_now(&self) -> bool { - match (self.unfold, self.matches.get(self.cursor)) { - (Some((row, height)), Some(under)) => height > 0 && row == *under, - _ => false, - } + self.open_block() > 0 + } + + /// How many rows the block that is open right now wants, and zero when none + /// is. + /// + /// **The height of the row under the marker**, which is the only block that + /// can be open — the accordion has one panel. Deliberately not the height + /// [`Self::rows_wanted`] reserves, and the two disagreeing is the point: what + /// is *drawn* follows the marker, what is *asked for* must not. See + /// [`Self::rows_wanted`]. + fn open_block(&self) -> u16 { + self.matches + .get(self.cursor) + .and_then(|row| self.unfolds.get(row)) + .copied() + .unwrap_or(0) } /// Where the unfolded block was drawn by the last [`Self::render`]. @@ -417,7 +587,20 @@ impl Picker { // tear-down and a cursor query per keystroke, on a surface that is open // while a turn is in flight. `render` still draws the block only when it // is open, so the reserved rows simply show more of the list meanwhile. - let block = self.unfold.map_or(0, |(_, height)| height); + // + // **The LARGEST configured unfold, never the focused one.** With a height + // per row, asking for the focused row's height puts the oscillation back + // in a second door: every arrow key between a one-row preview and a + // seven-row one would change the demand by six, and every keystroke that + // filtered a tall row out of the list would change it again. Reserving the + // largest is the only figure that is a function of the *configuration* + // rather than of the marker or the query — so it cannot move while the + // operator moves, which is the property this line exists for. It over-asks + // by the difference whenever a shorter row holds the marker; `App::viewport_wanted` + // clamps the total to the terminal anyway, and the surplus shows more of + // the list, which is the same surplus a configured-but-shut unfold has + // always produced. + let block = self.unfolds.values().copied().max().unwrap_or(0); list.saturating_add(block).saturating_add(1) } @@ -525,6 +708,29 @@ impl Picker { } Outcome::Idle } + // **The spacebar marks, and only on a picker told it accepts + // several.** It sits above the printable arm rather than inside it, + // so on every other picker in the product a space falls through to + // that arm and is a query character exactly as it has always been — + // which is what makes this change unobservable to the nine call + // sites that never mark anything. + // + // A heading is never marked, for the reason it is never chosen: it is + // not a row anybody can act on. No path that moves the marker leaves + // it on one, so this guard is the same belt-and-braces `Enter` keeps + // — and unlike `Enter`, `focus` can put the marker there. + KeyCode::Char(' ') + if self.plural + && !key.modifiers.contains(KeyModifiers::CONTROL) + && !key.modifiers.contains(KeyModifiers::ALT) => + { + if let Some(row) = self.matches.get(self.cursor).copied() { + if !self.rows[row].heading && !self.marks.remove(&row) { + self.marks.insert(row); + } + } + Outcome::Idle + } // Every printable character narrows. Modified characters are left // alone — `Ctrl+C` has already returned above, and a `Ctrl` or `Alt` // chord is a command somebody meant, not a letter they typed. @@ -582,11 +788,12 @@ impl Picker { // block is reserved rather than borrowed from the list, because a composer // squeezed into rows the list is also using is the overlap 0.32.0 exists to // end. - let block = if self.unfolded_now() { - self.unfold.map_or(0, |(_, height)| height) - } else { - 0 - }; + // The block **the marked row** opens, which is the one that can be open, + // and never the larger figure `rows_wanted` reserves: drawing the reserved + // height under a short preview would paint blank rows the list could have + // used, and drawing it under a tall one is the clipping that height is + // measured to avoid. + let block = self.open_block(); let room = (area.height as usize).saturating_sub(1 + block as usize); // **A row is spent saying what is not shown, and only when something is // not.** Until 0.32.0 a picker over four hundred rows drew the twenty it @@ -652,6 +859,21 @@ impl Picker { // the string actually about to be drawn; the two sets agree on its // width today and this does not depend on them continuing to. let marker_width = marker.chars().count(); + // The box, on a plural picker only, between the marker and the kind + // mark. **Drawn for every row rather than for the marked ones**, so + // the column exists before anything is in it: a box that appears only + // once a row is marked is a list that shifts sideways under the + // operator's eyes, and — worse — a plural picker with nothing marked + // yet would look exactly like a single-answer one, which is the whole + // thing the operator has to know before they press Enter. + let check = if !self.plural { + "" + } else if self.marks.contains(&self.matches[position]) { + CHECKED + } else { + UNCHECKED + }; + let check_width = check.chars().count(); // The kind mark rides here, between the marker and the label, and // never inside either. Not in the label, because the matcher ranks // the label and a shared first character makes both of its top tiers @@ -662,11 +884,14 @@ impl Picker { let mark_width = mark.chars().count(); let label = fit( &row.label, - width.saturating_sub(marker_width + mark_width), + width.saturating_sub(marker_width + check_width + mark_width), &theme.glyphs, ); let label_width = label.chars().count(); let mut spans = vec![Span::styled(marker, theme.style(Tone::Accent))]; + if !check.is_empty() { + spans.push(Span::styled(check, theme.style(Tone::Accent))); + } if !mark.is_empty() { spans.push(Span::styled(mark, theme.style(Tone::Muted))); } @@ -684,7 +909,7 @@ impl Picker { // reaches the buffer is a budget that disagrees with the row by // however much they differ — and this budget being one cell out is // precisely how an ellipsis ends up on the floor. - let used = marker_width + mark_width + label_width + 2; + let used = marker_width + check_width + mark_width + label_width + 2; if let Some(room) = width.checked_sub(used) { if room > 1 { spans.push(Span::styled(" ", theme.style(Tone::Muted))); @@ -703,11 +928,11 @@ impl Picker { // for. Reserved as blank rows here and drawn by the caller into the // rectangle recorded after the loop, because only this widget knows // where the row landed once the list has scrolled. - if block > 0 - && self - .unfold - .is_some_and(|(row, _)| row == self.matches[position]) - { + // `block` is already the height of the row under the marker and is + // zero when that row has no unfold, so the row it opens under is the + // marked one by construction — the second half of the old condition + // was asking the same question `open_block` has now answered. + if block > 0 && position == self.cursor { let top = area .y .saturating_add(u16::try_from(lines.len()).unwrap_or(u16::MAX)); @@ -777,6 +1002,9 @@ impl Picker { // where the row's own name begins, and a mark is a fact about the row // rather than part of what it is called — a reader following the caret // onto `: clear` has been pointed at punctuation. + // Past the BOX as well on a plural picker, for the same reason: the box + // says whether the row is marked, which is a fact about it rather than + // part of its name. let indent = if self.matches.is_empty() { 0 } else { @@ -786,7 +1014,12 @@ impl Picker { .and_then(|index| self.rows[*index].mark) .map(|mark| mark.chars().count() + 1) .unwrap_or(0); - (theme.glyphs.marker.chars().count() + mark) as u16 + let check = if self.plural { + CHECKED.chars().count() + } else { + 0 + }; + (theme.glyphs.marker.chars().count() + check + mark) as u16 }; frame.set_cursor_position(Position { x: (area.x + indent).min(area.right().saturating_sub(1)), diff --git a/src/pluginview.rs b/src/pluginview.rs index 8534c9c..9983d9e 100644 --- a/src/pluginview.rs +++ b/src/pluginview.rs @@ -1059,6 +1059,51 @@ pub fn declared_at(root: &Path, bundle: &Path) -> Option<(io_harness::config::Sc None } +/// Every declared bundle's id beside the directory it was read from — loaded, +/// switched off and refused alike. +/// +/// **The refused ones are in the list on purpose.** A bundle whose manifest will +/// not parse is one an operator cannot fix from the manifest and most wants gone, +/// and it is the one a name is the only handle on: [`Refused`] carries the id it +/// could read, or the directory's own name where it could not. +/// +/// Pure, and takes the view a surface already built: it is what a door hands +/// [`crate::manage::plan`] so that `plugin remove ` can be answered without +/// `Config::plugins()` — a full re-parse of every declared manifest, confined by +/// `tests/dependencies.rs` to `src/resolved.rs`. +/// +/// **The pairs are not a map and must not become one.** The id is unique among +/// the *loaded* bundles alone (see [`Listed::id`]), so two entries may share one +/// and a map would keep whichever was inserted last — which is the silent wrong +/// delete [`declared_at`] and [`remove`] are both written against. +#[must_use] +pub fn ids(view: &View) -> Vec<(String, PathBuf)> { + view.plugins + .iter() + .map(|plugin| (plugin.id.clone(), plugin.root.clone())) + .chain( + view.refused + .iter() + .map(|refused| (refused.id.clone(), refused.path.clone())), + ) + .collect() +} + +/// Every declared bundle called `id`, as the directory that identifies it. +/// +/// **Every hit, never the first.** The caller decides what to do with two of them; +/// what it may not do is pick, and a helper returning an `Option` would have made +/// that decision here where the count is still known. See [`Listed::id`] for why +/// there can be two, and [`declared_at`] for what taking the wrong one costs. +#[must_use] +pub fn by_id<'a>(declared: &'a [(String, PathBuf)], id: &str) -> Vec<&'a Path> { + declared + .iter() + .filter(|(name, _)| name == id) + .map(|(_, root)| root.as_path()) + .collect() +} + /// A TOML basic string, escaped. /// /// The twin of [`crate::servers`]'s own, which is private to that module — here diff --git a/src/prices.rs b/src/prices.rs index c47de01..9c0491e 100644 --- a/src/prices.rs +++ b/src/prices.rs @@ -392,15 +392,27 @@ pub fn refusal(text: &str) -> Option { /// against the rows it would replace in that same file. Swapping in the merged /// count would make a short write look safe whenever another scope happened to /// price enough models, which is the failure the guard exists to catch. +/// +/// **Counted through [`crate::edit::keys`], and that is a crate rule rather than a +/// preference.** `src/edit.rs` is the only file permitted to parse TOML — +/// `tests/dependencies.rs` asserts it and `AGENTS.md` states it — and this +/// function held a `text.parse::()` that walked straight through the +/// gate, because the gate named two literal spellings and `FromStr` is neither of +/// them. The invariant the release states was false for exactly as long as that +/// line stood, and the fix is the one the rule always pointed at: the need here +/// was never a document, it is the keys of one table in one file, which is the +/// same act [`shape`] already asks [`crate::edit::sections`] for, one level down. +/// +/// Both call sites reach this only after [`refusal`] has answered `None`, so the +/// table is [`Shape::Absent`] or [`Shape::Table`] — a flat block of rows, which is +/// precisely what `edit::keys` counts. A `[prices]` that spells its table as an +/// inline `models = { ... }` has no region of its own and counts zero here where +/// the document parse counted its entries; that is not a regression, because +/// io-cli cannot write into that spelling either way — [`has_models_section`] +/// answers `false`, the write falls to the append arm, and `edit::apply`'s +/// read-back refuses the duplicate key it would have made. pub fn priced_in(text: &str) -> usize { - let Ok(document) = text.parse::() else { - return 0; - }; - document - .get("prices") - .and_then(|prices| prices.get("models")) - .and_then(|models| models.as_table()) - .map_or(0, |models| models.len()) + crate::edit::keys(text, "prices.models").len() } /// The `[app.io-cli.prices]` edits that record where a table came from. diff --git a/src/reload.rs b/src/reload.rs index 4f22a20..0441e27 100644 --- a/src/reload.rs +++ b/src/reload.rs @@ -2,7 +2,7 @@ //! //! **io-harness takes the instruction files as a snapshot and never takes //! another.** `read_instructions` runs inside `Config::discover` -//! (`io-harness-0.71.0/src/config.rs:2111`), composes each named file into a +//! (`io-harness-0.72.0/src/config.rs:2111`), 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 diff --git a/src/resume.rs b/src/resume.rs index 931d04f..af860f2 100644 --- a/src/resume.rs +++ b/src/resume.rs @@ -96,8 +96,8 @@ //! nothing better to say about. use io_harness::{ - AssistantTurn, Containment, Error, Observer, PendingQuestion, PlanStep, PlanVerdict, Policy, - Provider, RecoveryDecision, RunOutcome, RunStatus, Store, TaskContract, + AssistantTurn, Choice, Containment, Error, Observer, PendingQuestion, PlanStep, PlanVerdict, + Policy, Provider, Question, RecoveryDecision, RunOutcome, RunStatus, Store, TaskContract, }; /// What a run stopped on, as data. Marks, wording and ordering belong to the @@ -107,6 +107,12 @@ use io_harness::{ /// question id cannot be handed to the plan driver by a caller that got its /// variables the wrong way round — the mistake that would deliver one operator's /// answer into somebody else's run. +/// +/// **A batched ask does not add a fifth kind**, and the reason is the same +/// property: io-harness parks a whole batch as one row under one `question_id`, so +/// it is carried by the `questions` field of [`Self::Question`], the variant that +/// already owns that id, rather than by a variant that would give one id a second +/// name. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Pending { /// The agent asked the operator something and nobody answered. @@ -118,7 +124,46 @@ pub enum Pending { /// What it already knew, when it said. context: Option, /// The options it offered. An answer is not obliged to be one of them. - choices: Vec, + /// + /// [`Choice`] rather than `String` since io-harness 0.72.0, so an offer's + /// description and preview survive the trip through this type and a + /// surface has something to render. + /// + /// **Empty for a batch**, and that is the store's shape rather than a + /// loss here: `Store::put_questions` writes the `choices` column only for + /// the singular ask, so a batch's offers are in the `questions` field below + /// and nowhere else. + choices: Vec, + /// Every question of a batched ask, in the order the agent asked them, or + /// **empty for the ordinary single question** — which is exactly what + /// `PendingQuestion::questions` holds and is not a flag invented here. + /// + /// **A field on this variant rather than a variant of its own, because a + /// batch is not a second kind of pause.** io-harness 0.72.0 parks a whole + /// batch as *one* `pending_questions` row: one `question_id`, one + /// `RunOutcome::AwaitingAnswer`, one `Waiting::Question`, and the same four + /// `resume_*_with_answer` functions with the signatures they already had. A + /// second variant would say there is a second id and a second driver, and + /// there is neither — so it would break this enum's one load-bearing + /// property (each answerable kind carries its own id under its own name) by + /// giving one id two names. Keeping it one variant keeps that property + /// literally true: there is one `question_id` here, and it addresses the + /// whole ask. + /// + /// What a batch's row does *not* carry in its own columns is why this field + /// has to exist at all. The `question` field is the batch rendered as + /// numbered prose (`"1. …\n2. …"`) and the `choices` field is + /// empty, so a surface reading only those draws a wall of text with nothing + /// to pick. The per-question text, context and offers are here. + /// + /// **`Question::multiple` is readable here and nowhere else**, so a resumed + /// multi-select is only recoverable for a batch: `PendingQuestion` has no + /// `multiple` column, and `Store::put_question` — the singular writer the + /// run loop calls for `ask_question` — writes no `questions` value at all. + /// A singular ask's `multiple` is therefore lost by the store before io-cli + /// sees the row, which is io-harness's to fix and is stated here rather than + /// papered over with a default that would read as a fact. + questions: Vec, /// The step it asked on, which is committed — so the resume starts after /// it and the question is not asked twice. step: u32, @@ -430,11 +475,19 @@ pub fn pending_for(store: &Store, run_id: i64) -> Result { } if let Some(question) = unresolved_question(store, run_id)? { + // `questions` straight through, empty and all: a row written by 0.71.0 or + // earlier has a NULL column that io-harness reads as the empty vector, and a + // singular `ask_question` written by 0.72.0 has one too. Both are the + // single-question shape, and neither needs a reader of its own here — + // io-harness's `Choice` deserializer already accepts the plain-string + // spelling every earlier release wrote into `choices`, so a second reader in + // this crate would be a second opinion about a format io-harness owns. return Ok(Pending::Question { question_id: question.id, question: question.question, context: question.context, choices: question.choices, + questions: question.questions, step: question.step, }); } diff --git a/src/sessions.rs b/src/sessions.rs index 9700612..2aafee5 100644 --- a/src/sessions.rs +++ b/src/sessions.rs @@ -241,6 +241,12 @@ fn stamp(created_at: &str) -> String { /// same argument [`cut_note`] is built on. The three states an operator has to /// act on, and the two they have to know about, are the ones that get a mark; /// everything else is the absence of one. +/// +/// **A batched ask gets the same mark as a single question, deliberately.** It is +/// one parked row waiting on one answer, and a sixth word for "asks, but several +/// at once" would spend a column an operator reads at a glance on a distinction +/// that changes nothing about what they do next. [`note`] has the room to say how +/// many, and says it. pub fn mark(pending: &Pending) -> Option<&'static str> { match pending { Pending::Question { .. } => Some(QUESTION_MARK), @@ -268,9 +274,32 @@ pub fn mark(pending: &Pending) -> Option<&'static str> { /// continue. pub fn note(pending: &Pending) -> Option { Some(match pending { - Pending::Question { question, .. } => { - format!("waiting on your answer: {question}") - } + // **A batch is counted rather than quoted, and the quoting is what would + // be false.** io-harness renders a batch's `question` column as numbered + // prose — `"1. …\n2. …"` — so the singular arm here would paste several + // newline-separated questions into a one-line picker detail, which either + // wraps a row that has no second row or is cut after `1.` and reads as the + // whole ask. The count is true, fits, and says the thing the operator's + // next keystroke depends on: this needs more than one answer. + Pending::Question { + question, + questions, + .. + } => match questions.len() { + // No batch, or a batch of one. The agent's own words either way: an + // empty `questions` is the singular ask, whose words are the `question` + // column, and a one-question batch's column has been numbered `1. …` by + // the store, so the question itself reads better than the rendering of + // it. + 0 | 1 => { + let asked = match questions.first() { + Some(first) => first.question.as_str(), + None => question.as_str(), + }; + format!("waiting on your answer: {asked}") + } + n => format!("waiting on your answers to {n} questions the agent asked together"), + }, Pending::Plan { steps, .. } => format!( "waiting on your verdict on a plan of {} step{}", steps.len(), diff --git a/src/skillview.rs b/src/skillview.rs index 6796295..1f34316 100644 --- a/src/skillview.rs +++ b/src/skillview.rs @@ -134,6 +134,13 @@ use crate::skills; /// string comes from `Skills::discover` — or disabled, where it comes from here. const NO_DESCRIPTION: &str = "(no description)"; +/// The file a folder skill is written as: `/SKILL.md`. +/// +/// The one spelling this module compares against, always case-insensitively — +/// io-harness joins this literal when it walks a directory, and a Windows or macOS +/// filesystem will hand back `Skill.md` for it. +const SKILL_FILE: &str = "SKILL.md"; + /// The narrowest a path may be drawn at, before the separator in front of it. /// /// Twenty cells is `...cli/skills/io-mcp.md` — the last two segments and the mark @@ -406,13 +413,18 @@ fn origin(home: &Path, name: &str, path: &Path) -> Origin { } } -/// The `*.md` files sitting in `skills/disabled`, sorted, absolute. +/// The skills sitting in `skills/disabled`, sorted, absolute. /// -/// Top-level files only, which is what [`disable`] ever puts there — see the note -/// on it about the bundle it declines to move. A directory that is not there at -/// all is no disabled skills, which is the ordinary case: `disabled/` is created -/// by the first disable and never before, so that an operator who has turned -/// nothing off has no directory to wonder about. +/// **`Skills::discover`'s walk, one level down included**: a `*.md` file at the +/// top, and a `/SKILL.md` for a directory. Both are shapes [`disable`] puts +/// there — it moves a folder skill whole — and a listing that read only the files +/// would take a disabled folder skill off the surface that turns it back on, which +/// is a one-way door dressed up as a toggle. +/// +/// A directory that is not there at all is no disabled skills, which is the +/// ordinary case: `disabled/` is created by the first disable and never before, +/// so that an operator who has turned nothing off has no directory to wonder +/// about. /// /// Canonicalised the way `Skills::discover` canonicalises, so a path off this /// surface is comparable with a path off that one and neither depends on the @@ -424,11 +436,16 @@ fn disabled_files(dir: &Path) -> Vec { let mut files: Vec = entries .filter_map(|entry| entry.ok()) .map(|entry| entry.path()) - .filter(|path| { - path.is_file() + .filter_map(|path| { + if path.is_dir() { + let inside = path.join(SKILL_FILE); + return inside.is_file().then_some(inside); + } + (path.is_file() && path .extension() - .is_some_and(|extension| extension.eq_ignore_ascii_case("md")) + .is_some_and(|extension| extension.eq_ignore_ascii_case("md"))) + .then_some(path) }) .map(|path| std::fs::canonicalize(&path).unwrap_or(path)) .collect(); @@ -446,19 +463,38 @@ fn disabled_files(dir: &Path) -> Vec { /// same directory: it has to know whether a *disabled* file already answers to a /// name before it writes its own file under that name, and answering it any other /// way would be a second reading of what a skill is called. +/// +/// # The fallback is the directory for a `SKILL.md`, and the file stem otherwise +/// +/// The stem alone is right for a loose `foo.md` and wrong for `foo/SKILL.md`, +/// whose stem is the literal word `SKILL` — io-harness's own `default_name` +/// special-cases exactly that (`skills.rs:345-356`), so this does too. It is one +/// rule rather than two because *every* caller here is asking what the run will +/// resolve: [`install`] names its destination from it, [`enable`] asks whether +/// anything already answers to it, [`crate::import`] plans a whole directory of +/// them — and a reading that answered `SKILL` would have three imported skills +/// collide against each other and an added one land under a name nothing +/// addresses. pub(crate) fn describe(path: &Path) -> (String, String) { let stem = path .file_stem() .map(|stem| stem.to_string_lossy().to_string()) .unwrap_or_default(); + let fallback = if stem.eq_ignore_ascii_case("SKILL") { + path.parent() + .and_then(Path::file_name) + .map_or(stem, |parent| parent.to_string_lossy().to_string()) + } else { + stem + }; let Ok(text) = std::fs::read_to_string(path) else { // Unreadable, and this surface still lists it: a file the operator cannot // read is one they especially need to be shown the path of. - return (stem, NO_DESCRIPTION.to_string()); + return (fallback, NO_DESCRIPTION.to_string()); }; let (name, description, body) = front_matter(&text); ( - name.unwrap_or(stem), + name.unwrap_or(fallback), description .or_else(|| first_prose_line(body)) .unwrap_or_else(|| NO_DESCRIPTION.to_string()), @@ -629,23 +665,50 @@ pub fn rows(skills: &[Listed], width: u16, glyphs: &Glyphs) -> Vec { /// [`view`] took to produce the row being acted on — and buys that the move is /// unreachable with a bundle path from anywhere. /// -/// ponytail: declines a `SKILL.md` bundle rather than moving its directory. Moving -/// the file alone would leave an empty directory behind and land as `disabled/SKILL.md`, -/// which collides with the next bundle disabled and resolves to the name `SKILL`; -/// moving the directory is the correct answer and is what this grows if an operator -/// asks for it. Declining is the one option that cannot lose a file. +/// # A folder skill moves as a folder +/// +/// `//SKILL.md` goes to `/disabled//SKILL.md`, the +/// directory renamed whole. Moving the file alone would leave an empty directory +/// behind and land as `disabled/SKILL.md`, which collides with the next folder +/// skill disabled and resolves to the name `SKILL` — so `subject` answers with +/// the directory, and `disabled_files` walks one level down to find it again. +/// A folder io-cli did not lay out is still declined, which is the one option +/// that cannot lose somebody's file. **A loose `SKILL.md` is declined too**, and +/// for the destination's sake rather than the file's: parked under its own name it +/// would make `disabled/` itself discover as a skill. [`remove`] takes that file +/// away; nothing can park it. pub fn disable(path: &Path, bundles: &[(String, PathBuf)]) -> Result { if let Some(refusal) = refuse_bundle(path, bundles) { return Err(refusal); } - if is_bundle(path) { + let target = match subject(path) { + Ok(target) => target, + Err(folder) => { + return Err(format!( + "{} is a skill folder io-cli did not lay out; move {} into the \ + disabled one by hand to turn it off", + path.display(), + folder.display(), + )) + } + }; + // **The one refusal here that is about the destination rather than the file.** + // `relocate` keeps the name, so a loose `SKILL.md` moved as a file lands as + // `disabled/SKILL.md` — and a directory holding a `SKILL.md` is precisely what + // `Skills::discover` walks into. The off-switch would put the file back in + // front of the model under the name `disabled`, taking every other parked + // skill's hiding place with it. [`remove`] takes such a file away; there is no + // answer for parking one, so this says so instead of inventing a name for it. + if target == path && is_skill_file(path) { return Err(format!( - "{} is a skill folder rather than a single file; move the folder into the disabled \ - one by hand to turn it off", - path.display() + "{} is a loose `{SKILL_FILE}`, so moving it into `{}/` would make that \ + directory discover as a skill of its own and put the file back in \ + front of the model. Rename it to `.md` first, or remove it.", + path.display(), + skills::DISABLED, )); } - let Some(dir) = path.parent() else { + let Some(dir) = target.parent() else { return Err(format!("{} is not in a skills directory", path.display())); }; let disabled = dir.join(skills::DISABLED); @@ -655,7 +718,7 @@ pub fn disable(path: &Path, bundles: &[(String, PathBuf)]) -> Result Result target, + Err(folder) => { + return Err(format!( + "{} is a skill folder io-cli did not lay out; move {} out of the \ + disabled one by hand to turn it on", + path.display(), + folder.display(), + )) + } + }; // Out of `disabled/` and into its parent, which is the skills directory. Two - // levels up from the file rather than a path recomputed from the home, + // levels up from what is moving rather than a path recomputed from the home, // because the caller has a path off [`view`] and the home it came from is not // on this call — and a file that is not two levels deep is not one this // surface put there. - let Some(dir) = path.parent().and_then(Path::parent) else { + let Some(dir) = target.parent().and_then(Path::parent) else { return Err(format!( "{} is not in a disabled skills directory", path.display() @@ -708,7 +785,7 @@ pub fn enable(path: &Path, bundles: &[(String, PathBuf)]) -> Result Result.md` in this home's skills directory, where `` is what +/// `describe` answers — the frontmatter `name:`, the directory for a +/// `SKILL.md`, the file stem otherwise. The dominant skill layout on disk is +/// `/SKILL.md`, so a destination taken from the source's file name wrote +/// `SKILL.md` into the skills directory: a file `is_bundle` reads as a folder +/// skill, that both levers then refused forever with a sentence about deleting a +/// folder that is not there. Naming from the resolved name also makes the two +/// checks below ask about the name the run will resolve, which is the only name +/// a collision can happen under. +/// +/// Three refusals, and the last two are the ones that matter. A resolved name +/// that is not one ordinary path component is refused before anything is joined +/// to it, because that name came out of somebody else's file. A destination that /// already exists is refused rather than overwritten, because a skill file is /// prose somebody wrote and this verb has no undo. And a source whose **resolved /// name** is already claimed is refused even when its filename is free — that @@ -743,9 +834,30 @@ pub fn install(home: &Path, source: &Path) -> Result { source.display() )); } - let Some(file_name) = source.file_name() else { - return Err(format!("{} has no file name", source.display())); - }; + // The question the run will ask, asked before anything is named after the + // answer — and this is where the source's *file name* used to be taken + // instead. A `/SKILL.md` source landed here as `SKILL.md`, which + // [`is_bundle`] then read as a folder skill and both levers refused forever, + // while every name check below asked about the literal word `SKILL` rather + // than about the name the run resolves. + let (name, _) = describe(source); + + // **A frontmatter `name:` is somebody else's text and it is about to become a + // file name in io's own home.** `crate::import::one_path_component` refuses + // the same value for the same reason and is called rather than restated: one + // definition of a trust rule is one place for it to be wrong. Refused rather + // than sanitised, for that function's reason — a name is how the model + // addresses a skill, and rewriting it would add one under a name the + // operator's other tool does not use. + if !crate::import::one_path_component(&name) { + return Err(format!( + "`{name}` is the name {} resolves to, and a skill's name becomes its \ + file name here — that one is not a single path component, so io-cli \ + did not join it onto its own skills directory. Rename it in the file \ + and add it again.", + source.display() + )); + } let dir = skills::dir(home); // Made here for [`disable`]'s reason: the surface that first moves a file in @@ -753,19 +865,15 @@ pub fn install(home: &Path, source: &Path) -> Result { if let Err(error) = crate::home::create(&dir) { return Err(format!("could not create {}: {error}", dir.display())); } - let destination = dir.join(file_name); + let destination = dir.join(format!("{name}.md")); if destination.exists() { return Err(format!( - "{} is already there; remove it first, or rename the file you are \ - installing", + "{} is already there; remove it first, or give the file you are \ + installing another `name:`", destination.display() )); } - // The question the run will ask, asked before the file is in a position to - // make the run fail. - let (name, _) = describe(source); - // **A directory that will not discover is a refusal, never a skipped check.** // `discover` errors on a set above the ceiling, on two files already sharing a // name, and on an unreadable entry — every one of those is a home that is @@ -797,10 +905,16 @@ pub fn install(home: &Path, source: &Path) -> Result { // install claim a name a parked skill holds. The operator would then never be // able to turn that one back on: [`enable`] refuses a name already claimed, // and it would refuse forever. `crate::skills::install` has consulted the - // disabled set for this reason since 0.21.0 and this must agree with it. - if crate::skills::disabled_names(&dir.join(skills::DISABLED)) + // disabled set for this reason since 0.21.0 and this agrees with it on every + // loose file, because both resolve a parked name through [`describe`]. + // + // Asked of [`disabled_files`] rather than of `crate::skills::disabled_names` + // for the one place they differ: [`disable`] parks a folder skill as a folder, + // which has no `.md` extension for that function to match on, and a name held + // by one of those strands it exactly as a loose one would. + if disabled_files(&dir) .iter() - .any(|held| held == &name) + .any(|parked| describe(parked).0 == name) { return Err(format!( "a skill switched off in {}/{} already answers to `{name}`; installing \ @@ -842,23 +956,35 @@ pub fn install(home: &Path, source: &Path) -> Result { /// refusal lives here rather than at the call site so a second caller cannot walk /// past it. /// -/// The folder form is refused too, for [`disable`]'s reason: `/SKILL.md` is -/// one skill spread over a directory, and unlinking the file alone would leave the -/// directory behind holding nothing that discovery can see. +/// **The folder form takes the folder.** `/SKILL.md` is one skill spread +/// over a directory, so unlinking the file alone would leave the directory behind +/// holding nothing discovery can see — an undeletable ghost on the surface whose +/// subject is what is where. A folder io-cli did not lay out is refused instead, +/// and the sentence names the folder rather than describing one that is not +/// there: every loose `SKILL.md` used to be told to delete a directory it never +/// had. pub fn remove(path: &Path, bundles: &[(String, PathBuf)]) -> Result { if let Some(refusal) = refuse_bundle(path, bundles) { return Err(refusal); } - if is_bundle(path) { - return Err(format!( - "{} is a skill folder rather than a single file; delete the folder by \ - hand to take it away", - path.display() - )); - } - std::fs::remove_file(path) - .map_err(|error| format!("could not remove {}: {error}", path.display()))?; - Ok(path.to_path_buf()) + let target = match subject(path) { + Ok(target) => target, + Err(folder) => { + return Err(format!( + "{} is a skill folder io-cli did not lay out; delete {} by hand to \ + take it away", + path.display(), + folder.display(), + )) + } + }; + let gone = if target == path { + std::fs::remove_file(target) + } else { + std::fs::remove_dir_all(target) + }; + gone.map_err(|error| format!("could not remove {}: {error}", target.display()))?; + Ok(target.to_path_buf()) } /// The sentence for a path that lives inside a `[[plugin]]` bundle, if it does. @@ -888,14 +1014,101 @@ fn refuse_bundle(path: &Path, bundles: &[(String, PathBuf)]) -> Option { )) } -/// Whether a path is a `SKILL.md` inside its own directory. +/// Whether a path is a `SKILL.md` that has a directory of **its own**. /// /// A different sense of the word from [`refuse_bundle`]'s: this one is a *file /// shape* — one skill spread over a directory — and says nothing about whose /// directory it is in. Both are checked, and neither substitutes for the other. +/// +/// **The basename alone is not the shape, and reading it as the shape was the +/// defect.** [`install`] used to name its destination from the source's file +/// name, so a `/SKILL.md` source landed as `/SKILL.md` — a loose +/// file with no directory of its own, which this answered `true` about, and which +/// [`remove`] and [`disable`] then refused forever with a sentence about deleting +/// a folder that did not exist. A directory is the skill's when it is named after +/// the skill, which is the layout io-harness's own `default_name` assumes, or +/// when it sits inside a skills directory as [`own_folder`] describes. A skills +/// directory itself is neither. fn is_bundle(path: &Path) -> bool { + if !is_skill_file(path) { + return false; + } + // By position first: it costs no read of the file, and it is the shape every + // skill io-cli or `/import` ever wrote is in. + own_folder(path).is_some() + || path + .parent() + .and_then(Path::file_name) + .is_some_and(|folder| folder.to_string_lossy() == describe(path).0) +} + +/// Whether the file is named `SKILL.md`, in whatever case the filesystem kept. +fn is_skill_file(path: &Path) -> bool { path.file_name() - .is_some_and(|name| name.eq_ignore_ascii_case("SKILL.md")) + .is_some_and(|name| name.eq_ignore_ascii_case(SKILL_FILE)) +} + +/// Whether `dir` is a skills directory laid out the way io-cli lays one out. +/// +/// Asked of [`crate::skills::dir`] rather than against a name written out here, +/// so the two cannot drift and this module needs no copy of a constant that is +/// private to that one. +fn is_skills_dir(dir: &Path) -> bool { + dir.parent().is_some_and(|home| skills::dir(home) == dir) +} + +/// Whether `dir` is the `disabled/` inside a skills directory of io-cli's own. +fn is_disabled_dir(dir: &Path) -> bool { + dir.file_name().is_some_and(|name| name == skills::DISABLED) + && dir.parent().is_some_and(is_skills_dir) +} + +/// The directory a folder skill owns, when it is one io-cli may rename or delete. +/// +/// `//SKILL.md`, and the parked form +/// `/disabled//SKILL.md` that [`disable`] makes of it. The answer is +/// a directory about to be moved or unlinked *whole*, so the question is asked +/// narrowly and every other shape keeps the refusal it already had. +/// +/// **Two directories are never the answer, and both matter.** A skills directory +/// is not a skill's folder, and neither is the `disabled/` inside one: a stray +/// `/SKILL.md` would otherwise have [`remove`] delete every skill beside +/// it, and a hand-placed `/disabled/SKILL.md` would have it delete every +/// skill the operator had switched off. Both are declined here, and so is a +/// candidate whose own parent is neither. And a bundle's +/// `/SKILL.md` never reaches here: [`refuse_bundle`] has already refused it +/// by canonicalised containment, independently of any file name, which is why +/// this may answer about *shape* alone. +/// +/// ponytail: io-cli's own layout and nothing else. A run pointed elsewhere by +/// `[run] skills` gets the refusal this surface always gave, never an act on a +/// directory whose shape io-cli did not choose. The day a call site can hand the +/// resolved skills directory in, this takes it as an argument and the layout test +/// goes with it. +fn own_folder(path: &Path) -> Option<&Path> { + if !is_skill_file(path) { + return None; + } + let folder = path.parent()?; + if is_skills_dir(folder) || is_disabled_dir(folder) { + return None; + } + let above = folder.parent()?; + (is_skills_dir(above) || is_disabled_dir(above)).then_some(folder) +} + +/// What a lever moves or deletes: a folder skill's own directory where it has one +/// io-cli laid out, and the file itself otherwise. +/// +/// `Err` carries the directory to name in the refusal — the file's parent — so +/// each lever writes the sentence its own verb needs and none of them describes a +/// folder that is not there, which is what the old wording did for every loose +/// `SKILL.md`. +fn subject(path: &Path) -> Result<&Path, &Path> { + if !is_bundle(path) { + return Ok(path); + } + own_folder(path).ok_or_else(|| path.parent().unwrap_or(path)) } /// Move one file into `into`, keeping its name. diff --git a/src/triage.rs b/src/triage.rs index a0d17b4..12b8ad7 100644 --- a/src/triage.rs +++ b/src/triage.rs @@ -41,7 +41,7 @@ pub enum Disposition { Silent, } -/// Every kind io-harness 0.71 declares, in its own declaration order. +/// Every kind io-harness 0.72 declares, in its own declaration order. /// /// The order is the enum's rather than alphabetical so that this table can be /// read down the side of `observe.rs` when the pin moves. @@ -113,6 +113,23 @@ pub const TRIAGE: &[(&str, Disposition, &str)] = &[ wherever nothing draws one: plain mode, and a resumed run whose overlay this process is \ not holding", ), + // **0.33.0 — the one kind in this table whose fact no overlay can carry.** + // The obvious row here would have copied `question_asked` above and been + // `Silent` with the overlay as its route, and it would have been wrong. + // `crate::intent::Answerer` implements `Responder::answer` and nothing else, + // so io-harness's own `answer_all` hands the overlay the batch one question at + // a time; the overlay draws a question, never an ask. "These three arrived + // together" is the entire content of this variant — it is why io-harness + // added it — and there is no surface in this product that would have said it. + // A `Silent` row would have routed the only fact it carries to nothing. + ( + "questions_asked", + Disposition::Line, + "the batch line, saying how many the agent asked at once and numbering each one in the \ + order it asked them. The questions themselves keep `question_asked`'s rule and are drawn \ + under it only where no overlay will draw them — the count is committed either way, \ + because that is the part nothing else says", + ), ("question_answered", Disposition::Line, "the answer line"), ( "plan_proposed", diff --git a/tests/configure.rs b/tests/configure.rs index a85e867..79af53d 100644 --- a/tests/configure.rs +++ b/tests/configure.rs @@ -787,6 +787,140 @@ fn f1_the_dated_price_table_is_machine_written() { assert_eq!(machine, vec!["prices.as_of"]); } +// F5 — the price refresh is an act one descent below the date it writes, and the +// bare `/config` list is facts only. +// +// **These are the first tests this row has ever had.** `REFRESH_PRICES` carries a +// doc comment saying it lives in the library "so a test can reach it", and through +// 0.32.0 no test reached it — while that one row was the whole reason `/config` was +// refused mid-turn. Nothing here can assert what `src/main.rs` pushes onto the +// picker, because nothing under `tests/` links the binary; what they assert is that +// the library offers the act in exactly one place and offers the bare list without +// it, so a driver that put it back would have to spell the row itself. + +/// A user scope that dates its prices, so the act has a date to report. +const DATED_PRICES: &str = "[prices]\nas_of = \"2026-08-29\"\n"; + +/// The bare list is settings and nothing else: no act, and no sentinel among them. +/// +/// Sabotage: append the refresh to `rows`, or let the sentinel through the sweep in +/// `settings` — under which this fails by name. +#[test] +fn f5_the_bare_list_carries_no_act() { + let s = scopes(DATED_PRICES, "", ""); + let config = s.config(); + let settings = configure::settings(&config); + let rows = configure::rows(&settings); + + let act = configure::refresh_row(&configure::setting(&config, "prices.as_of")); + assert!( + !rows.iter().any(|row| row.label == act.label), + "the price refresh is a row of the bare `/config` list, which is what made \ + the bare command a write" + ); + assert!( + !settings + .iter() + .any(|setting| setting.path == configure::REFRESH_PRICES), + "the sentinel reached `settings`, which may only name things a file can hold" + ); + assert_eq!( + configure::kind_of(configure::REFRESH_PRICES), + None, + "the sentinel answered to a kind, which would make it a key" + ); +} + +/// The act is the row after `leave it` in `prices.as_of`'s own descent. +/// +/// By index, because that is what the driver decides on: `store::acts` reads the +/// position and row 0 declines, which is this product's rule for every +/// confirmation. The date is asserted in the row as well as in the title — the act +/// is offered *beside the fact it writes*, and a row that lost the date would be an +/// offer to re-read a catalogue with no way to tell whether it needs re-reading. +#[test] +fn f5_the_refresh_is_one_descent_below_the_date_it_writes() { + let s = scopes(DATED_PRICES, "", ""); + let config = s.config(); + let (title, rows, keys) = configure::descent(&config, "prices.as_of") + .expect("prices.as_of descends into the act that writes it"); + + assert_eq!( + rows.len(), + keys.len(), + "the keys are read by the chosen row's index, so a shorter list decides the \ + wrong row or none at all" + ); + assert_eq!( + rows[0].label, + io_cli::store::LEAVE_IT, + "row 0 of a confirmation must decline" + ); + assert_eq!( + keys[0], + configure::DECLINE, + "row 0 must act on nothing, and its KEY is the `!`-prefixed sentinel rather \ + than the label above — a configuration file may hold a quoted bare key \ + called `leave it`, and a sentinel that a real key can equal is not a \ + sentinel" + ); + assert_eq!( + keys[1], + configure::REFRESH_PRICES, + "the act is the row after the decline" + ); + assert_eq!( + rows[1].label, + configure::refresh_row(&configure::setting(&config, "prices.as_of")).label + ); + assert!( + rows[1] + .detail + .as_deref() + .is_some_and(|detail| detail.contains("2026-08-29")), + "the act does not say when the catalogue was last read: {:?}", + rows[1].detail + ); + assert!( + title.contains("2026-08-29") && title.contains("user"), + "the descent's title must state the date in force and the file that decided \ + it: {title}" + ); +} + +/// One key opens the act, and it is the price table's own. +/// +/// Sabotage: key the descent on `Kind::Machine` instead of on the key — under which +/// this still passes today and fails the moment a second machine-written key exists, +/// which is why the assertion is the whole list rather than a `contains`. Sabotage: +/// return the act for every key — under which it fails naming all thirty-seven. +#[test] +fn f5_no_other_setting_descends_into_the_refresh() { + let s = scopes(DATED_PRICES, "", ""); + let config = s.config(); + + let mut offering: Vec<&str> = Vec::new(); + for key in configure::CATALOGUE { + let Some((_, rows, keys)) = configure::descent(&config, key) else { + continue; + }; + assert_eq!(rows.len(), keys.len(), "{key}: the two lists disagree"); + if keys.iter().any(|k| k == configure::REFRESH_PRICES) { + offering.push(key); + } + } + assert_eq!( + offering, + vec!["prices.as_of"], + "the price refresh is reachable from a key that is not the date it writes" + ); + + assert!( + configure::descent(&config, "something.nobody.declared").is_none(), + "a key io-cli has no schema for was given a descent it invented" + ); +} + /// A key no catalogue entry names has no kind, and that is deliberate. /// /// `settings` lists keys an operator wrote that io-cli does not know about; a kind diff --git a/tests/dependencies.rs b/tests/dependencies.rs index 4cf71aa..167a43a 100644 --- a/tests/dependencies.rs +++ b/tests/dependencies.rs @@ -998,6 +998,34 @@ fn code_of(text: &str) -> String { .join("\n") } +/// Every door `toml` opens into a document. +/// +/// **A ban that names two spellings is a ban on two spellings.** Until 0.33.0 +/// this gate refused `toml::from_str` and `toml::de::` and nothing else, and +/// `src/prices.rs` counted a table with `text.parse::()` — the same +/// act, through `FromStr`, past a gate that had never been told to look for it. +/// The crate's headline invariant, and this release's N5, were false for as long +/// as that one line stood. It was found by sabotage rather than by the gate, +/// which is the whole finding: the gate could not have found it. +/// +/// So the list is `toml`'s entry points enumerated rather than sampled. The free +/// functions, including a `from_slice` this version no longer has — named anyway, +/// because a `toml` upgrade that brings it back should not also quietly reopen a +/// door. The deserializer module and the `Deserializer` re-exported beside it. +/// And every `FromStr` route that can be spelled with a turbofish, whether it is +/// written as a method call or as an associated function. The annotation route +/// has no literal to name and is caught separately, at the call site. +const TOML_PARSERS: &[&str] = &[ + "toml::from_str", + "toml::from_slice", + "toml::de::", + "toml::Deserializer", + "toml::Value::from_str", + "toml::Table::from_str", + "toml::value::Table::from_str", + "parse:: usize { + let mut at = at.min(text.len()); + while !text.is_char_boundary(at) { + at -= 1; + } + at +} + +/// The byte index at or after `at` that a `&str` may be cut on. +fn ceil_boundary(text: &str, at: usize) -> usize { + let mut at = at.min(text.len()); + while !text.is_char_boundary(at) { + at += 1; + } + at +} + +/// The first place `text` states one of io-harness's bounds as io-cli's own. +/// +/// Three things have to line up, and each one is a guard against prose the product +/// already ships: +/// +/// 1. **The figure sits against the noun it counts** — `ten questions`, +/// `12-question`, `twelve lines`. A figure a clause away from a noun is a +/// coincidence, and a window wide enough to catch one is a window wide enough +/// to catch `question`, a word that appears sixty times on one guide page. +/// 2. **A ceiling word is nearby**, so a count is not read as a cap. +/// 3. **Nothing in the window denies it**, so the sentence that makes the +/// criterion true is not the sentence that fails it. +/// +/// Returns the offending window, because a gate that says only "somewhere in this +/// file" is a gate somebody greps for an hour. +fn restated_bound(text: &str) -> Option { + let lower = text.to_lowercase(); + for bound in HARNESS_BOUNDS { + for spelling in bound.spellings { + for counted in bound.counts { + for joiner in [" ", "-"] { + let phrase = format!("{spelling}{joiner}{counted}"); + for (at, _) in lower.match_indices(&phrase) { + // `112 lines` is not `12 lines`, and a version string is + // not a bound. + if lower[..at].ends_with(|c: char| c.is_ascii_digit()) { + continue; + } + let from = floor_boundary(&lower, at.saturating_sub(90)); + let to = ceil_boundary(&lower, at + phrase.len() + 90); + let window = &lower[from..to]; + if !CEILING.iter().any(|word| window.contains(word)) { + continue; + } + if DISCLAIMED.iter().any(|word| window.contains(word)) { + continue; + } + return Some(format!( + "{} — `{}` is io-harness's: ...{}...", + bound.constant, + phrase, + window.trim() + )); + } + } + } + } + } + None +} + +/// Every page this product ships to a reader. +/// +/// Sorted for the reason `spawning_modules` is sorted: `read_dir` answers in +/// whatever order the filesystem holds it, and a gate that names a different file +/// first on a different machine is a gate somebody stops believing. +fn shipped_prose() -> Vec<(PathBuf, String)> { + fn walk(dir: &Path, out: &mut Vec<(PathBuf, String)>) { + for entry in std::fs::read_dir(dir).expect("docs is readable").flatten() { + let path = entry.path(); + if path.is_dir() { + walk(&path, out); + } else if path.extension().is_some_and(|ext| ext == "md") { + let text = std::fs::read_to_string(&path).expect("a shipped page"); + out.push((path, text)); + } + } + } + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let mut out = Vec::new(); + let readme = root.join("README.md"); + let text = std::fs::read_to_string(&readme).expect("README.md is shipped"); + out.push((readme, text)); + walk(&root.join("docs"), &mut out); + out.sort_by(|a, b| a.0.cmp(&b.0)); + out +} + +#[test] +fn n6_no_shipped_page_states_a_harness_bound_as_io_clis_own() { + // **The criterion had no oracle, which is the same thing as not being + // asserted.** N6 says io-cli states no numeric bound of io-harness's as its + // own, and it was true by inspection: the harness's three constants are + // private, none of the figures appeared in `docs/`, and `the-session.md` says + // outright that io-cli renders what arrives rather than restating a bound. + // Nothing would have caught the next release writing "at most ten questions" + // into a guide page, and a figure copied out of a private constant is wrong + // the first time the harness moves it, with nothing red anywhere. + + // The detector is proved in both directions before it is pointed at the docs. + // A sweep that cannot see a restated bound passes every page in the product + // and asserts nothing, and it passes *silently* — which is the failure mode + // this whole file exists to refuse. + for stated in [ + "io-cli shows at most ten questions in one batch.", + "A preview is capped at twelve lines before it is drawn.", + "Previews are truncated at 800 bytes.", + "The batch holds up to 10 questions.", + "No preview is longer than 12 rows.", + "A preview is cut off at eight hundred characters.", + ] { + assert!( + restated_bound(stated).is_some(), + "the sweep cannot see the bound in {stated:?}, so it asserts nothing", + ); + } + + // And does not fire on a page denying it imposes one, or on a number about + // something else. Every line but the first two is shipped prose, quoted from + // the page it lives on — these are the sentences the sweep has to survive. + for innocent in [ + "io-harness limits a preview to twelve lines before it sends one, and io-cli \ + does not restate that bound as its own.", + "The viewport is capped at 112 lines of scrollback.", + "twelve lines of the head say where you are: which question of how many", + "no group may hold more than ten, **inspect**", + "That is twelve tools in its workspace, and no page may list more than twelve.", + "a thought longer than ten rows is fitted with the rest kept", + "a twenty-row viewport, which holds a twelve-step plan with its footer", + "typed thirty seconds into a ten-minute turn arrived nine and a half minutes late", + "Absent, it is io-harness's own 10; `0` is clamped to 1 rather than meaning none.", + ] { + assert!( + restated_bound(innocent).is_none(), + "the sweep fires on {innocent:?}, which states no bound of io-cli's: {:?}", + restated_bound(innocent), + ); + } + + // A gate whose corpus has moved out from under it reads nothing and passes. + let pages = shipped_prose(); + assert!( + pages + .iter() + .any(|(path, _)| path.ends_with("guide/the-session.md")), + "the sweep is reading somewhere other than the shipped guide", + ); + assert!( + pages.len() > 20, + "the shipped documentation is {} pages, which is fewer than this product has", + pages.len(), + ); + + for (path, text) in pages { + assert!( + restated_bound(&text).is_none(), + "{} states a bound that is io-harness's, not io-cli's: {}. \ + The harness moves these in a point release and nothing here would go \ + red — say what io-cli does with what arrives, not what the harness \ + allows to arrive.", + path.display(), + restated_bound(&text).unwrap_or_default(), + ); + } +} diff --git a/tests/docs.rs b/tests/docs.rs index 3bc1b21..e4e5425 100644 --- a/tests/docs.rs +++ b/tests/docs.rs @@ -27,8 +27,22 @@ fn repo() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) } +/// One shipped file, with its line endings normalised. +/// +/// **The `\r` strip is why this file passes on Windows.** Git checks these +/// documents out with CRLF there, and every helper below reasons about `\n` — most +/// sharply [`paragraph`], which ends a paragraph at `"\n\n"` and on a CRLF +/// checkout found no boundary at all, took the rest of the document, and reported +/// that a page naming eleven commands named all thirty-eight. Normalising once +/// here rather than in each helper is the difference between one rule and a guard +/// every future reader has to remember. +/// +/// Caught by the release matrix on 0.33.0, with macOS and Linux green — which is +/// what the matrix is for. fn read(name: &str) -> String { - std::fs::read_to_string(repo().join(name)).unwrap_or_else(|error| panic!("{name}: {error}")) + std::fs::read_to_string(repo().join(name)) + .unwrap_or_else(|error| panic!("{name}: {error}")) + .replace("\r\n", "\n") } /// The pages that describe the product **as it is now**, for a sweep that @@ -1225,7 +1239,12 @@ fn shipped_markdown() -> Vec<(String, String)> { .expect("walked from the root") .to_string_lossy() .replace('\\', "/"); - let text = std::fs::read_to_string(&path).unwrap_or_default(); + // Line endings normalised for the same reason the path separator + // above is: these pages are read on three platforms and every + // sweep below reasons about `\n`. See `read`. + let text = std::fs::read_to_string(&path) + .unwrap_or_default() + .replace("\r\n", "\n"); out.push((relative, text)); } } @@ -1296,10 +1315,11 @@ fn f7_no_shipped_document_leaves_a_contact_placeholder_unfilled() { /// Every relative link in a markdown file, as (containing file, target). /// -/// Anchors and absolute URLs are not this gate's business: `#section` is checked -/// by nothing here and `https://` is checked by nobody offline. What is left is -/// the set of links that can rot silently when a file moves — which is exactly -/// what this release does to a 2,847-line README. +/// An absolute URL is not this gate's business: `https://` is checked by nobody +/// offline. What is left is the set of links that can rot silently when a file +/// moves — which is exactly what this release does to a 2,847-line README. The +/// fragment on a target is dropped here and checked by [`anchor_links`], which +/// asks the other half of the question: the file is there, but is the heading? fn relative_links() -> Vec<(String, String)> { let mut links = Vec::new(); @@ -1365,6 +1385,171 @@ fn f4_every_relative_link_resolves_to_a_file_that_exists() { ); } +/// The anchor GitHub gives each heading in a markdown file, in document order. +/// +/// GitHub's rule: lowercase, punctuation dropped, spaces to hyphens. So +/// `## What it costs` is reached as `#what-it-costs`, and the backticks around +/// `## `[app.io-cli.keys]`` are no part of its anchor. A heading repeated inside +/// one file gets `-1` on the second and `-2` on the third, which is why this +/// returns the slugs in order rather than a set. +/// +/// A `#` inside a fenced block is a shell prompt or a comment, not a heading, so +/// the fences are tracked and their contents skipped. +fn heading_slugs(text: &str) -> Vec { + let mut bases: Vec = Vec::new(); + let mut slugs = Vec::new(); + let mut fenced = false; + + for line in text.lines() { + let trimmed = line.trim_start(); + if trimmed.starts_with("```") || trimmed.starts_with("~~~") { + fenced = !fenced; + continue; + } + if fenced { + continue; + } + let Some(rest) = trimmed.strip_prefix('#') else { + continue; + }; + let heading = rest.trim_start_matches('#'); + // `#notaheading` is a fragment or a hashtag; a heading has a space. + if !heading.starts_with(' ') { + continue; + } + let base: String = heading + .trim() + .to_lowercase() + .chars() + .filter(|c| c.is_alphanumeric() || *c == ' ' || *c == '-' || *c == '_') + .map(|c| if c == ' ' { '-' } else { c }) + .collect(); + + let seen = bases.iter().filter(|already| *already == &base).count(); + slugs.push(if seen == 0 { + base.clone() + } else { + format!("{base}-{seen}") + }); + bases.push(base); + } + + slugs +} + +/// Every anchor link in a markdown file, as (containing file, line, the page the +/// heading is promised to be in, fragment). +/// +/// Both spellings land here with the promise made explicit: `](#x)` promises a +/// heading in the containing file, `](page.md#x)` one in `page.md`, so the gate +/// below asks a single question of both. Links inside a fenced block are examples +/// of what to type, not links a reader can click, and are skipped. +fn anchor_links() -> Vec<(String, usize, PathBuf, String)> { + let root = repo(); + let mut links = Vec::new(); + + for (path, text) in shipped_markdown() { + let base = std::path::Path::new(&path) + .parent() + .map(std::path::Path::to_path_buf) + .unwrap_or_default(); + let mut fenced = false; + + for (number, line) in text.lines().enumerate() { + let trimmed = line.trim_start(); + if trimmed.starts_with("```") || trimmed.starts_with("~~~") { + fenced = !fenced; + continue; + } + if fenced { + continue; + } + + let mut rest = line; + while let Some(open) = rest.find("](") { + let after = &rest[open + 2..]; + let Some(close) = after.find(')') else { break }; + let target = &after[..close]; + rest = &after[close + 1..]; + + // A link target may carry a title: `](path "Title")`. Take the path. + let target = target.split_whitespace().next().unwrap_or(target); + if target.starts_with("http://") + || target.starts_with("https://") + || target.starts_with("mailto:") + { + continue; + } + let Some((page, fragment)) = target.split_once('#') else { + continue; + }; + if fragment.is_empty() { + continue; + } + let page = if page.is_empty() { + root.join(&path) + } else { + root.join(&base).join(page) + }; + links.push((path.clone(), number + 1, page, fragment.to_string())); + } + } + } + + links +} + +/// **F4, the third part — no anchor link is dead.** +/// +/// The split moved byte ranges faithfully and left every in-page anchor pointing +/// at a heading that had gone to another file: 34 of the 46 fragment-only links +/// in the shipped documentation resolved to nothing, and a reader clicking one +/// watched the page not move. Nothing caught it, because the link gate above +/// dropped the fragment and asked only whether the file was there — for a +/// fragment-only link, the file is always there. +/// +/// The non-empty assertion is load-bearing for the same reason it is on the +/// orphan gate: a parser that quietly stops matching would iterate nothing and +/// pass, which is the vacuous-gate shape this repository has shipped four times. +/// +/// Sabotage: change one anchor to a heading that is not in the page it names, or +/// move a heading whose anchor is linked. Only this fails. +#[test] +fn f4_every_anchor_link_resolves_to_a_heading() { + let root = repo(); + let links = anchor_links(); + + assert!( + links.len() > 20, + "only {} anchor links were found across the documentation, so this gate \ + is checking almost nothing — the parser stopped matching", + links.len(), + ); + + let mut dead = Vec::new(); + for (from, line, page, fragment) in links { + let shown = page.strip_prefix(&root).unwrap_or(&page).display(); + let Ok(text) = std::fs::read_to_string(&page) else { + dead.push(format!( + "{from}:{line}: #{fragment} — no {shown} to hold it" + )); + continue; + }; + if !heading_slugs(&text).iter().any(|slug| slug == &fragment) { + dead.push(format!( + "{from}:{line}: #{fragment} is in no heading of {shown}" + )); + } + } + + assert!( + dead.is_empty(), + "these anchors name a heading that is not in the page they point at, so \ + a reader clicking one watches the page not move:\n{}", + dead.join("\n"), + ); +} + /// **F4, the other half — no guide page is orphaned.** /// /// A split that leaves a page linked from nowhere is worse than not splitting: @@ -1740,3 +1925,389 @@ fn f7_the_security_policy_names_a_private_route_that_is_not_a_public_issue() { so it has to say through what", ); } + +/// Every run of whitespace as one space, so a needle is a claim rather than a +/// claim **plus the column the paragraph happened to wrap at**. +/// +/// Every gate above matches raw bytes, which is right for a table row and wrong +/// for a sentence: markdown prose here is hard-wrapped at eighty columns, so a +/// two-line sentence carries a newline and two indents that no author chose and +/// every reflow moves. A needle written against one wrapping either fails on the +/// next edit — teaching people to delete the gate — or is shortened until it stops +/// being the claim. Flattening first is what lets the needle be the sentence. +/// +/// It does **not** strip markup, and must not: the pages carry `` `/config` `` +/// with its backticks in the bytes, so a needle drops the backticks only where the +/// file does. +fn flat(text: &str) -> String { + text.split_whitespace().collect::>().join(" ") +} + +/// The paragraph beginning at `opening`, up to the blank line that ends it. +/// +/// Narrower than [`under_heading`] because these two lists are paragraphs rather +/// than sections, and a section-wide search would find `/config` in the three +/// paragraphs of prose *about* the split and read them as members of it. +fn paragraph<'a>(text: &'a str, opening: &str) -> &'a str { + let from = text + .find(opening) + .unwrap_or_else(|| panic!("no paragraph opening {opening:?}")); + let rest = &text[from..]; + let to = rest.find("\n\n").unwrap_or(rest.len()); + &rest[..to] +} + +/// Every backticked word in `region` that begins with a slash, in the order the +/// prose names them. +/// +/// A command and nothing else: the refused paragraph also backticks `!`, which is +/// a shell escape rather than a command and is asserted for separately. +fn slash_words(region: &str) -> Vec { + let mut out = Vec::new(); + let mut rest = region; + while let Some(open) = rest.find('`') { + let after = &rest[open + 1..]; + let Some(close) = after.find('`') else { break }; + let word = &after[..close]; + rest = &after[close + 1..]; + if word.starts_with('/') && word.len() > 1 { + out.push(word.to_string()); + } + } + out +} + +/// `spell`'s answer with the capital a sentence opens on. +fn capitalised(word: &str) -> String { + let mut chars = word.chars(); + match chars.next() { + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + None => String::new(), + } +} + +/// **The mid-turn split in the prose is the split `runs_mid_turn` makes.** +/// +/// The count and both halves, asked of the code. `docs/guide/keys.md` printed +/// twenty-one refused commands while the code refused twenty-two of them — +/// `/exit` and `/export` had never been listed — and the number in front of the +/// admitted half was `Ten` for a release in which it was eleven. Neither was +/// catchable by reading a diff: the sentence was not touched by the release that +/// falsified it. +/// +/// **The two halves are read out of `COMMANDS` and never written down here.** +/// `/steer` and `/compact` are subtracted by name, and that subtraction is the one +/// thing this test states rather than derives: both are refused by `runs_mid_turn` +/// and both nevertheless reach a running turn, through the arms that existed +/// before the mid-turn set did. The prose says so in its own paragraph, so listing +/// them under "refused" would be the page contradicting itself two lines later. +/// +/// Sorted before comparison rather than compared in order: membership and count +/// are the claim, and a gate that failed because an author moved `/undo` two +/// commas to the left is a gate that gets deleted. +/// +/// Sabotage: put `/config` back in the refused paragraph; drop `/export` from it; +/// add a twelfth name to the admitted paragraph; or write `Ten` in front of +/// either sentence. Each fails, naming the command or the number. +#[test] +fn the_prose_splits_the_commands_the_way_runs_mid_turn_splits_them() { + use io_cli::commands::runs_mid_turn; + + // Reached through their own arms rather than through the mid-turn set, and + // named in the prose as exactly that. + const OWN_ARMS: [&str; 2] = ["/steer", "/compact"]; + + let keys = guide("keys"); + let contract = read("docs/CONTRACT.md"); + + // **Counted from `COMMANDS`, not from `MID_TURN`.** `MID_TURN` holds + // *spellings*, and `/settings` is a second spelling of `/config` rather than a + // command of its own — `parse` reads `"config" | "settings"` in every arm, and + // `COMMANDS` carries one row for the pair. A count taken from `MID_TURN` would + // tell an operator there is a twelfth command and then fail to name it. The + // same reasoning already applies in the other direction to `/copy diff`, which + // is a `COMMANDS` row and not a command, so the set is deduplicated by first + // word before it is counted. + let mut admitted: Vec<&str> = COMMANDS + .iter() + .map(|(name, _)| *name) + .filter(|name| runs_mid_turn(name.trim_start_matches('/'))) + .map(|name| name.split_whitespace().next().unwrap_or(name)) + .collect(); + admitted.dedup(); + let count = spell(admitted.len()); + assert_ne!( + count, + "an unspelled number", + "the mid-turn set is {} commands and `spell` has no word for it, so the \ + two sentences below cannot be checked at all", + admitted.len(), + ); + + let opens = format!( + "**{} commands report while the agent works**", + capitalised(count) + ); + assert!( + keys.contains(&opens), + "docs/guide/keys.md should open the mid-turn paragraph {opens:?}; \ + `COMMANDS` admits {} of them", + admitted.len(), + ); + assert!( + flat(&contract).contains(&format!( + "**{} commands run while a turn is in flight, and the rest are refused.**", + capitalised(count), + )), + "docs/CONTRACT.md should say how many commands run mid-turn, and the code \ + admits {}", + admitted.len(), + ); + + // The admitted half, as the prose names it. Compared against the same + // deduplicated set the count came from, so the sentence and the number in + // front of it cannot disagree — and so `/settings`, which is `/config`'s other + // spelling rather than a command, is not demanded of a page that lists + // commands. + let mut named = slash_words(paragraph(&keys, &opens)); + named.sort(); + let mut listed_admitted: Vec = admitted.iter().map(|name| name.to_string()).collect(); + listed_admitted.sort(); + assert_eq!( + named, listed_admitted, + "docs/guide/keys.md names a different set of mid-turn commands than \ + `COMMANDS` admits", + ); + + // The refused half, likewise — read out of `COMMANDS` through the same + // predicate the driver asks, so a command that changes sides changes this. + let refused_opens = "**Everything else keeps that refusal"; + let mut listed = slash_words(paragraph(&keys, refused_opens)); + listed.sort(); + let mut refused: Vec = COMMANDS + .iter() + .map(|(name, _)| *name) + .filter(|name| !runs_mid_turn(name.trim_start_matches('/'))) + .filter(|name| !OWN_ARMS.contains(name)) + .map(str::to_string) + .collect(); + refused.sort(); + assert_eq!( + listed, refused, + "docs/guide/keys.md's refused list is not the refused half of COMMANDS; \ + an operator reading it is told a command is refused that runs, or told \ + nothing about one that is", + ); + assert!( + paragraph(&keys, refused_opens).contains("`!` line"), + "a `!` line is refused mid-turn too and the paragraph has to say so, \ + because it is the one refusal that is not a slash command", + ); + + // And the two that are neither: refused by the predicate, and reaching the + // turn anyway. A page that dropped this paragraph would leave the list above + // reading as the whole truth. + let flattened = flat(&keys); + for name in OWN_ARMS { + assert!( + flattened.contains(&format!("`{name}`")), + "docs/guide/keys.md stops saying that {name} reaches a running turn \ + through its own arm, so its absence from both lists reads as an \ + omission", + ); + } +} + +/// **No page still describes the two writes 0.33.0 took out of `/config`.** +/// +/// Both were true sentences and both are now folklore. The bare `/config` list +/// carried a row that re-read the provider's catalogue and wrote a scope file, and +/// a horizontal arrow on a row wrote the scope file on the keystroke — the only +/// unconfirmed write in the product reachable from a bare arrow key. Every page +/// that explained why `/config` was refused mid-turn explained it in terms of one +/// of those. +/// +/// Every shipped page rather than the two that carried the claims, for +/// `no_documentation_surface_still_claims_the_old_asymmetry`'s reason: a negative +/// gate aimed at one file goes **vacuous** the moment the sentence moves, and this +/// project has moved its prose between files once already. +/// +/// The positives are the load-bearing half. Four `!contains` assertions are +/// satisfied by four empty files, so each falsehood is paired with the sentence +/// that replaces it, on the page that owns it. +/// +/// Sabotage: restore any one of the four sentences, or delete any one of the +/// replacements. Each fails on its own and names the file. +#[test] +fn no_page_still_says_config_writes_a_file_from_the_list_or_from_an_arrow() { + const FALSEHOODS: &[&str] = &[ + // The count in front of the mid-turn set, which was ten through 0.32.0. + "Ten commands report while the agent works", + "Ten commands run while a turn is in flight", + // The whole-command refusal, and the reason given for it. + "`/config` is refused even bare", + "refused mid-turn in every form", + "picker offers a row that re-reads", + // The arrow that wrote where it stood. + "A horizontal arrow changes a boolean or a closed set of words where it stands", + ]; + + for (name, text) in shipped_prose() { + let said = flat(&text); + for claim in FALSEHOODS { + assert!( + !said.contains(claim), + "{name} still says {claim:?}, which has not been true since \ + 0.33.0: the bare `/config` list has no row that acts, and \ + `Left`/`Right` open a setting's values instead of writing one", + ); + } + } + + // The replacements, each on the page that owns the claim: which form of + // `/config` runs mid-turn, that the arrows stopped writing, and where the + // refresh went. + for (page, needle) in [ + ( + "keys", + "`/config` joined the first list in 0.33.0, and only in its bare form.", + ), + ( + "configuration", + "**No arrow key writes a configuration file, and until 0.33.0 one did.**", + ), + ( + "configuration", + "**The price refresh is one descent below `prices.as_of`", + ), + ] { + assert!( + flat(&guide(page)).contains(needle), + "docs/guide/{page}.md is missing {needle:?}, so the sentence that \ + replaces a falsehood above is not there and the page is silent \ + rather than corrected", + ); + } + + const ADMITTED: &str = + "**`/config` is admitted bare and refused the moment it carries a word.**"; + assert!( + flat(&read("docs/CONTRACT.md")).contains(ADMITTED), + "docs/CONTRACT.md is the page a script author reads, so it has to state \ + which spellings of `/config` a running turn accepts", + ); +} + +/// **The question surface is documented as the one surface it became.** +/// +/// io-harness 0.72.0 shipped batched asks, described choices, previews and +/// multi-select, and every one of them reached this crate silently when the pin +/// moved. A capability that arrives without a sentence is a capability nobody +/// finds: an operator meets `PgUp` by pressing it, or does not. +/// +/// Each claim is asserted on the page that owns it rather than through an `any()` +/// over the set, so moving one to a different page fails here and has to be moved +/// deliberately. +/// +/// Sabotage: delete any one of these sentences. Each fails, naming the page and +/// the sentence. +#[test] +fn the_guide_describes_a_batched_ask_a_described_offer_and_a_marked_set() { + let session = flat(&guide("the-session")); + for needle in [ + // A batch is one overlay, walked, and nothing is sent until it is whole. + "**An agent can ask several things at once, and they arrive as one overlay.**", + "**`PgUp` and `PgDn` walk the batch**", + "**Nothing is sent until every question is decided.**", + // ...and the two things a reader would otherwise go looking for. + "There is no review pane and no submit key", + "`Esc` decides the question on the screen", + // A description is always drawn; a preview unfolds one at a time. + "A description is always on the screen, on a row of its own under the label", + "A preview is a block", + // And the set. + "`Space` marks and unmarks the one under the marker", + ] { + assert!( + session.contains(needle), + "docs/guide/the-session.md is missing {needle:?}, so the question \ + surface 0.33.0 rebuilt is undocumented on the page that owns it", + ); + } + + // The spacebar is a borrowed key rather than a bound one, and the keys page is + // where a reader looks for a key. It is deliberately NOT in `commands::KEYS` + // — that table is what the session binds all the time, asserted row for row by + // `the_readme_key_table_is_the_key_table`, and this key is held only while a + // list that accepts several answers is open, exactly as `/config`'s two arrows + // and the queue's four keys are. A row for it in `KEYS` would put a key in + // `/help` that most sessions never have. + const SPACEBAR: &str = + "borrowed by a question that takes several answers, and it is the spacebar"; + assert!( + flat(&guide("keys")).contains(SPACEBAR), + "docs/guide/keys.md lists every key the session borrows, and the spacebar \ + is one from 0.33.0", + ); + + // Headless: one row, one id, one `--answer`, and the limitation stated. + let headless = flat(&guide("headless")); + for needle in [ + "**A batched ask is one row, one id and one `--answer`.**", + "there is no per-question flag", + "**One limitation to know before you script against it.**", + ] { + assert!( + headless.contains(needle), + "docs/guide/headless.md is missing {needle:?}; an operator scripting \ + against a parked batch has no other page to learn it from", + ); + } +} + +/// **The two management verbs 0.33.0 repaired say what they now accept.** +/// +/// `io skill add /SKILL.md` installed a file that neither lever could touch +/// again, and `io plugin add` printed a removal line the removal verb could not +/// read. Both are fixes to a *sentence the product itself printed*, so a fix with +/// no documentation leaves the operator with the old sentence and a verb that has +/// quietly changed under it. +/// +/// Sabotage: delete either page's paragraph. Each fails on its own. +#[test] +fn the_guide_says_a_skill_installs_under_its_own_name_and_a_bundle_removes_by_name() { + // Each needle names the page that owns the claim. The first two are the shape + // of the repair — the installed file is named from the skill's own name, and a + // folder skill is manageable — and the rest are the second reading `plugin + // remove` grew, on both the panel page and the argv page. + for (page, needle) in [ + ( + "skills", + "**`io skill add ./my-skill/SKILL.md` works, and until 0.33.0 it did not.**", + ), + ( + "skills", + "**A skill `/import` wrote as a folder is manageable too, and it never was.**", + ), + ( + "plugins", + "**From a shell it is `io plugin remove`, and it takes a directory or a name.**", + ), + ( + "plugins", + "**Two bundles of one name are refused, with both directories named.**", + ), + ( + "headless", + "**`io plugin remove` takes a directory or a bundle's name.**", + ), + ] { + assert!( + flat(&guide(page)).contains(needle), + "docs/guide/{page}.md is missing {needle:?}. Both verbs were repaired \ + in 0.33.0 and both were repairs to a sentence the product itself \ + printed, so an undocumented fix leaves the operator with the old \ + sentence over a verb that has quietly changed", + ); + } +} diff --git a/tests/events.rs b/tests/events.rs index 79648a5..3ec1c93 100644 --- a/tests/events.rs +++ b/tests/events.rs @@ -17,7 +17,7 @@ use std::time::Duration; use io_cli::events::{kind_name, Events}; use io_cli::theme::{Tone, DARK}; -use io_harness::{EventKind, RunEvent, TodoItem, TodoState, TODO_MAX_ITEMS}; +use io_harness::{EventKind, Question, RunEvent, TodoItem, TodoState, TODO_MAX_ITEMS}; // The `STYLED` and `FALLS_THROUGH` lists stood here until 0.11.0. They were a // pair because the second one was a real destination — a muted line naming the @@ -615,8 +615,8 @@ fn f8_this_release_has_seen_every_kind_io_harness_emits() { let declared = support::harness_event_kinds(); assert_eq!( declared.len(), - 51, - "the locked io-harness declares fifty-one event kinds; found {}", + 52, + "the locked io-harness declares fifty-two event kinds; found {}", declared.len(), ); } @@ -2526,6 +2526,180 @@ fn o3_the_committed_question_is_not_a_warning() { ); } +// --------------------------------------------------------------------------- +// 0.33.0 — a batch is drawn as a batch +// --------------------------------------------------------------------------- + +/// Three questions the agent asked in one call, as io-harness 0.72.0 emits them. +/// +/// The three texts share no word with each other and none of them is a substring +/// of the heading, so a `contains` below fails when the arm drops a question +/// rather than being answered by something already on the frame for another +/// reason. +fn a_batch() -> EventKind { + EventKind::QuestionsAsked { + questions: vec![ + Question::new("drop the column or keep it?").with_choices(["drop", "keep"]), + Question::new("which index goes with it?"), + Question::new("rename the table too?"), + ], + } +} + +/// **A batched ask names every question it carried, numbered, in the order asked.** +/// +/// io-harness emits no `QuestionAsked` for a batch, so before this release the +/// whole ask put *nothing at all* in a transcript — in the release whose subject +/// is batched asks. +/// +/// Asserted per row and by pairing rather than over the flattened string: the +/// ordinal and the question it belongs to have to be on the same row, which is a +/// claim a `contains` over one glued string cannot make and which fails when the +/// arm numbers the batch off its own loop counter. +#[test] +fn a_batched_ask_names_every_question_and_says_they_arrived_together() { + let mut events = Events::new(DARK); + // No overlay is holding these, so the transcript draws the questions too. + events.set_answering(false); + let drawn = rows(events.event(&event(a_batch()), Duration::ZERO)); + + let heading = drawn + .first() + .unwrap_or_else(|| panic!("a batched ask committed nothing at all: {drawn:?}")); + assert!( + heading.contains("3 questions together"), + "the heading does not say the ask was a batch, which is the only thing \ + distinguishing this event from three singular ones: {heading:?}", + ); + + for (index, asked) in [ + "drop the column or keep it?", + "which index goes with it?", + "rename the table too?", + ] + .into_iter() + .enumerate() + { + let ordinal = format!("{} of 3", index + 1); + let row = drawn + .iter() + .find(|row| row.contains(&ordinal)) + .unwrap_or_else(|| panic!("no row is numbered {ordinal:?}: {drawn:?}")); + assert!( + row.contains(asked), + "the row numbered {ordinal:?} does not carry the question asked in that \ + position: {row:?}", + ); + } + + // The offers belong to the question above them and are marked as offers. The + // needle carries the bullet, because both labels are also words inside the + // first question's own text — `contains("drop")` would be green with the + // choice rows deleted. + let first = drawn + .iter() + .position(|row| row.contains("1 of 3")) + .expect("the first question is numbered"); + assert_eq!( + drawn[first + 1].trim(), + format!("{} drop", DARK.glyphs.bullet), + "the first offer is not the row under the question it belongs to: {drawn:?}", + ); + assert_eq!( + drawn[first + 2].trim(), + format!("{} keep", DARK.glyphs.bullet), + "the second offer is not the row under the question it belongs to: {drawn:?}", + ); + // And the question with no offers has none invented for it. + let last = drawn + .iter() + .position(|row| row.contains("3 of 3")) + .expect("the third question is numbered"); + assert_eq!( + last, + drawn.len() - 1, + "a question that offered nothing grew a choice row: {drawn:?}", + ); +} + +/// **The count is committed even when the overlay is holding the questions, and +/// the questions are not.** +/// +/// The two halves are one claim. `intent::Answerer` implements `Responder::answer` +/// alone, so io-harness's `answer_all` hands the overlay one question at a time +/// and nothing there says the three arrived together — that fact reaches nobody +/// unless this line commits unconditionally. The questions themselves keep +/// `question_asked`'s 0.32.0 rule, or a batch asks the operator each of its +/// questions twice. +/// +/// Sabotage, either direction: put the heading inside the `plain || !answering` +/// guard, or move the question rows outside it. One assertion here fails for each. +#[test] +fn a_batched_ask_commits_its_count_even_when_an_overlay_draws_the_questions() { + let mut events = Events::new(DARK); + events.set_answering(true); + let drawn = rows(events.event(&event(a_batch()), Duration::ZERO)); + + assert_eq!( + drawn.len(), + 1, + "the overlay asks these one at a time, so the transcript commits the count \ + and nothing else: {drawn:?}", + ); + assert!( + drawn[0].contains("3 questions together"), + "the batch reached no surface at all: {drawn:?}", + ); + assert!( + !drawn[0].contains("drop the column"), + "the question is on the overlay and in the transcript, which is the defect \ + 0.32.0 removed from the singular arm: {drawn:?}", + ); +} + +/// A batch may carry one question — io-harness rejects an empty list and accepts a +/// single-element one — and the sentence is written for it. +/// +/// Sabotage: delete the `asked == 1` branch. The heading reads `1 questions +/// together`, which the second assertion names. +#[test] +fn a_batch_of_one_is_not_announced_as_1_questions() { + let mut events = Events::new(DARK); + events.set_answering(true); + let line = rendered( + &mut events, + EventKind::QuestionsAsked { + questions: vec![Question::new("which index goes with it?")], + }, + ); + assert!(line.contains("one question"), "{line:?}"); + assert!(!line.contains("1 questions"), "{line:?}"); +} + +/// **The singular arm is untouched, and this is what says so.** +/// +/// The cheap way to have handled 0.72.0 would have been to fold `QuestionAsked` +/// into the batch renderer as a batch of one. It compiles, it is shorter, and it +/// silently rewords every question this product has drawn since 0.7.0 — `the agent +/// asks: ` becomes a heading and a `1 of 1` row. +#[test] +fn a_single_question_is_still_drawn_as_a_question_rather_than_a_batch_of_one() { + let mut events = Events::new(DARK); + events.set_answering(false); + let single = rendered(&mut events, a_question()); + + assert!( + single.contains("the agent asks: drop the column or keep it?"), + "the singular question lost its own sentence: {single:?}", + ); + for batched in ["together", " of 1", "one question"] { + assert!( + !single.contains(batched), + "a single question was drawn through the batch arm ({batched:?}): {single:?}", + ); + } +} + /// **A path that contains io-harness's separator is drawn as itself.** /// /// The display translation belongs to `read_skill`, whose target is a *name*. The diff --git a/tests/exec.rs b/tests/exec.rs index 2542025..e3ce6e9 100644 --- a/tests/exec.rs +++ b/tests/exec.rs @@ -1624,6 +1624,7 @@ fn the_listing_names_the_handle_each_parked_run_is_answered_through() { question: "which environment?".into(), context: None, choices: vec![], + questions: vec![], step: 12, }; let plain = exec::listed(41, &question, false).expect("a question is a row"); @@ -1708,6 +1709,7 @@ fn each_pause_produces_the_decision_that_carries_its_own_id() { question: "which environment?".into(), context: None, choices: vec![], + questions: vec![], step: 12, }; assert_eq!( @@ -1828,6 +1830,7 @@ fn a_flag_that_decides_the_wrong_pause_is_refused_and_names_the_right_one() { question: "which environment?".into(), context: None, choices: vec![], + questions: vec![], step: 12, }; let wrong = exec::decision_for( @@ -1913,6 +1916,154 @@ fn a_payload_without_its_verdict_and_a_verdict_without_its_payload_are_both_refu ); } +/// 0.33.0 F11 — the headless door tells the truth about a batched ask. +/// +/// **What the door can and cannot do, stated because the difference is where this +/// release found a promise it could not keep elsewhere.** io-harness 0.72.0 parks +/// a whole batch as one `pending_questions` row under one `question_id`, and +/// `resume_with_answer_observed` records one reply against it — so a single +/// `io resume --answer "…"` really does answer the whole batch, and the id +/// carried here is the one it takes. What it cannot do is take the batch apart: +/// the per-question breakdown, `PendingQuestion::answers`, is written only by a +/// `Responder` inside the running process and stays empty for every answer that +/// arrives through a resume. So the sentence has to say the one text covers all of +/// them, and must not offer a per-question flag that does not exist. +/// +/// Sabotage: leave `waiting_on`'s singular arm handling a batch. Nothing errors, +/// the run really is answerable, and an operator reading `--answer ""` +/// sends one sentence to a five-part ask — which is answered, resolved and +/// unrepeatable. +#[test] +fn a_parked_batch_is_listed_and_refused_as_the_several_questions_it_is() { + use io_cli::resume::Pending; + + let batch = Pending::Question { + question_id: 219, + // What the store's own writer renders into this column. Carried here so + // the assertions below cannot be satisfied by it. + question: "1. which environment?\n2. which database?\n3. which region?".into(), + context: None, + // Empty, exactly as `Store::put_questions` leaves it — the offers are per + // question, and a batch row has none of its own. + choices: vec![], + questions: vec![ + io_harness::Question::new("which environment?"), + io_harness::Question::new("which database?"), + io_harness::Question::new("which region?"), + ], + step: 12, + }; + + // The listing keeps naming what the run is waiting on. Renaming `question` + // would describe a difference the resume door does not have and break every + // script written against this stream. + let row = exec::listed(41, &batch, true).expect("a batch is a row"); + let value: serde_json::Value = serde_json::from_str(&row).expect("one JSON object"); + assert_eq!(value["waiting_on"], "question"); + assert_eq!( + value["id"], 219, + "one row, one id, and it is the one to answer" + ); + assert_eq!( + value["questions"], 3, + "and the one thing `waiting_on` cannot say: how much that one answer has \ + to cover — {row}", + ); + + // A single question is `1` rather than absent, so a script reading this key + // does not have to know which release wrote the row it is reading. + let single = Pending::Question { + question_id: 219, + question: "which environment?".into(), + context: None, + choices: vec![], + questions: vec![], + step: 12, + }; + let row = exec::listed(41, &single, true).expect("a question is a row"); + let value: serde_json::Value = serde_json::from_str(&row).expect("one JSON object"); + assert_eq!(value["questions"], 1); + // And the three pauses that are not questions say null rather than a count of + // something they do not hold — the shape `id` already uses for a died run. + for pending in [ + Pending::Plan { + plan_id: 307, + steps: vec![], + step: 5, + }, + Pending::Died { last_step: 6 }, + ] { + let row = exec::listed(41, &pending, true).expect("a row"); + let value: serde_json::Value = serde_json::from_str(&row).expect("one JSON object"); + assert!(value["questions"].is_null(), "{row}"); + } + + // The plain stream says it too, and only when there is more than one: a + // `1 question` on every row is the mark nobody reads. + let plain = exec::listed(41, &batch, false).expect("a batch is a row"); + assert!(plain.contains("3 questions"), "{plain}"); + assert_eq!( + exec::listed(41, &single, false).expect("a question is a row"), + "run 41 question 219 step 12", + "a single question's plain row is byte for byte what it was, so nothing new \ + appears on the rows an operator already reads", + ); + + // The refusal an operator gets when they type no `--answer`. + let asked = exec::decision_for(41, &batch, &resume_args(&["io", "resume", "41"])) + .expect_err("a question needs an answer"); + assert!( + asked.contains("219") && asked.contains("--answer"), + "it must still name the id and the flag: {asked}", + ); + // `"3 questions"` and not `'3'`: the sentence already carries `219` and `41`, + // and a bare digit is the kind of `contains` this suite has shipped green + // while meaning nothing. + assert!( + asked.contains("3 questions"), + "and say how many questions the one answer has to cover: {asked}", + ); + // The half that keeps the line above from being satisfied by the singular + // sentence, which names no count and offers `` — one sentence for + // a three-part ask. + assert_ne!( + asked, + exec::decision_for(41, &single, &resume_args(&["io", "resume", "41"])) + .expect_err("a question needs an answer"), + "a batch and a single question must not be told the same thing", + ); + assert!( + !asked.contains(""), + "the singular invitation is what sends one sentence to a several-part ask: {asked}", + ); + assert!( + asked.contains("no per-question flag"), + "and the refusal must say the door has no way to answer them separately, \ + rather than leaving an operator to discover it: {asked}", + ); + + // The door is not broken, only honest: one `--answer` answers the batch, and + // the decision carries the one id the harness's resume takes. + assert_eq!( + exec::decision_for( + 41, + &batch, + &resume_args(&[ + "io", + "resume", + "41", + "--answer", + "1. staging 2. postgres 3. eu-west" + ]) + ) + .expect("one answer answers the whole batch"), + io_cli::exec::Decision::Answer { + question_id: 219, + answer: "1. staging 2. postgres 3. eu-west".into(), + }, + ); +} + // --------------------------------------------------------------------------- // O15 — the whole release stayed on the interactive side of the event stream // --------------------------------------------------------------------------- diff --git a/tests/glyphs.rs b/tests/glyphs.rs index 5534bb8..52ae749 100644 --- a/tests/glyphs.rs +++ b/tests/glyphs.rs @@ -35,7 +35,7 @@ use io_cli::glyphs::{self, Glyphs, ASCII, UNICODE}; use io_cli::picker::{fit, fit_left, Picker, Row}; use io_cli::status::Status; use io_cli::theme::{Background, Theme, DARK, MONO}; -use io_harness::{Edit, EventKind, RunEvent, TodoItem, TodoState}; +use io_harness::{Edit, EventKind, Question, RunEvent, TodoItem, TodoState}; use ratatui::text::Line; /// The theme every sweep below renders in: the ordinary dark palette, fully @@ -543,6 +543,16 @@ fn every_event_this_release_renders_draws_in_ascii() { question: "which of the two parsers did you mean?".into(), choices: vec!["the tokenizer".into(), "the expression parser".into()], }, + // 0.33.0 — the batched ask io-harness 0.72.0 added. Its rows carry the + // muted leader, a separator between the ordinal and the question, and the + // bullet in front of each offer, and all three differ between the sets. + EventKind::QuestionsAsked { + questions: vec![ + Question::new("which of the two parsers did you mean?") + .with_choices(["the tokenizer", "the expression parser"]), + Question::new("port the error paths with it?"), + ], + }, EventKind::QuestionAnswered { answer: "the tokenizer".into(), by: "responder".into(), @@ -1262,6 +1272,77 @@ fn the_worktree_mark_is_the_same_word_in_both_sets() { ); } +/// **F5 — a batched ask is legible as a batch in both sets (0.33.0).** +/// +/// The sweep above already proves the rows are ASCII-clean. What it cannot prove +/// is that they still *say* anything: a substitution that dropped the ordinal, or +/// one that reached for a numbered mark in Unicode and a bare number in ASCII, +/// would pass it. So the batch is rendered twice and the two renderings are +/// compared. +/// +/// The claim is the same one `the_worktree_mark_is_the_same_word_in_both_sets` +/// makes and it is deliberate: what makes a batch legible as a batch is the word +/// `n of m`, which needs no entry in [`Glyphs`] at all. The two sets differ by +/// their separator and their bullet and by nothing else, which is asserted as a +/// whole-string equality after normalising exactly those two — and each set is +/// then required to have actually drawn its own pair, so the equality is not two +/// strings that carry no mark at all. +#[test] +fn a_batched_ask_numbers_its_questions_in_both_sets() { + let batch = || EventKind::QuestionsAsked { + questions: vec![ + Question::new("which of the two parsers did you mean?") + .with_choices(["the tokenizer", "the expression parser"]), + Question::new("port the error paths with it?"), + ], + }; + let render = |theme: Theme| { + let mut events = io_cli::events::Events::new(theme); + text(&events.event(&RunEvent::new(1, 1, batch()), Duration::ZERO)) + }; + let unicode = render(DARK.with_glyphs(UNICODE)); + let plain = render(ascii()); + + assert_ascii("the batched ask", &plain); + for drawn in [&unicode, &plain] { + assert!( + drawn.contains("2 questions together"), + "the heading stopped saying the ask was a batch: {drawn:?}", + ); + assert!( + drawn.contains("1 of 2") && drawn.contains("2 of 2"), + "the ordinals are what make the batch legible once the reader is past \ + the heading, and one of them is gone: {drawn:?}", + ); + assert!( + drawn.contains("port the error paths with it?"), + "a question of the batch went missing: {drawn:?}", + ); + } + + // Each set drew its own marks. Without this the equality below would hold for + // a renderer that had stopped drawing a bullet and a separator entirely. + for (set, drawn) in [(UNICODE, &unicode), (ASCII, &plain)] { + assert!( + drawn.contains(set.bullet) && drawn.contains(set.separator.trim()), + "the {} set drew neither its bullet nor its separator: {drawn:?}", + set.name, + ); + } + + // The separator and the bullet are the *only* difference. A batch that had + // reached for a mark with no ASCII form would fail the sweep; one that had + // reached for a different mark per set would pass the sweep and fail this. + assert_eq!( + unicode + .replace(UNICODE.separator.trim(), ASCII.separator.trim()) + .replace(UNICODE.bullet, ASCII.bullet), + plain, + "the sets draw more than a separator and a bullet apart\n\ + unicode: {unicode:?}\nascii: {plain:?}", + ); +} + /// **F5 — the branch field is a plain name, so it draws the same under the ASCII /// set, under `--plain` and under `NO_COLOR`.** /// diff --git a/tests/intent.rs b/tests/intent.rs index 68f4b0a..855b198 100644 --- a/tests/intent.rs +++ b/tests/intent.rs @@ -15,7 +15,7 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use io_cli::app::App; use io_cli::intent::{Asked, Intent}; use io_cli::theme::DARK; -use io_harness::{PendingQuestion, Question, Responder}; +use io_harness::{Choice, PendingQuestion, Question, Responder}; fn key(code: KeyCode) -> KeyEvent { KeyEvent::new(code, KeyModifiers::NONE) @@ -168,11 +168,15 @@ async fn the_overlay_refuses_a_paste_and_keeps_the_composer_clear() { fn the_question_its_context_and_its_choices_are_all_drawn() { let (answerer, mut questions) = io_cli::intent::channel(); let responder: Arc = Arc::new(answerer); - let question = Question { - question: "drop the column or keep it?".to_string(), - context: Some("it has 40 rows and one caller".to_string()), - choices: vec!["drop".to_string(), "keep".to_string()], - }; + // **The offers are spelled with words the question does not contain**, and + // that is the whole assertion below. Through 0.32.0 this fixture offered + // `drop` and `keep` against a question reading "drop the column or keep it?", + // so the line checking that the choices are drawn passed on the question text + // alone — it could not fail while the question rendered at all, which is a + // gate asserting nothing. + let question = Question::new("drop the column or keep it?") + .with_context("it has 40 rows and one caller") + .with_choices(["created_at", "updated_at"]); let runtime = tokio::runtime::Builder::new_current_thread() .build() @@ -193,8 +197,8 @@ fn the_question_its_context_and_its_choices_are_all_drawn() { "the context is shown" ); assert!( - screen.contains("drop") && screen.contains("keep"), - "so are the choices", + screen.contains("created_at") && screen.contains("updated_at"), + "so are the choices, and neither word is in the question line above: {screen}", ); assert!( screen.contains("Esc"), @@ -213,11 +217,11 @@ async fn the_question_is_readable_and_answerable_with_no_colour() { let responder: Arc = Arc::new(answerer); let asking = tokio::spawn(async move { responder - .answer(&Question { - question: "drop the column or keep it?".to_string(), - context: Some("it has 40 rows".to_string()), - choices: vec!["drop".to_string()], - }) + .answer( + &Question::new("drop the column or keep it?") + .with_context("it has 40 rows") + .with_choices(["drop"]), + ) .await }); @@ -241,12 +245,15 @@ async fn the_question_is_readable_and_answerable_with_no_colour() { /// The question every test below is asked, live or stored — one fixture, so the /// two construction paths are compared on identical material. +/// +/// The offers are words the question does not contain, for the reason +/// [`the_question_its_context_and_its_choices_are_all_drawn`] gives: an offer +/// spelled with a word already in the question line makes "the choices are drawn" +/// a claim the question's own rendering satisfies. fn question() -> Question { - Question { - question: "drop the column or keep it?".to_string(), - context: Some("it has 40 rows and one caller".to_string()), - choices: vec!["drop".to_string(), "keep".to_string()], - } + Question::new("drop the column or keep it?") + .with_context("it has 40 rows and one caller") + .with_choices(["created_at", "updated_at"]) } /// A `PendingQuestion` the store itself wrote and read back. @@ -285,7 +292,18 @@ fn live(question: Question) -> (Intent, tokio::sync::oneshot::Receiver String { - let (mut screen, _recorder) = support::screen_of(80, 12, 12); + drawn_overlay_at(overlay, 80, 12, theme) +} + +/// The same, at a stated size. A block whose height depends on the width it wraps +/// at cannot be asserted through a helper that hard-codes one. +fn drawn_overlay_at( + overlay: &mut Intent, + width: u16, + height: u16, + theme: &io_cli::theme::Theme, +) -> String { + let (mut screen, _recorder) = support::screen_of(width, height, height); screen .draw(|frame| overlay.render(frame, frame.area(), theme)) .expect("a frame"); @@ -303,8 +321,8 @@ fn the_properties_both_paths_owe(open: impl Fn() -> Intent) { "the context: {screen}", ); assert!( - screen.contains("drop") && screen.contains("keep"), - "the choices, as offers: {screen}", + screen.contains("created_at") && screen.contains("updated_at"), + "the choices, as offers, in words the question line does not carry: {screen}", ); assert!(screen.contains("Esc"), "the way out is a word: {screen}"); @@ -323,14 +341,14 @@ fn the_properties_both_paths_owe(open: impl Fn() -> Intent) { } assert_eq!( answering.key(key(KeyCode::Enter)), - Some(Some("keep it".to_string())), + Some(vec![Some("keep it".to_string())]), "and the answer is exactly what was typed", ); let mut declining = open(); assert_eq!( declining.key(key(KeyCode::Esc)), - Some(None), + Some(vec![None]), "Esc declines, and declining carries no answer", ); } @@ -356,7 +374,7 @@ async fn a_live_answer_goes_down_the_channel_and_leaves_the_caller_nothing_to_do let (overlay, reply) = live(question()); assert_eq!( - overlay.resolve(Some("keep it".to_string())), + overlay.resolve(vec![Some("keep it".to_string())]), None, "nothing comes back: the turn took it", ); @@ -379,7 +397,7 @@ fn a_stored_answer_comes_back_to_the_caller_that_has_to_deliver_it() { let overlay = Intent::resumed(&stored(&question())); assert_eq!( - overlay.resolve(Some("keep it".to_string())), + overlay.resolve(vec![Some("keep it".to_string())]), Some(Some("keep it".to_string())), "the caller resumes the run with this", ); @@ -392,9 +410,9 @@ fn a_stored_answer_comes_back_to_the_caller_that_has_to_deliver_it() { fn esc_on_a_stored_question_leaves_the_run_parked_with_no_answer() { let mut overlay = Intent::resumed(&stored(&question())); - assert_eq!(overlay.key(key(KeyCode::Esc)), Some(None)); + assert_eq!(overlay.key(key(KeyCode::Esc)), Some(vec![None])); assert_eq!( - overlay.resolve(None), + overlay.resolve(vec![None]), Some(None), "no answer is delivered, and none is invented", ); @@ -445,17 +463,15 @@ fn a_stored_question_is_readable_with_no_colour_at_all() { /// eight rows of the viewport through 0.31.0 and left the operator with nothing /// to type into. fn five_choices() -> Question { - Question { - question: "which column should the migration drop?".to_string(), - context: Some("the table has 40 rows and one caller".to_string()), - choices: vec![ - "created_at".to_string(), - "updated_at".to_string(), - "deleted_at".to_string(), - "archived_at".to_string(), - "expired_at".to_string(), - ], - } + Question::new("which column should the migration drop?") + .with_context("the table has 40 rows and one caller") + .with_choices([ + "created_at", + "updated_at", + "deleted_at", + "archived_at", + "expired_at", + ]) } /// **O1 — the answer composer is rendered and focusable, at 80x24 and at 80x8.** @@ -525,7 +541,7 @@ fn o2_enter_on_an_offer_sends_that_offer_verbatim() { ); assert_eq!( overlay.key(key(KeyCode::Enter)), - Some(Some("expired_at".to_string())), + Some(vec![Some("expired_at".to_string())]), "the offer the marker was on, exactly as the agent spelled it", ); } @@ -551,7 +567,7 @@ fn o2_typing_from_an_offer_answers_rather_than_filtering() { } assert_eq!( overlay.key(key(KeyCode::Enter)), - Some(Some("none of those".to_string())), + Some(vec![Some("none of those".to_string())]), "the words the operator typed, not the offer the marker started on", ); } @@ -570,7 +586,7 @@ fn o2_folding_the_composer_keeps_what_was_typed() { assert_eq!( overlay.key(key(KeyCode::Enter)), - Some(Some("half written".to_string())), + Some(vec![Some("half written".to_string())]), "the composer lost its contents when the marker left it", ); } @@ -597,7 +613,7 @@ fn o2_a_question_with_no_choices_is_the_surface_it_always_was() { } assert_eq!( overlay.key(key(KeyCode::Enter)), - Some(Some("i meant the second one".to_string())), + Some(vec![Some("i meant the second one".to_string())]), ); } @@ -611,13 +627,13 @@ fn o2_a_question_with_no_choices_is_the_surface_it_always_was() { #[test] fn o4_esc_declines_from_the_offers_as_well_as_from_the_composer() { let (mut from_composer, _a) = live(five_choices()); - assert_eq!(from_composer.key(key(KeyCode::Esc)), Some(None)); + assert_eq!(from_composer.key(key(KeyCode::Esc)), Some(vec![None])); let (mut from_offers, _b) = live(five_choices()); from_offers.key(key(KeyCode::Up)); assert_eq!( from_offers.key(key(KeyCode::Esc)), - Some(None), + Some(vec![None]), "Esc on an offer declines too, rather than choosing it", ); } @@ -678,3 +694,1039 @@ fn o16_the_overlay_asks_for_rows_that_grow_with_the_question() { this release replaced", ); } + +// --------------------------------------------------------------------------- +// 0.33.0 — a batch of questions is one overlay, and a second question is held +// rather than dropped. +// --------------------------------------------------------------------------- + +/// Five independent questions, the shape io-harness 0.72.0's `answer_all` exists +/// for and the shape that arrived as five consecutive overlays before it. +fn five_questions() -> Vec { + (1..=5) + .map(|at| Question::new(format!("question {at} of the migration?"))) + .collect() +} + +/// **F2 — a batch crosses the channel as ONE delivery and comes back in the order +/// it was asked.** +/// +/// The count is what catches the regression. io-harness's default `answer_all` +/// body loops `answer` once per question, so without the override the first +/// `recv` yields a delivery of **one** and the second question is not even sent +/// until the first has been answered — five overlays, each hiding the next. +/// +/// Sabotage: delete `Answerer::answer_all` and fall back to the trait default, +/// under which the length assertion fails at 1. Sabotage the order by collecting +/// the answers into a set or by delivering them as they are decided, under which +/// the final comparison fails while every other assertion here still passes. +#[tokio::test] +async fn t07_a_batch_crosses_as_one_delivery_and_comes_back_in_order() { + let (answerer, mut questions) = io_cli::intent::channel(); + let responder: Arc = Arc::new(answerer); + let asking = tokio::spawn(async move { + let batch = five_questions(); + responder.answer_all(&batch).await + }); + + let batch = questions.recv().await.expect("the batch reaches the ui"); + assert_eq!( + batch.len(), + 5, + "five questions did not cross as one unit, so the interface is still \ + answering them one at a time", + ); + + let mut app = app(); + app.open_intent(batch); + for at in 1..=5 { + assert!( + app.asking(), + "the overlay closed after {} answers, and a batch that is not whole \ + parks the run", + at - 1, + ); + typed(&mut app, &format!("answer {at}")); + app.key(key(KeyCode::Enter)); + } + assert!( + !app.asking(), + "the overlay is gone once every question is decided" + ); + + assert_eq!( + asking.await.expect("the responder future"), + (1..=5) + .map(|at| Some(format!("answer {at}"))) + .collect::>(), + "the run received the answers out of the order it asked the questions", + ); +} + +/// **F2 — declining one question of a batch lands `None` in exactly that position, +/// and the surface says the run parks rather than reporting success.** +/// +/// io-harness commits a batch only when every entry is `Some`: four answers out of +/// five park the whole batch for a human. So the one thing this must not do is +/// look like an answered turn. +/// +/// Sabotage: make `Esc` decline the whole batch, under which the vector comes back +/// all-`None` and the position assertions fail. Sabotage the notice by recording +/// only the per-question lines, under which the scrollback claims four answers and +/// never says the run stopped. +#[tokio::test] +async fn t07_declining_one_question_parks_the_batch_and_says_so() { + let (answerer, mut questions) = io_cli::intent::channel(); + let responder: Arc = Arc::new(answerer); + let asking = tokio::spawn(async move { + let batch = five_questions(); + responder.answer_all(&batch).await + }); + + let mut app = app(); + app.open_intent(questions.recv().await.expect("the batch")); + for at in 1..=5 { + if at == 3 { + // The one nobody here can answer. It decides *this* question and + // moves on rather than closing the overlay. + app.key(key(KeyCode::Esc)); + assert!( + app.asking(), + "Esc closed the whole batch, so four questions nobody declined \ + were declined for them", + ); + continue; + } + typed(&mut app, &format!("answer {at}")); + app.key(key(KeyCode::Enter)); + } + + let answers = asking.await.expect("the responder future"); + assert_eq!( + answers.len(), + 5, + "one entry per question, whatever was decided" + ); + assert_eq!(answers[2], None, "the declined question is the third one"); + for (at, answer) in answers.iter().enumerate() { + if at == 2 { + continue; + } + assert_eq!( + answer.as_deref(), + Some(format!("answer {}", at + 1).as_str()), + "question {} lost its answer to the decline beside it", + at + 1, + ); + } + + let said = said(&mut app); + // **Needled on words only the batch summary says.** `pauses` was the needle + // until the sabotage pass proved it vacuous: the per-question decline line + // committed for the third question is "left unanswered — the run pauses and + // keeps the question", so the whole summary could be deleted and this stayed + // green. What the summary alone carries is the count and the rule — that four + // answers buy nothing because a batch is committed whole — and those are what + // a transcript showing four `answered` lines is missing. + assert!( + said.contains("keeps all 5"), + "the scrollback never says the other four answers are parked with the \ + decline, so it reads as a turn that carried on: {said}", + ); + assert!( + said.contains("a batch is committed only when every question is answered"), + "the scrollback says the run stopped but not why four answers were not \ + enough: {said}", + ); +} + +/// **F3 — a second question arriving while one is open is HELD, and both reply +/// channels survive.** +/// +/// The defect this closes: `App::open_intent` assigned the overlay +/// unconditionally, so the second question replaced the first and dropped its +/// reply channel — and a dropped channel is `None`, which io-harness reads as +/// *pause the run and keep the question*. The first question became a silent +/// pause that nobody was ever shown. The mpsc has always carried N; only the +/// interface assumed one. +/// +/// **This test fails before the fix**, on the first assertion: the first future +/// resolves `None` while the operator's words go to the second question. +/// +/// Sabotage: restore the unconditional assignment, or hold the second question and +/// never open it — the first fails the answer comparison, the second fails +/// `asking()` after the first answer. +#[tokio::test] +async fn f3_a_second_question_is_held_rather_than_replacing_the_first() { + let (answerer, mut questions) = io_cli::intent::channel(); + let responder: Arc = Arc::new(answerer); + + // Asked and received one at a time, so which question is which is a fact + // rather than a race. + let first = { + let responder = Arc::clone(&responder); + tokio::spawn(async move { responder.answer(&Question::new("the first?")).await }) + }; + let one = questions.recv().await.expect("the first question"); + let second = { + let responder = Arc::clone(&responder); + tokio::spawn(async move { responder.answer(&Question::new("the second?")).await }) + }; + let two = questions.recv().await.expect("the second question"); + + let mut app = app(); + app.open_intent(one); + app.open_intent(two); + + typed(&mut app, "answering the first"); + app.key(key(KeyCode::Enter)); + assert!( + app.asking(), + "the held question did not open, so a run is still waiting on an overlay \ + that will never be drawn", + ); + + typed(&mut app, "answering the second"); + app.key(key(KeyCode::Enter)); + assert!(!app.asking(), "and nothing is left holding the keyboard"); + + assert_eq!( + first.await.expect("the first responder future"), + Some("answering the first".to_string()), + "the first question's channel was dropped by the second question arriving", + ); + assert_eq!( + second.await.expect("the second responder future"), + Some("answering the second".to_string()), + ); +} + +/// **A `multiple` question answers with the marked offers, in the caller's row +/// order, spelled by whatever io-harness spells a several-part answer with.** +/// +/// **What this gates, stated honestly.** `Question::answer_of` is `join(", ")`, so +/// the equality below is satisfied by *any* io-cli that joins with `", "` — +/// including one that writes the separator out itself. What it does gate is the +/// selection and the order: which offers came back, and that they came back in the +/// order the agent listed them rather than the order they were marked. The joiner +/// half is a **drift** gate rather than a today gate — it goes red the release +/// io-harness changes `answer_of`'s spelling and io-cli is not calling it, which is +/// the only moment a forked joiner is detectable at all, and is why the right-hand +/// side is a call and never a literal. +/// +/// Sabotage: mark the offers and answer with `chosen()`'s row order reversed, or +/// with the row under the marker alone — under which the first arm fails on the +/// pair and the second still passes, which is the half a one-label answer cannot +/// see. +#[test] +fn t07_a_multiple_question_answers_in_the_harness_own_spelling() { + let plural = || { + Question::new("which platforms should the build target?") + .with_choices(["linux", "windows", "macos"]) + .multiple() + }; + + let (mut overlay, _reply) = live(plural()); + // The marker opens on the free-text row, as it does on every question. Up is + // the last offer. + overlay.key(key(KeyCode::Up)); + overlay.key(key(KeyCode::Char(' '))); + overlay.key(key(KeyCode::Up)); + overlay.key(key(KeyCode::Char(' '))); + assert_eq!( + overlay.key(key(KeyCode::Enter)), + Some(vec![Some(Question::answer_of(["windows", "macos"]))]), + "the marked offers, in the caller's row order and in the harness's spelling", + ); + + // **Nothing marked is still an answer**: `Picker::chosen` falls back to the + // row under the marker, so `Enter` on an offer of a plural question sends that + // offer rather than nothing at all. + let (mut unmarked, _reply) = live(plural()); + unmarked.key(key(KeyCode::Up)); + assert_eq!( + unmarked.key(key(KeyCode::Enter)), + Some(vec![Some(Question::answer_of(["macos"]))]), + "a plural question with nothing marked answered with nothing", + ); +} + +/// **The spacebar on a single-answer question is still a space — including the +/// first one, pressed while the marker is still on an offer.** +/// +/// `Picker::accepting_several` costs the spacebar, so a question that does not take +/// several must never opt in — otherwise every two-word answer in the product loses +/// its spaces to a mark nobody asked for. +/// +/// **The space is pressed BEFORE anything is typed, and that ordering is the whole +/// test.** Until 0.33.0 this typed `"two words"` from an offer, which gated nothing: +/// the printable arm moves the marker to the free-text row on the *first* character, +/// so by the time the space arrived `writing()` was already true and the space +/// reached the composer whatever `Intent::list` had opted into. Deleting the +/// `multiple` routing condition outright left it green. Pressing space first is the +/// only sequence in which a mark and a character are still distinguishable. +/// +/// Two assertions, and each names its own failure. The marker must have *moved* to +/// the row that takes prose — a space swallowed as a mark leaves it on the offer — +/// and the answer must still carry that leading space, because a mark produces no +/// character at all. +/// +/// Sabotage: call `accepting_several` unconditionally in `Intent::list`, or drop +/// `self.current().multiple` from the space arm in `Intent::decision` — under both +/// the marker stays on the offer and the answer comes back `"two words"` with its +/// first space missing. +#[test] +fn t07_the_spacebar_on_a_single_answer_question_is_still_a_space() { + let (mut overlay, _reply) = live(five_choices()); + // Five offers and the row that takes prose, which is the last one. + let free = overlay.offers().rows().len() - 1; + // From an offer, which is the only place a mark could be made at all. + overlay.key(key(KeyCode::Up)); + assert_ne!( + overlay.offers().selected(), + free, + "the marker is on an offer" + ); + + assert_eq!( + overlay.key(key(KeyCode::Char(' '))), + None, + "a space does not close the overlay", + ); + assert_eq!( + overlay.offers().selected(), + free, + "the space was swallowed as a mark: it never reached the composer, so the \ + marker never moved to the row that takes prose", + ); + + for ch in "two words".chars() { + assert_eq!( + overlay.key(key(KeyCode::Char(ch))), + None, + "typing does not close the overlay", + ); + } + assert_eq!( + overlay.key(key(KeyCode::Enter)), + Some(vec![Some(" two words".to_string())]), + "the spacebar was taken from a question that takes one answer", + ); +} + +/// **F9 — the free-text row is last and holds the marker, on every question of a +/// batch as on a single one.** Asserted by index rather than off the screen: a +/// row string can be produced by a question line that happens to contain the same +/// words, and 0.32.0's decision is about *which row is focused*, which no screen +/// assertion can see. +/// +/// Sabotage: focus row 0 when the overlay moves to the next question of a batch — +/// under which the second arm fails while every single-question test passes, and +/// a reflexive `Enter` becomes silent agreement with an offer nobody read. +#[tokio::test] +async fn f9_the_free_text_row_is_last_and_focused_on_every_question_of_a_batch() { + let (answerer, mut questions) = io_cli::intent::channel(); + let responder: Arc = Arc::new(answerer); + let _asking = tokio::spawn(async move { + let batch = vec![five_choices(), Question::new("and then what?")]; + responder.answer_all(&batch).await + }); + + let mut overlay = Intent::new(questions.recv().await.expect("the batch")); + // Five offers on the first question, none on the second: the free-text row is + // `choices.len()` in both, which is the index the unfold is keyed on. + for offers in [5usize, 0] { + let rows = overlay.offers().rows(); + assert_eq!( + rows.len(), + offers + 1, + "the free-text row is missing, or something else is in the list", + ); + assert_eq!( + rows[offers].label, + io_cli::intent::OWN_WORDS, + "the free-text row is not the last one", + ); + assert_eq!( + overlay.offers().selected(), + offers, + "the marker did not open on the row that takes prose", + ); + // Decide this one, which moves the overlay to the next question. + overlay.key(key(KeyCode::Char('x'))); + overlay.key(key(KeyCode::Enter)); + } +} + +// --------------------------------------------------------------------------- +// 0.33.0 — an offer can say more than its label: F4 the description, F5 the +// preview, F6 the room the preview is given. +// --------------------------------------------------------------------------- + +/// Offers that say more than their labels. +/// +/// **Every word asserted below appears exactly once in this fixture**, and never +/// in the question, the context, a label or the key line. That is not tidiness: +/// two gates in this file were vacuous because `contains` found their needle in +/// the question text, so a description asserted with words the question already +/// says proves that the question was drawn. +fn explained() -> Question { + Question::new("which column should the migration drop?") + .with_context("the table has 40 rows and one caller") + .with_choices([ + Choice::new("created_at").describe("stamped once and never read since"), + Choice::new("updated_at"), + Choice::new("archived_at").preview("ALTER TABLE ledger\n DROP COLUMN archived_at;"), + ]) +} + +/// **F4 — a description takes a row under its label, and an offer without one +/// takes no row at all.** +/// +/// The count is what catches it. A description drawn into the row's `detail` +/// instead — the same words, on the same line — leaves four rows where this wants +/// five, and looks correct in any screen assertion that only asks whether the +/// sentence is present. +/// +/// Sabotage: push a description row for every offer rather than for the described +/// ones, under which the count is seven; drop the row entirely and it is four; +/// build it with `Row::new` rather than `Row::heading` and the `heading` +/// assertion fails, which is the one that keeps `Enter` from answering the agent +/// with its own explanation. +#[test] +fn f4_a_description_takes_a_row_and_an_offer_without_one_takes_none() { + let (mut overlay, _reply) = live(explained()); + + let rows = overlay.offers().rows().to_vec(); + assert_eq!( + rows.len(), + 5, + "three offers, one description and the free-text row is five rows: {:?}", + rows.iter().map(|row| &row.label).collect::>(), + ); + assert_eq!(rows[0].label, "created_at"); + assert_eq!( + rows[1].label.trim(), + "stamped once and never read since", + "the description is not directly under the label it explains", + ); + assert!( + rows[1].heading, + "the description is a selectable row, so Enter can answer the agent with \ + its own explanation of an offer", + ); + assert_eq!( + rows[2].label, "updated_at", + "the offer with no description grew a row anyway", + ); + assert_eq!(rows[3].label, "archived_at"); + assert_eq!(rows[4].label, io_cli::intent::OWN_WORDS); + + // And it reaches the screen — always, rather than under the marker. The + // marker opens on the free-text row, so the preview on `archived_at` is + // **not** open, which is the whole difference between the two fields. + let screen = drawn_overlay(&mut overlay, &DARK); + assert!( + screen.contains("stamped once and never read since"), + "the description reached the row list and not the screen: {screen}", + ); + assert!( + !screen.contains("ALTER TABLE"), + "a preview is drawn without its offer holding the marker, which puts \ + every offer's block on the screen at once: {screen}", + ); +} + +/// Two offers, each with a preview nothing else on the screen says. +fn two_previews() -> Question { + Question::new("which migration should run first?").with_choices([ + Choice::new("widen the ledger") + .preview("ALTER TABLE ledger\n ALTER COLUMN amount TYPE numeric;"), + Choice::new("backfill the ledger") + .preview("UPDATE ledger\n SET amount = 0 WHERE amount IS NULL;"), + ]) +} + +/// **F5 — a preview unfolds under the offer that holds the marker, moving the +/// marker folds it and opens the next, and only one is ever open.** +/// +/// Asserted three ways round, because each catches a different mistake: nothing +/// is open before an offer is marked, the marked offer's own block is the one +/// drawn, and the block belonging to the other offer is absent every time. +/// +/// Sabotage: draw every configured preview rather than the open one, under which +/// the "two at once" assertions fail; key the unfold on the row that *was* marked +/// rather than the row that is, under which the second and third frames show the +/// wrong block. +#[test] +fn f5_a_preview_unfolds_under_the_marked_offer_and_only_one_is_ever_open() { + let (mut overlay, _reply) = live(two_previews()); + + let closed = drawn_overlay(&mut overlay, &DARK); + assert!( + !closed.contains("TYPE numeric") && !closed.contains("IS NULL"), + "a preview is unfolded while the marker is still on the free-text row: \ + {closed}", + ); + + // Up from the free-text row lands on the second offer. + overlay.key(key(KeyCode::Up)); + assert_eq!(overlay.offers().selected(), 1, "the marker did not move"); + let second = drawn_overlay(&mut overlay, &DARK); + assert!( + second.contains("IS NULL"), + "the marked offer's preview is not drawn: {second}", + ); + assert!( + !second.contains("TYPE numeric"), + "the other offer's preview is open at the same time: {second}", + ); + + overlay.key(key(KeyCode::Up)); + assert_eq!(overlay.offers().selected(), 0); + let first = drawn_overlay(&mut overlay, &DARK); + assert!( + first.contains("TYPE numeric"), + "moving the marker did not open the next preview: {first}", + ); + assert!( + !first.contains("IS NULL"), + "the preview the marker left did not fold: {first}", + ); +} + +/// **F5/O4 — `Enter` on an offer whose preview is open answers with the offer.** +/// +/// The defect this closes is the one the release nearly shipped: through 0.32.0 +/// the free-text row was the only row that unfolded anything, so "something is +/// unfolded" and "the operator is writing" were the same question and `Enter` was +/// routed by the first. Give an offer a preview and that test starts answering +/// `true` on the offers as well — `Enter` would go to the composer, which is +/// empty, and the keypress would do nothing at all. +/// +/// Sabotage: route `Enter` on `Picker::unfolded_now` again, under which this +/// fails with `None` — the overlay still open and the run still stopped. +#[test] +fn f5_enter_on_an_offer_with_an_open_preview_answers_with_the_offer() { + let (mut overlay, _reply) = live(two_previews()); + overlay.key(key(KeyCode::Up)); + assert_eq!( + overlay.key(key(KeyCode::Enter)), + Some(vec![Some("backfill the ledger".to_string())]), + "Enter over an open preview did not answer with the offer under it", + ); +} + +/// One offer, one preview, so the block's height is the only thing that moves. +fn one_preview(preview: &str) -> Question { + Question::new("which migration?") + .with_choices([Choice::new("drop three columns").preview(preview)]) +} + +/// A preview of **one logical line** that wraps to several rows at a narrow +/// width. The fixture is the test: a gate written against `lines.len()` sees one +/// row here and one row in [`SHORT`], and cannot tell them apart. +const LONG: &str = + "ALTER TABLE ledger DROP COLUMN archived_at, DROP COLUMN expired_at, DROP COLUMN deleted_at;"; + +/// The control: one logical line that also *occupies* one row at that width. +const SHORT: &str = "DROP COLUMN archived_at;"; + +/// **F6 — the unfold is the WRAPPED height at the rendered width, not the count +/// of lines in the preview.** +/// +/// This is the defect 0.32.0 paid for in two overlays: a `Paragraph` with +/// wrapping on occupies more rows than it has lines, and a surface that reserved +/// `lines.len()` drew its block over rows the list had been promised. +/// +/// The two fixtures have the **same logical line count** and different wrapped +/// heights, so `lines.len()` cannot distinguish them and `rows::wrapped` must. +/// Both halves are asserted: the room asked for, and the last word of the preview +/// actually reaching the screen — a block reserved one row deep clips the rest +/// silently, which is exactly how this failure hides. +/// +/// Sabotage: measure with `preview.lines().count()` instead of `rows::wrapped`, +/// under which the difference below is 0 and `deleted_at;` is off the screen. +#[test] +fn f6_the_unfold_is_the_wrapped_height_and_not_the_line_count() { + assert_eq!( + LONG.lines().count(), + SHORT.lines().count(), + "the fixtures differ in line count, so this proves nothing about wrapping", + ); + + let (long, _a) = live(one_preview(LONG)); + let (short, _b) = live(one_preview(SHORT)); + // Identical questions, identical labels, identical heads: the only thing that + // can differ between these two numbers is the block. + assert_eq!( + long.rows_wanted(200, &DARK), + short.rows_wanted(200, &DARK), + "at a width that fits both previews on one row they must ask for the same", + ); + let extra = long + .rows_wanted(40, &DARK) + .saturating_sub(short.rows_wanted(40, &DARK)); + assert!( + extra >= 2, + "a one-line preview that wraps asked for {extra} rows more than one that \ + does not, which is the `lines.len()` measurement this release replaced", + ); + + // And the whole block is on the screen, not just the room for it. + let (mut overlay, _c) = live(one_preview(LONG)); + overlay.key(key(KeyCode::Up)); + let screen = drawn_overlay_at(&mut overlay, 40, 16, &DARK); + assert!( + screen.contains("deleted_at;"), + "the tail of a wrapped preview was clipped, so the block was reserved \ + shorter than it draws: {screen}", + ); +} + +/// **F6 — the room a preview asks for is the same number before and after the +/// frame that measures it.** +/// +/// This is the ordering the criterion is about. A preview's height is a function +/// of the width, the width is not known until something draws, and the driver +/// reads the demand **before** it draws — so a measurement that only reaches +/// `Picker::set_unfold` from inside `render` arrives a frame late: the overlay +/// opens a block too short and grows under the operator's hands on their first +/// keystroke. +/// +/// Sabotage, in both directions, and each fails only this test. Measure the +/// previews solely in `render` and the first number is short of the second. +/// Measure them in `rows_wanted` *and* leave the block the picker is now +/// reserving in the sum, and the second is larger than the first by the whole +/// block — a viewport that grows every frame until it hits the ceiling. +#[test] +fn f6_the_room_asked_for_is_the_same_before_and_after_the_first_frame() { + let (mut overlay, _reply) = live(one_preview(LONG)); + + let before = overlay.rows_wanted(40, &DARK); + let _ = drawn_overlay_at(&mut overlay, 40, 16, &DARK); + assert_eq!( + overlay.rows_wanted(40, &DARK), + before, + "the room asked for changed once a frame had measured the preview", + ); + + // And it does not follow the marker either: the reservation is the largest + // configured block, not the open one. + overlay.key(key(KeyCode::Up)); + assert_eq!( + overlay.rows_wanted(40, &DARK), + before, + "the demand moved when the marker did, which re-places the viewport on \ + every arrow key", + ); +} + +/// **F5 — the quoted block is drawn in both glyph sets, and in each set's own +/// character.** +/// +/// The vocabulary is the markdown blockquote's — `theme.glyphs.rule` and a space +/// — so it is `─ ` under Unicode and `- ` under ASCII, one cell plus a space +/// either way. Asserted with the preview's own first words attached, because the +/// ASCII `rule` is also the ASCII `dash` and a bare `contains("- ")` is satisfied +/// by the key line above the list. +/// +/// Sabotage: write the prefix as a literal `| ` or `> ` rather than taking it off +/// the glyph set, under which both arms fail; take it off `glyphs.dash` instead +/// and the Unicode arm fails, because an em dash is not the rule. +#[test] +fn f5_the_quoted_block_is_drawn_in_both_glyph_sets() { + let ascii = DARK.with_glyphs(io_cli::glyphs::ASCII); + + let (mut unicode_overlay, _a) = live(one_preview(SHORT)); + unicode_overlay.key(key(KeyCode::Up)); + let unicode = drawn_overlay(&mut unicode_overlay, &DARK); + assert!( + unicode.contains("\u{2500} DROP COLUMN archived_at;"), + "the Unicode set did not quote the preview with its own rule: {unicode}", + ); + + let (mut ascii_overlay, _b) = live(one_preview(SHORT)); + ascii_overlay.key(key(KeyCode::Up)); + let drawn = drawn_overlay(&mut ascii_overlay, &ascii); + assert!( + drawn.contains("- DROP COLUMN archived_at;"), + "the ASCII set did not quote the preview at all: {drawn}", + ); + assert!( + !drawn.contains('\u{2500}'), + "the ASCII set drew a box-drawing character, which is a replacement box \ + on the terminal that asked for ASCII: {drawn}", + ); +} + +/// **F9 — the free-text row is still last and still holds the marker once the +/// offers carry more than labels.** +/// +/// The existing F9 test uses bare offers, where the free-text row's index is +/// `choices.len()` and a marker aimed at either number lands in the same place. +/// A described offer takes a row for its description, so the two numbers part +/// company — and aiming at `choices.len()` now puts the opening marker on a real +/// offer, which is exactly the reflexive-`Enter` failure F9 exists to prevent. +/// +/// Sabotage: focus `choices.len()` rather than the last row, under which the +/// first arm's marker sits on `archived_at` — index 3 of 5 — and `Enter` agrees +/// with an offer nobody read. +#[tokio::test] +async fn f9_the_free_text_row_is_last_when_the_offers_carry_descriptions() { + let (answerer, mut questions) = io_cli::intent::channel(); + let responder: Arc = Arc::new(answerer); + let _asking = tokio::spawn(async move { + let batch = vec![explained(), Question::new("and then what?")]; + responder.answer_all(&batch).await + }); + + let mut overlay = Intent::new(questions.recv().await.expect("the batch")); + // Five rows on the first question — three offers, one description and the + // free-text row — and one on the second. + for expected in [5usize, 1] { + let rows = overlay.offers().rows(); + assert_eq!(rows.len(), expected, "the list is not the shape F4 builds"); + assert_eq!( + rows[expected - 1].label, + io_cli::intent::OWN_WORDS, + "the free-text row is not the last one", + ); + assert_eq!( + overlay.offers().selected(), + expected - 1, + "the marker opened on an offer rather than on the row that takes \ + prose, which turns a reflexive Enter into agreement", + ); + overlay.key(key(KeyCode::Char('x'))); + overlay.key(key(KeyCode::Enter)); + } +} + +/// **F4/F5 — an offer's own label is what the agent gets back, whichever row the +/// list drew it on.** +/// +/// A description takes a row, so from 0.33.0 the row index the picker hands back +/// is no longer the choice index io-harness expects. Indexing `choices` with a +/// row answers the agent with a **different offer of the same question** — +/// plausible, wrong, and invisible on screen. +/// +/// Sabotage: index `question.choices` with the row directly, under which `Enter` +/// on `updated_at` — row 2, choice 1 — answers `archived_at`. +#[test] +fn f4_choosing_an_offer_below_a_description_answers_with_that_offer() { + let (mut overlay, _reply) = live(explained()); + // Home puts the marker on the first row, then two Downs step past the + // description onto `updated_at`, which the picker refuses to stop on. + overlay.key(key(KeyCode::Home)); + assert_eq!(overlay.offers().selected(), 0); + overlay.key(key(KeyCode::Down)); + assert_eq!( + overlay.offers().selected(), + 2, + "the marker rested on a description row, which is not a choice", + ); + assert_eq!( + overlay.key(key(KeyCode::Enter)), + Some(vec![Some("updated_at".to_string())]), + "the answer is the offer under the marker, not the one at that row index", + ); +} + +// --------------------------------------------------------------------------- +// 0.33.0 — a batch that PARKED resumes as the batch the agent asked, not as the +// row's rendering of it. +// --------------------------------------------------------------------------- + +/// A batch the store itself parked as one row, written and read exactly as a +/// resume reads it. +/// +/// **`Store::put_questions`, and that is the point of the fixture.** Every stored +/// test above goes through `put_question` — the *singular* writer — which leaves +/// the row's `questions` empty, so nothing before this ever handed +/// `Intent::resumed` a batch-shaped row. That gap is why a resumed batch shipped as +/// one accent line holding embedded newlines, question one's context presented as +/// everyone's, a picker with zero offers and `multiple` defaulted to false: the +/// code path was never given the shape it fails on. +fn stored_batch(questions: &[Question]) -> PendingQuestion { + let store = io_harness::Store::memory().expect("an in-memory store"); + let run = store + .start_run("port the migration", "openrouter") + .expect("a run to hang the batch off"); + let id = store + .put_questions(run, 1, questions) + .expect("the row is written"); + store + .question(id) + .expect("the row reads back") + .expect("the row is there") +} + +/// The batch every test below resumes. +/// +/// **The second question carries everything**, deliberately: its own context, its +/// own offers, a described offer and `multiple`. A fix that read `questions[0]` and +/// built one question out of it would satisfy every assertion about the first one +/// and fail every assertion about this one, which is the only reason the tests +/// below page across before they assert. +fn parked_batch() -> Vec { + vec![ + Question::new("which database should the port target?") + .with_context("both are already in the lockfile") + .with_choices(["postgres", "sqlite"]), + Question::new("which platforms should the build cover?") + .with_context("the runner offers three and CI uses one") + .with_choices([ + Choice::new("linux").describe("the only one CI has today"), + Choice::new("windows"), + Choice::new("macos"), + ]) + .multiple(), + ] +} + +/// **A parked batch resumes as the batch, with every question's own offers, +/// context and `multiple` — reached through the live construction path.** +/// +/// The defect: `Intent::resumed` built one `Question` out of the row's `question`, +/// `context` and `choices` columns and never read `questions`. For a batch those +/// three columns are a *rendering* — the whole ask as `"1. …\n2. …"`, the **first** +/// question's context, and no choices at all — so a resumed batch was one +/// `Tone::Accent` line with embedded newlines that ratatui does not break a `Line` +/// on, one question's context presented as everyone's, an empty picker, and +/// `multiple` false: no marks and no `Question::answer_of`. It is reachable through +/// this release's own flow, since `Esc` on a batch parks the run and `/resume` +/// opens it here, and `io exec` reaches it on the first ask. +/// +/// Sabotage: build the batch from `pending.question`/`context`/`choices` as before, +/// under which the very first assertion fails on a question line that is the whole +/// numbered block. Sabotage by reading `pending.questions[0]` alone, under which +/// `PageDown` moves nothing and every assertion about the second question fails +/// while the first question's still pass. +#[test] +fn f2_a_parked_batch_resumes_as_the_batch_the_agent_asked() { + let row = stored_batch(&parked_batch()); + // What the store actually holds, so the assertions below are known to be about + // the shape the defect was reachable through rather than a fixture that + // flattered the fix. + assert!( + row.choices.is_empty(), + "put_questions writes no choices column, so a surface reading it draws a \ + picker with nothing to pick", + ); + assert!( + row.question.contains('\n'), + "the row's question is the whole batch as numbered prose: {}", + row.question, + ); + assert_eq!( + row.questions.len(), + 2, + "and the ask itself is in `questions`" + ); + + let mut overlay = Intent::resumed(&row); + assert_eq!( + overlay.question().question, + "which database should the port target?", + "the overlay opened on the row's rendering rather than on the first \ + question the agent asked", + ); + + // Tall enough that nothing asserted here is a row the picker elided: a batch's + // head is five lines before the offers start. + let first = drawn_overlay_at(&mut overlay, 80, 20, &DARK); + assert!( + first.contains("question 1 of 2"), + "a resumed batch has no batch chrome, so nothing on screen says another \ + question is waiting behind this one: {first}", + ); + + overlay.key(key(KeyCode::PageDown)); + assert_eq!( + overlay.question().question, + "which platforms should the build cover?", + "PgDn moved nothing, so the resumed overlay is holding one question", + ); + assert!( + overlay.question().multiple, + "the second question's `multiple` did not survive the store, so the \ + spacebar marks nothing and `Question::answer_of` never spells the answer", + ); + + let labels: Vec<&str> = overlay + .offers() + .rows() + .iter() + .map(|offer| offer.label.as_str()) + .collect(); + assert_eq!( + labels, + [ + "linux", + " the only one CI has today", + "windows", + "macos", + io_cli::intent::OWN_WORDS, + ], + "the second question's offers, its described offer's own row, and the row \ + that takes prose", + ); + + let second = drawn_overlay_at(&mut overlay, 80, 20, &DARK); + assert!( + second.contains("the runner offers three and CI uses one"), + "the second question is drawn under the FIRST question's context: {second}", + ); + assert!( + !second.contains("both are already in the lockfile"), + "question one's context is presented as question two's: {second}", + ); + assert!( + second.contains("space marks several"), + "the list did not opt into marks, so a `multiple` question cannot be \ + answered with more than one offer: {second}", + ); +} + +/// **A resumed batch is delivered as ONE text, because it parked as one row.** +/// +/// io-harness parks a whole `ask_questions` under a single `question_id` and +/// `answer_question` is a single compare-and-swap, so there is one text to hand +/// `resume_with_answer_observed` however many questions the operator worked +/// through. Every answer is paired with the question it answers, because a bare +/// list of sentences makes the model re-derive the pairing from position. +/// +/// Sabotage: return the last answer alone — the shape a `Destination::Stored` per +/// question falls into when `resolve` just zips and keeps the last delivery — under +/// which the first question's words vanish and the run is resumed with half the ask +/// answered. Sabotage the pairing by joining the answers with no question text +/// between them, under which the equality fails on a block the model would have to +/// re-derive the pairing of from position. +#[test] +fn f2_a_resumed_batch_comes_back_as_one_text_for_the_one_row_it_parked_as() { + let mut overlay = Intent::resumed(&stored_batch(&parked_batch())); + + for ch in "postgres, and drop the old one".chars() { + overlay.key(key(KeyCode::Char(ch))); + } + assert_eq!( + overlay.key(key(KeyCode::Enter)), + None, + "one question of two decided, and something was delivered anyway", + ); + + // On the second question, which takes several. Up from the row that takes + // prose is `macos`, and one more is `windows`. + overlay.key(key(KeyCode::Up)); + overlay.key(key(KeyCode::Char(' '))); + overlay.key(key(KeyCode::Up)); + overlay.key(key(KeyCode::Char(' '))); + let answers = overlay + .key(key(KeyCode::Enter)) + .expect("both questions are decided, so the batch is delivered"); + assert_eq!( + answers, + vec![ + Some("postgres, and drop the old one".to_string()), + Some(Question::answer_of(["windows", "macos"])), + ], + ); + + let delivered = overlay + .resolve(answers) + .expect("a resumed row has no turn awaiting it: the caller resumes with this") + .expect("every question was answered, so there is an answer"); + assert_eq!( + delivered, + format!( + "1. which database should the port target?\n postgres, and drop the old one\n\ + 2. which platforms should the build cover?\n {}", + Question::answer_of(["windows", "macos"]), + ), + "every answer beside the question it answers, once, in the order asked", + ); +} + +/// **One decline anywhere in a resumed batch delivers no answer at all**, and the +/// run stays parked on the row it was found on. +/// +/// A batch is answered wholly or not at all — io-harness's own rule for the live +/// path, and it binds harder here: resolving the row is one compare-and-swap with +/// no second chance, so a text assembled around a hole would answer an ask the +/// operator did not finish. `Some(None)` is what the driver reads as *the operator +/// left it*, which is exactly what `Esc` has always promised. +/// +/// Sabotage: assemble the text anyway with a placeholder for the missing answer, +/// under which this returns `Some(Some(..))` and the run is resumed with an answer +/// nobody gave. +#[test] +fn f2_one_decline_in_a_resumed_batch_leaves_the_run_parked() { + let mut overlay = Intent::resumed(&stored_batch(&parked_batch())); + + for ch in "postgres".chars() { + overlay.key(key(KeyCode::Char(ch))); + } + assert_eq!(overlay.key(key(KeyCode::Enter)), None); + let answers = overlay + .key(key(KeyCode::Esc)) + .expect("declining the last undecided question decides the batch"); + assert_eq!(answers, vec![Some("postgres".to_string()), None]); + + assert_eq!( + overlay.resolve(answers), + Some(None), + "an answer was assembled around a question nobody answered", + ); +} + +/// **A singular row is untouched by the batch path, including one written before +/// `questions` existed.** +/// +/// `Store::put_question` writes no `questions` value at all, and a row written by +/// io-harness 0.71.0 has no such column — so the row's own `question`, `context` and +/// `choices` are the ask, exactly as they were, and reading `questions` first must +/// not change that. Its choices are bare labels, which io-harness serializes as a +/// plain JSON array of strings byte for byte as 0.71.0 wrote it (its own `Choice` +/// doctests gate that encoding); this asserts the consequence, which is that they +/// come back as selectable offers answering with their own labels. +/// +/// Sabotage: read `pending.questions` unconditionally, under which a singular row +/// hands the constructor an empty batch and this panics on the first index before +/// it asserts anything. Sabotage the fallback's `with_choices`, under which the +/// offers are gone, `Home` rests on the row that takes prose, and the `Enter` +/// assertion answers nothing at all. +#[test] +fn f2_a_singular_row_still_opens_on_its_own_columns() { + let row = stored(&question()); + assert!( + row.questions.is_empty(), + "the singular writer wrote a batch, so this fixture is not the shape it \ + claims to be", + ); + assert!( + row.choices + .iter() + .all(|choice| choice.description.is_none() && choice.preview.is_none()), + "the fixture's offers are bare labels, which is the 0.71.0 spelling", + ); + + let mut overlay = Intent::resumed(&row); + let screen = drawn_overlay(&mut overlay, &DARK); + assert!( + !screen.contains("question 1 of"), + "a single question grew batch chrome: {screen}", + ); + assert!( + screen.contains("drop the column or keep it?") && screen.contains("created_at"), + "the row's own columns are the ask: {screen}", + ); + + overlay.key(key(KeyCode::Home)); + assert_eq!( + overlay.key(key(KeyCode::Enter)), + Some(vec![Some("created_at".to_string())]), + "a bare label round-tripped through the store is still an offer that \ + answers with itself", + ); +} diff --git a/tests/live.rs b/tests/live.rs index 60d4f35..85c508a 100644 --- a/tests/live.rs +++ b/tests/live.rs @@ -1,8 +1,8 @@ //! The live rehearsal for F1. //! -//! Every test here is `#[ignore]`d, so `cargo test` never runs one and CI — which -//! holds no secrets — never tries. Run them by hand with a key in the -//! environment: +//! Every test that talks to a provider is `#[ignore]`d, so `cargo test` never +//! runs one and CI — which holds no secrets — never tries. Run them by hand with +//! a key in the environment: //! //! ```text //! export OPENROUTER_API_KEY=sk-or-... @@ -20,6 +20,11 @@ //! credential check against a real endpoint, the catalogue read, and a real turn //! driven through the real bridge and the real event renderer, editing a real //! file inside the sandbox. +//! +//! **One test here is not live and runs everywhere**, deliberately: +//! `every_live_arm_that_watches_for_a_question_names_both_ask_variants` reads +//! this file's own source. An arm nobody runs in CI is an arm nothing notices has +//! stopped matching, and 0.33.0 paid for exactly that — see its own comment. mod support; @@ -1254,22 +1259,33 @@ async fn live_f1_f3_f4_a_contained_turn_carries_this_crates_contract() { crossterm::event::KeyModifiers::NONE, )); } - Some(asked) = questions.recv() => { + Some(delivery) = questions.recv() => { + // **One delivery is one overlay, however many questions it + // carries** (0.33.0). A batch is answered in place — one + // question on screen at a time, deciding one moves to the next + // undecided one, deciding the last delivers — so the operator's + // side types an answer and presses Enter once per question. A + // batch left part-answered is committed by nothing and parks the + // whole run, which is why this loops rather than answering the + // first and moving on. + let count = delivery.len(); answered .lock() .expect("not poisoned") - .push(format!("question: {}", asked.question.question)); - app.open_intent(asked); - for character in "the second one".chars() { + .push(format!("questions: {count}")); + app.open_intent(delivery); + for _ in 0..count { + for character in "the second one".chars() { + app.key(crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Char(character), + crossterm::event::KeyModifiers::NONE, + )); + } app.key(crossterm::event::KeyEvent::new( - crossterm::event::KeyCode::Char(character), + crossterm::event::KeyCode::Enter, crossterm::event::KeyModifiers::NONE, )); } - app.key(crossterm::event::KeyEvent::new( - crossterm::event::KeyCode::Enter, - crossterm::event::KeyModifiers::NONE, - )); } else => break, } @@ -1351,9 +1367,22 @@ async fn live_f1_f3_f4_a_contained_turn_carries_this_crates_contract() { // **F3, and the opportunistic half.** Whether a model asks about intent is // its choice; what must hold is that an answer given here is the answer // recorded there. + // + // **Both ask variants, and that is a gate-integrity fix rather than a + // feature (0.33.0).** io-harness 0.72.0 emits `QuestionsAsked` for a batched + // ask and does *not* also emit the singular, so watching only the singular + // means the moment a model picks `ask_questions` this count is zero, the + // assertion below is skipped, and a live arm that proves nothing looks + // exactly like one that passed. `every_live_arm_that_watches_for_a_question` + // below keeps this pair together. let asked = events .iter() - .filter(|event| matches!(event.kind, EventKind::QuestionAsked { .. })) + .filter(|event| { + matches!( + event.kind, + EventKind::QuestionAsked { .. } | EventKind::QuestionsAsked { .. } + ) + }) .count(); println!("live 0.10.0: {asked} questions asked"); if asked > 0 { @@ -1437,22 +1466,28 @@ async fn live_f1_a_question_is_answered_on_an_uncontained_turn() { let answered = Arc::clone(&answers); let operator = tokio::spawn(async move { let mut app = App::new(DARK, "live"); - while let Some(asked) = questions.recv().await { + while let Some(delivery) = questions.recv().await { + // One answer and one Enter per question the delivery carries, for the + // reason the contained arm above records: a batch is answered in place + // and a part-answered one parks the run. + let count = delivery.len(); answered .lock() .expect("not poisoned") - .push(asked.question.question.clone()); - app.open_intent(asked); - for character in "the one under archive".chars() { + .push(format!("questions: {count}")); + app.open_intent(delivery); + for _ in 0..count { + for character in "the one under archive".chars() { + app.key(crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Char(character), + crossterm::event::KeyModifiers::NONE, + )); + } app.key(crossterm::event::KeyEvent::new( - crossterm::event::KeyCode::Char(character), + crossterm::event::KeyCode::Enter, crossterm::event::KeyModifiers::NONE, )); } - app.key(crossterm::event::KeyEvent::new( - crossterm::event::KeyCode::Enter, - crossterm::event::KeyModifiers::NONE, - )); } }); @@ -1476,9 +1511,17 @@ async fn live_f1_a_question_is_answered_on_an_uncontained_turn() { let _ = operator.await; let events = collected.lock().expect("not poisoned").clone(); + // Both ask variants, for the reason the contained arm above records: a + // batched ask emits only the plural, and an arm watching one name would go + // silent rather than red the first time a model batches. let asked = events .iter() - .filter(|event| matches!(event.kind, EventKind::QuestionAsked { .. })) + .filter(|event| { + matches!( + event.kind, + EventKind::QuestionAsked { .. } | EventKind::QuestionsAsked { .. } + ) + }) .count(); println!("live 0.12.0 F1: outcome {:?}", result.outcome); println!( @@ -1500,6 +1543,239 @@ async fn live_f1_a_question_is_answered_on_an_uncontained_turn() { } } +/// **F1 (0.33.0) — a batch asked in one call is answered on one overlay.** +/// +/// The arm io-harness 0.72.0's `ask_questions` exists for, driven through `App` +/// so what answers is the type a keystroke reaches. The goal names the tool +/// rather than hoping for it: what is being verified here is that this crate's +/// overlay answers a batch and the run takes the answers, not the model's +/// judgement about when batching is the right move — that judgement is what the +/// two arms above already leave to the model. +/// +/// **Every answer is distinct, and that is the assertion.** io-harness emits one +/// `QuestionAnswered` per answer, in the order the questions were asked, so the +/// texts this side typed and the texts the run recorded are comparable as +/// sequences. That is what makes this fail rather than go quiet: +/// +/// * an overlay that answered the first question and delivered leaves the batch +/// part-answered, io-harness commits a batch only when every entry is `Some`, +/// the run parks, and **nothing** is recorded — zero answers against the two or +/// three typed here; +/// * an overlay that answered them in the wrong order, or that sent one answer to +/// every question, records a sequence that is the wrong sequence; +/// * a model that declined to ask at all types nothing, which is the model's +/// choice and is printed rather than asserted, exactly as in the arms above. +#[tokio::test] +#[ignore = "live: needs OPENROUTER_API_KEY"] +async fn live_f1_a_batched_ask_is_answered_on_one_overlay() { + use io_cli::app::App; + use io_cli::contract::Capabilities; + + let key = key(); + let dir = tempfile::tempdir().expect("a workspace"); + let root = dir.path(); + std::fs::write( + root.join("notes.md"), + "# notes\n\nold line\n\n## archive\n\nold line\n", + ) + .expect("the fixture file"); + + let store = Store::open(root.join("runs.db")).expect("a store"); + let mut session = Session::open(&store, root).expect("a session"); + let provider = io_harness::OpenRouter::new(&key, model()); + let policy = workspace_policy(); + + let collected = Arc::new(Mutex::new(Vec::new())); + let observer = Collector { + events: Arc::clone(&collected), + }; + + let (answerer, mut questions) = io_cli::intent::channel(); + let contract = io_cli::contract::session( + "Add a short `## setup` section to notes.md. Before you touch the file, use ONE \ + `ask_questions` call to ask me all three of these together: where the section goes, what \ + its one line should say, and whether the `## archive` section stays. Do not edit anything \ + until you have my answers.", + root.to_path_buf(), + &no_configuration(), + &no_configuration().plugins(), + &Capabilities::default(), + Arc::new(answerer), + None, + ) + .with_max_steps(12); + + // A distinct answer per question, in the order they were asked, kept on this + // side so the recorded sequence has something to be compared against. + let spoken = Arc::new(Mutex::new(Vec::::new())); + let typing = Arc::clone(&spoken); + let operator = tokio::spawn(async move { + let mut app = App::new(DARK, "live"); + let mut nth = 0usize; + while let Some(delivery) = questions.recv().await { + let count = delivery.len(); + println!("live 0.33.0: a delivery of {count}"); + app.open_intent(delivery); + // One answer and one Enter per question: the batch is answered in + // place, deciding one moves to the next undecided one, and deciding + // the last is what delivers. + for _ in 0..count { + nth += 1; + let text = format!("answer {nth}"); + typing.lock().expect("not poisoned").push(text.clone()); + for character in text.chars() { + app.key(crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Char(character), + crossterm::event::KeyModifiers::NONE, + )); + } + app.key(crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Enter, + crossterm::event::KeyModifiers::NONE, + )); + } + } + }); + + let result = session + .turn_bounded_observed( + &contract, + &provider, + &store, + &policy, + &io_harness::ApproveAll, + &observer, + ) + .await + .expect("the turn runs"); + + // The contract first, for the reason the arms above record: the operator's + // loop ends when the responder inside it is dropped. + drop(contract); + drop(session); + let _ = operator.await; + + let events = collected.lock().expect("not poisoned").clone(); + let asked = events + .iter() + .filter(|event| { + matches!( + event.kind, + EventKind::QuestionAsked { .. } | EventKind::QuestionsAsked { .. } + ) + }) + .count(); + let batched: Vec = events + .iter() + .filter_map(|event| match &event.kind { + EventKind::QuestionsAsked { questions } => Some(questions.len()), + _ => None, + }) + .collect(); + let typed = spoken.lock().expect("not poisoned").clone(); + println!("live 0.33.0: outcome {:?}", result.outcome); + println!("live 0.33.0: {asked} asks, batches of {batched:?}, typed {typed:?}"); + + if typed.is_empty() { + eprintln!("live 0.33.0: the model asked nothing, so there is no batch to answer"); + return; + } + + let recorded: Vec = events + .iter() + .filter_map(|event| match &event.kind { + EventKind::QuestionAnswered { answer, by } if by == "responder" => { + Some(answer.trim().to_string()) + } + _ => None, + }) + .collect(); + assert_eq!( + recorded, typed, + "every answer typed on the overlay must reach the run, in the order it was asked — a \ + batch this side left part-answered is committed by nothing and parks the run with no \ + answer recorded at all", + ); +} + +/// **The offline gate that keeps the three arms above from going silent +/// (0.33.0).** +/// +/// A live arm runs by hand, never in CI and not every release, so nothing else in +/// this repository would notice the day one of them stopped matching. io-harness +/// 0.72.0 emits the plural for a batched ask and does **not** also emit the +/// singular, so an arm that filters on the singular alone matches nothing the +/// moment a model picks `ask_questions`: the count goes to zero, the assertion +/// behind `if asked > 0` never runs, and the arm passes having proved nothing. +/// Silence is indistinguishable from a green gate, which is the one failure a +/// test must not have. +/// +/// **Why this is not a `contains`.** `source.contains("QuestionsAsked")` is +/// satisfied by this very paragraph, by a `use`, by any prose anywhere in a file +/// this size — it would go green on a file whose every arm still watched the +/// singular alone. So the needle is pinned to the arm it has to appear in: every +/// *pattern position* — a variant name followed by a braced rest pattern, which +/// is a `matches!` arm and nothing else — must sit in an alternation with the +/// other variant. Prose never trips this, because prose does not write a rest +/// pattern after a name; and a comment can never satisfy it, because satisfying +/// it means being the other half of an alternation. +/// +/// Sabotage: drop the plural from either alternation above. This fails naming the +/// file and the line it was dropped from. +#[test] +fn every_live_arm_that_watches_for_a_question_names_both_ask_variants() { + // Spelled in halves so this test's own source carries no occurrence of what + // it hunts for. A gate whose first finding is its own assertion message is a + // gate that gets deleted. + let one = concat!("Question", "Asked"); + let many = concat!("Questions", "Asked"); + let arm = format!("{one} {{ .. }}"); + // Either order, and unqualified: the qualification is stripped below so that + // `EventKind::` being imported or spelled out is not what this gate decides. + let paired = [ + format!("{arm} | {many} {{ .. }}"), + format!("{many} {{ .. }} | {arm}"), + ]; + + let lines: Vec<&str> = include_str!("live.rs").lines().collect(); + let mut watching = 0usize; + let mut lone: Vec = Vec::new(); + for (index, line) in lines.iter().enumerate() { + if !line.replace("EventKind::", "").contains(&arm) { + continue; + } + watching += 1; + // Two lines, whitespace flattened: an alternation rustfmt wrapped onto the + // next line is still one arm, and a gate that failed on formatting is a + // gate someone turns off inside a release. + let window = lines[index..(index + 2).min(lines.len())] + .join(" ") + .replace("EventKind::", "") + .split_whitespace() + .collect::>() + .join(" "); + if !paired.iter().any(|form| window.contains(form.as_str())) { + lone.push(format!("tests/live.rs:{}: {}", index + 1, line.trim())); + } + } + + assert!( + lone.is_empty(), + "these live arms watch for one ask variant only, so a batched ask makes them match \ + nothing and their assertions are skipped rather than failed — write `{arm} | {many} {{ \ + .. }}`:\n{}", + lone.join("\n"), + ); + // **And the gate is not vacuous.** A renamed variant, or a rewrite that stops + // matching on the kind at all, would leave the loop above finding nothing and + // passing — which is the same silence this file exists to close. + assert!( + watching >= 2, + "this gate found {watching} live arms watching for a question; it is watching nothing, \ + so `{one}` is no longer how those arms are written", + ); +} + /// **F2 (0.12.0) — a contained turn proposes no plan unless the operator asked.** /// /// The absence this release exists for, asserted on the events rather than on an diff --git a/tests/manage.rs b/tests/manage.rs index e1c2aa7..55e5f8c 100644 --- a/tests/manage.rs +++ b/tests/manage.rs @@ -8,7 +8,7 @@ //! exists to have and the reason the parse is in the library rather than in //! `src/main.rs`, which nothing under `tests/` can link. -use std::path::Path; +use std::path::{Path, PathBuf}; use io_cli::manage::{self, ConfigVerb, McpVerb, PluginVerb, Request}; use io_harness::config::Scope; @@ -20,8 +20,9 @@ fn argv(words: &[&str]) -> Vec { } /// `Config::discover` reads `IO_CONFIG` at call time, so two tests setting it at -/// once would each see the other's file. Only the scope test needs it — every -/// other test here parses rather than plans. +/// once would each see the other's file. The scope test sets it; the `plugin +/// remove` tests below resolve a real configuration and would see it set, so they +/// take the same lock. Every other test here parses rather than plans. fn env_lock() -> std::sync::MutexGuard<'static, ()> { static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) @@ -41,7 +42,9 @@ fn server(request: &Request) -> &io_harness::McpServer { /// of parsed structs: two orderings could agree on every field of an `McpServer` /// and still be written by two code paths that spell an entry differently. fn written(root: &Path, request: &Request) -> String { - let plan = manage::plan(root, request) + // No declared bundle: every request written through this helper is an `mcp` or + // `config` one, and `plan` reads the set only for `plugin remove`. + let plan = manage::plan(root, request, &[]) .expect("the request plans") .expect("a write, not a read"); io_cli::edit::apply("", &plan.edits).expect("the edits apply to an empty file") @@ -78,6 +81,7 @@ fn f13_a_planned_config_write_inherits_the_deciding_file() { let inherited = manage::plan( root.path(), &manage::parse(&argv(&["config", "set", "app.io-cli.theme", "light"])).expect("it parses"), + &[], ) .expect("it plans") .expect("a write"); @@ -92,6 +96,7 @@ fn f13_a_planned_config_write_inherits_the_deciding_file() { let user = manage::plan( root.path(), &manage::parse(&argv(&["config", "set", "run.max_steps", "20"])).expect("it parses"), + &[], ) .expect("it plans") .expect("a write"); @@ -109,6 +114,7 @@ fn f13_a_planned_config_write_inherits_the_deciding_file() { "local", ])) .expect("it parses"), + &[], ) .expect("it plans") .expect("a write"); @@ -118,6 +124,7 @@ fn f13_a_planned_config_write_inherits_the_deciding_file() { let unset = manage::plan( root.path(), &manage::parse(&argv(&["config", "unset", "app.io-cli.theme"])).expect("it parses"), + &[], ) .expect("it plans") .expect("a write"); @@ -535,7 +542,7 @@ fn f6_config_set_takes_a_value_the_kind_admits_and_names_the_options_for_one_it_ scope: None, }) ); - let plan = manage::plan(root.path(), &request) + let plan = manage::plan(root.path(), &request, &[]) .expect("it plans") .expect("a write"); assert_eq!(plan.scope, Scope::User); @@ -574,7 +581,7 @@ max_retries = 2 let root = tempfile::tempdir().expect("a temporary directory"); let request = manage::parse(&argv(&["config", "unset", "run.max_steps"])).expect("unset parses"); - let plan = manage::plan(root.path(), &request) + let plan = manage::plan(root.path(), &request, &[]) .expect("it plans") .expect("a write"); @@ -655,7 +662,7 @@ command = \"mcp-search\" let request = manage::parse(&argv(&["mcp", "edit", "search", "--command", "mcp-find"])) .expect("one key at a time"); - let plan = manage::plan(root.path(), &request) + let plan = manage::plan(root.path(), &request, &[]) .expect("the server is declared") .expect("a write"); assert_eq!(plan.scope, Scope::Project); @@ -673,7 +680,7 @@ command = \"mcp-search\" assert!(aimed.contains("--scope"), "{aimed}"); let removal = manage::parse(&argv(&["mcp", "remove", "search"])).expect("remove parses"); - let plan = manage::plan(root.path(), &removal) + let plan = manage::plan(root.path(), &removal, &[]) .expect("the server is declared") .expect("a write"); assert_eq!(plan.scope, Scope::Project); @@ -685,7 +692,7 @@ command = \"mcp-search\" // A server no file in force declares is a refusal naming it, not a write to // a position that was guessed. let missing = manage::parse(&argv(&["mcp", "remove", "absent"])).expect("it parses"); - let refusal = manage::plan(root.path(), &missing).expect_err("nothing declares it"); + let refusal = manage::plan(root.path(), &missing, &[]).expect_err("nothing declares it"); assert!(refusal.contains("absent"), "{refusal}"); } @@ -704,7 +711,7 @@ fn f6_a_reading_verb_plans_no_write_at_all() { ] { let request = manage::parse(&manage::tokens(line)).expect(line); assert!( - manage::plan(root.path(), &request) + manage::plan(root.path(), &request, &[]) .expect("a read plans") .is_none(), "{line} planned a write" @@ -729,7 +736,7 @@ fn f6_a_plugin_directory_with_no_manifest_is_refused_before_anything_is_written( scope: Scope::User, }) ); - let refusal = manage::plan(root.path(), &request).expect_err("it is not a bundle"); + let refusal = manage::plan(root.path(), &request, &[]).expect_err("it is not a bundle"); assert!(refusal.contains(io_cli::pluginview::MANIFEST), "{refusal}"); // And with a manifest it is declared as written, relative to the root, so a @@ -741,13 +748,276 @@ fn f6_a_plugin_directory_with_no_manifest_is_refused_before_anything_is_written( "name = \"demo\"\n", ) .expect("a manifest"); - let plan = manage::plan(root.path(), &request) + let plan = manage::plan(root.path(), &request, &[]) .expect("it is a bundle now") .expect("a write"); let after = io_cli::edit::apply("", &plan.edits).expect("the entry is written"); assert!(after.contains("path = \"bundle\""), "{after}"); } +// --- `plugin remove `: the path first, then the declared name ------------- +// +// `io plugin add ` installs a bundle a marketplace holds and then tells the +// operator that `plugin remove ` takes it back out. Until this release that +// sentence was false: the verb resolved its word as a path only, so the id it had +// just printed found nothing and the refusal named a path nobody typed. The two +// readings now sit here in the order `marketplace::chosen` states — the disk is +// asked about the directory first, and nothing reads the *shape* of the word — +// and everything below is about that order, about never taking the first of two +// bundles sharing a name, and about the refusals saying which reading failed. +// +// Every fixture declares its bundles inside a fresh `tempfile` root and addresses +// entries by id and by path. No test here asserts a position in a +// `Config::discover` result: the user-scope file on the machine running the suite +// is discovered too, and six tests in an earlier release asserted indices over the +// developer's own `~/.io-cli/io.toml` and were green on CI alone. + +/// A bundle directory at `at` whose manifest carries `name`. +fn manifest(root: &Path, at: &str, name: &str) -> PathBuf { + let dir = root.join(at); + std::fs::create_dir_all(&dir).expect("the bundle directory"); + std::fs::write( + dir.join(io_cli::pluginview::MANIFEST), + format!("name = \"{name}\"\n"), + ) + .expect("the manifest"); + dir +} + +/// A local-scope file declaring each `(path, enabled)` in order, and its bytes. +fn declaring(root: &Path, entries: &[(&str, bool)]) -> String { + let text: String = entries + .iter() + .map(|(path, on)| { + let off = if *on { "" } else { "enabled = false\n" }; + format!("[[plugin]]\npath = \"{path}\"\n{off}\n") + }) + .collect(); + std::fs::write(root.join(io_harness::config::LOCAL_FILE), &text).expect("the configuration"); + text +} + +/// The `(id, directory)` pairs a door hands `plan`, resolved the way both doors +/// resolve them — through `resolved::Resolved`, which is the one module permitted +/// to call `Config::plugins()`. +fn declared_bundles(root: &Path) -> Vec<(String, PathBuf)> { + let config = io_harness::Config::discover(root).expect("the configuration discovers"); + io_cli::pluginview::ids(&io_cli::pluginview::view( + io_cli::resolved::Resolved::load(&config).loaded(), + )) +} + +/// The file after the removal `line` plans, or the refusal it gave instead. +fn removing(root: &Path, before: &str, line: &str) -> Result<(Scope, String), String> { + let request = manage::parse(&manage::tokens(line)).expect("the line parses"); + let plan = manage::plan(root, &request, &declared_bundles(root))? + .expect("a removal is a write, not a read"); + Ok(( + plan.scope, + io_cli::edit::apply(before, &plan.edits).expect("the edits apply to the file"), + )) +} + +/// **The path reading is asked first, and a word that is both a declared directory +/// and another bundle's name is the directory.** +/// +/// This is `marketplace::chosen`'s rule for `plugin add`, kept for `plugin remove`: +/// the disk answers, and nothing keys on whether the word holds a `/`, a leading +/// `.` or an extension. `docs/guide/headless.md` documents +/// `io plugin remove ./bundles/rust-review`, and `tests/commands.rs` sends the same +/// spelling through the slash form. +/// +/// Sabotage: match declared ids before asking the disk. Only this test fails — +/// and it fails by removing `bundles/elsewhere`, the entry the operator did not +/// name, which is the silent wrong delete this whole path is written against. +#[test] +fn f6_plugin_remove_reads_a_word_that_is_a_declared_directory_as_the_directory() { + // Every test below resolves a real configuration, and `Config::discover` reads + // `IO_CONFIG` at call time — so they take the same lock the scope test does. + let _lock = env_lock(); + let dir = tempfile::tempdir().expect("a temporary directory"); + let root = dir.path(); + // A directory called `twin`, and a *different* directory whose manifest is + // called `twin`. One word, two readings, and the disk decides. + manifest(root, "twin", "other-bundle"); + manifest(root, "bundles/elsewhere", "twin"); + let before = declaring(root, &[("twin", true), ("bundles/elsewhere", true)]); + + let (scope, after) = removing(root, &before, "plugin remove twin").expect("the directory"); + assert_eq!( + scope, + Scope::Local, + "the file that declared it is the file edited" + ); + assert!( + !after.contains("path = \"twin\""), + "the directory named on the command line is still declared: {after}", + ); + assert!( + after.contains("path = \"bundles/elsewhere\""), + "the bundle whose manifest is called `twin` was removed instead of the \ + directory the operator typed: {after}", + ); + + // The documented spelling resolves to the same entry it always has. + let (_, after) = + removing(root, &before, "plugin remove ./bundles/elsewhere").expect("the directory"); + assert!( + !after.contains("path = \"bundles/elsewhere\""), + "`./` in front of a declared directory stopped resolving, and that is the \ + spelling `docs/guide/headless.md` prints: {after}", + ); + assert!(after.contains("path = \"twin\""), "{after}"); +} + +/// **A word no file declares as a directory is read as a declared bundle's name**, +/// which is what `plugin add ` tells the operator to type. +/// +/// Sabotage: drop the id fallback and keep the path reading alone — the state this +/// release found. Only this test and the two below fail, and what ships is an +/// install that prints a removal command which cannot work. +#[test] +fn f6_plugin_remove_takes_the_name_a_declared_bundle_carries() { + let _lock = env_lock(); + let dir = tempfile::tempdir().expect("a temporary directory"); + let root = dir.path(); + manifest(root, "bundles/first", "alpha-bundle"); + manifest(root, "bundles/second", "beta-bundle"); + let before = declaring(root, &[("bundles/first", true), ("bundles/second", true)]); + + let (scope, after) = + removing(root, &before, "plugin remove beta-bundle").expect("the name resolves"); + assert_eq!(scope, Scope::Local); + assert!( + !after.contains("path = \"bundles/second\""), + "the entry declaring the bundle called `beta-bundle` is still there: {after}", + ); + assert!( + after.contains("path = \"bundles/first\""), + "the wrong entry was removed — a name resolved to a neighbour: {after}", + ); +} + +/// **Two declared bundles of one name are refused, with both directories named.** +/// +/// `Listed::id` is unique among the bundles io-harness *loaded*; two declared +/// `enabled = false` may share one, which is the `tools-v1`/`tools-v2` swap the +/// flag exists for. Taking the first of them deletes a `[[plugin]]` entry the +/// operator never pointed at, and nothing says so until a bundle's skills stop +/// being offered — so the refusal hands back the spelling that disambiguates, +/// which is the directory, and which is what the path reading above resolves. +/// +/// Sabotage: return the first hit instead of collecting them. Only this test +/// fails, and it fails silently in the field. +#[test] +fn f6_plugin_remove_refuses_two_bundles_of_one_name_and_names_both_directories() { + let _lock = env_lock(); + let dir = tempfile::tempdir().expect("a temporary directory"); + let root = dir.path(); + manifest(root, "bundles/one", "twinned"); + manifest(root, "bundles/two", "twinned"); + // Both switched off: io-harness reserves an id only for a bundle it switched + // on, so this is the shape in which two entries genuinely share one. + let before = declaring(root, &[("bundles/one", false), ("bundles/two", false)]); + + let refusal = removing(root, &before, "plugin remove twinned") + .expect_err("one name, two bundles, no answer"); + for named in ["bundles/one", "bundles/two"] { + assert!( + refusal.contains(named), + "the refusal must name every candidate's path, and `{named}` is not in \ + it: {refusal}", + ); + } + assert!( + refusal.contains('2'), + "the refusal should say how many bundles answer to the name: {refusal}", + ); + + // The fixture is sound: each of them is still removable by its directory, so + // the refusal above is about the ambiguity and not about a broken file. + let (_, after) = removing(root, &before, "plugin remove bundles/two").expect("the directory"); + assert!(!after.contains("path = \"bundles/two\""), "{after}"); + assert!(after.contains("path = \"bundles/one\""), "{after}"); +} + +/// **A word that is neither reading is refused as neither**, naming the directory +/// that was looked for and the name that was looked up. +/// +/// The refusal before this release named only the path — a path the operator never +/// typed, because they had typed a name — and left them with no way to tell a +/// misspelt directory from a misspelt bundle. +/// +/// Sabotage: drop the name half of the sentence and keep the old path-only +/// refusal. Only this test fails. +#[test] +fn f6_plugin_remove_refuses_a_word_that_is_neither_a_directory_nor_a_name() { + let _lock = env_lock(); + let dir = tempfile::tempdir().expect("a temporary directory"); + let root = dir.path(); + manifest(root, "bundles/first", "alpha-bundle"); + let before = declaring(root, &[("bundles/first", true)]); + + let refusal = removing(root, &before, "plugin remove nowhere-at-all") + .expect_err("neither a declared directory nor a declared name"); + assert!( + refusal.contains(&root.join("nowhere-at-all").display().to_string()), + "the directory reading is not reported, so an operator who mistyped a path \ + cannot see what was looked for: {refusal}", + ); + assert!( + refusal.contains("is called `nowhere-at-all`"), + "the refusal names only the path, which is the sentence this release \ + replaced: an operator who typed a name is told about a path they never \ + wrote: {refusal}", + ); +} + +/// **A bundle that did not load is removable by the name the listing shows**, and +/// it is the one an operator most wants gone. +/// +/// A directory with no manifest is dropped by io-harness, listed under +/// `pluginview::Refused`, and cannot be repaired from a manifest that is not there. +/// Its id is the directory's own name — asserted here rather than assumed, so that +/// a word which is secretly a path could not be what makes the removal work. +/// +/// Sabotage: build the pairs from `view.plugins` alone and leave `view.refused` +/// out. Only this test fails, and what ships is a broken entry an operator can see +/// on `/plugin` and cannot take out by the name it is listed under. +#[test] +fn f6_plugin_remove_takes_the_name_of_a_bundle_that_was_refused() { + let _lock = env_lock(); + let dir = tempfile::tempdir().expect("a temporary directory"); + let root = dir.path(); + manifest(root, "bundles/good", "good-bundle"); + std::fs::create_dir_all(root.join("bundles/ghost-bundle")).expect("a directory, no manifest"); + let before = declaring( + root, + &[("bundles/good", true), ("bundles/ghost-bundle", true)], + ); + + let declared = declared_bundles(root); + assert!( + declared + .iter() + .any(|(id, at)| id == "ghost-bundle" && at.ends_with("ghost-bundle")), + "a dropped bundle is identified by the directory's own name, and the pairs \ + handed to `plan` must carry it: {declared:?}", + ); + + let (scope, after) = + removing(root, &before, "plugin remove ghost-bundle").expect("the refused bundle"); + assert_eq!(scope, Scope::Local); + assert!( + !after.contains("path = \"bundles/ghost-bundle\""), + "the entry declaring the bundle that would not load is still there: {after}", + ); + assert!( + after.contains("path = \"bundles/good\""), + "the bundle that loaded was removed instead: {after}", + ); +} + // --- refusals say what was wrong ------------------------------------------------ #[test] diff --git a/tests/marketplace.rs b/tests/marketplace.rs index cf1f112..0dc00fa 100644 --- a/tests/marketplace.rs +++ b/tests/marketplace.rs @@ -194,7 +194,7 @@ fn f1_a_marketplace_verb_plans_no_configuration_write() { ] { let request = manage::parse(&manage::tokens(line)).expect(line); assert!( - manage::plan(root.path(), &request) + manage::plan(root.path(), &request, &[]) .expect("a marketplace verb plans") .is_none(), "{line} planned a write to a configuration file", diff --git a/tests/narrow.rs b/tests/narrow.rs index f832f87..ebbd13f 100644 --- a/tests/narrow.rs +++ b/tests/narrow.rs @@ -2410,23 +2410,21 @@ fn n2_the_speculation_line_fits_eighty_columns() { /// A question with everything on it: a context line, five offers, and words long /// enough to wrap at eighty columns. fn crowded_question() -> io_harness::Question { - io_harness::Question { - question: "which of the columns in this table should the migration drop, given \ - that it has forty rows and exactly one caller anywhere in the workspace?" - .to_string(), - context: Some( - "the table is referenced once, from the importer, and nothing reads it after \ - the backfill finishes" - .to_string(), - ), - choices: vec![ - "created_at".to_string(), - "updated_at".to_string(), - "deleted_at, which the soft-delete path still writes on every request".to_string(), - "archived_at".to_string(), - "expired_at".to_string(), - ], - } + io_harness::Question::new( + "which of the columns in this table should the migration drop, given \ + that it has forty rows and exactly one caller anywhere in the workspace?", + ) + .with_context( + "the table is referenced once, from the importer, and nothing reads it after \ + the backfill finishes", + ) + .with_choices([ + "created_at", + "updated_at", + "deleted_at, which the soft-delete path still writes on every request", + "archived_at", + "expired_at", + ]) } /// **N6 — the question overlay has a defined form at 80x24 and at the floor.** diff --git a/tests/picker.rs b/tests/picker.rs index 1e33768..87efe79 100644 --- a/tests/picker.rs +++ b/tests/picker.rs @@ -7,6 +7,7 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use io_cli::app::App; use io_cli::picker::{Outcome, Picker, Row}; use io_cli::theme::DARK; +use ratatui::text::Line; fn key(code: KeyCode) -> KeyEvent { KeyEvent::new(code, KeyModifiers::NONE) @@ -701,3 +702,482 @@ fn o11_tab_declines_on_a_heading_like_enter_does() { // pair agree rather than manufacturing an unreachable state. assert_eq!(picker.key(key(KeyCode::Tab)), Outcome::Chosen(1)); } + +// --------------------------------------------------------------------------- +// T04 — a marked set the spacebar toggles, and an unfold whose height is +// measured per row +// --------------------------------------------------------------------------- + +fn space() -> KeyEvent { + key(KeyCode::Char(' ')) +} + +/// **T04 — the spacebar marks and unmarks, and the screen says which rows.** +/// +/// The whole of the plural mechanic in one place: a toggle, a set read back in +/// the caller's own numbering, and a box the operator can see it in. A mark that +/// is only in the struct is a selection nobody can check before they press Enter. +#[test] +fn t04_the_spacebar_marks_and_unmarks_on_a_plural_picker() { + let mut picker = providers().accepting_several(); + assert!( + picker.marked().is_empty(), + "a picker opens with nothing marked", + ); + // With nothing marked, the plural answer is the singular one: the row under + // the marker, and not an empty list. + assert_eq!(picker.chosen(), vec![0]); + + assert_eq!( + picker.key(space()), + Outcome::Idle, + "marking chooses nothing" + ); + assert_eq!( + picker.marked(), + vec![0], + "the spacebar did not mark the row" + ); + assert_eq!( + picker.query(), + "", + "the space was typed into the query instead of marking", + ); + + // And unmarks: the same key, the same row. + picker.key(space()); + assert!( + picker.marked().is_empty(), + "the second press did not unmark it: {:?}", + picker.marked(), + ); + + // Two rows, reported in the caller's row order rather than in the order they + // were pressed — row 2 is marked before row 1 here on purpose. + picker.key(key(KeyCode::Down)); + picker.key(key(KeyCode::Down)); + picker.key(space()); + picker.key(key(KeyCode::Up)); + picker.key(space()); + assert_eq!(picker.marked(), vec![1, 2]); + assert_eq!( + picker.chosen(), + vec![1, 2], + "with rows marked, the plural answer is the marks and not the marker", + ); + // Enter still answers with one index, in the spelling every existing caller + // reads. The plural answer is a second question, asked of the same picker. + assert_eq!(picker.key(key(KeyCode::Enter)), Outcome::Chosen(1)); + + // On the screen, which is the only place the operator can check it. + let (mut screen, recorder) = support::screen_of(80, 24, 8); + screen + .draw(|frame| picker.render(frame, frame.area(), &DARK)) + .expect("frame"); + let viewport = screen.viewport_text(); + let checked: Vec<&str> = viewport + .lines() + .filter(|line| line.contains("[x]")) + .collect(); + assert_eq!( + checked.len(), + 2, + "two rows are marked and the screen shows a different number: {viewport:?}", + ); + // `OpenAI` is also a substring of `Any OpenAI-compatible endpoint`, so the + // second half says which of the two this is: a `contains` an unrelated row + // satisfies is the vacuity this product keeps finding in its own gates. + assert!( + checked[0].contains("Anthropic"), + "the first ticked row is not the first marked one: {checked:?}", + ); + assert!( + checked[1].contains("OpenAI") && !checked[1].contains("compatible"), + "the tick is on `Any OpenAI-compatible endpoint` rather than on `OpenAI`: \ + {checked:?}", + ); + let empty: Vec<&str> = viewport + .lines() + .filter(|line| line.contains("[ ]")) + .collect(); + assert_eq!( + empty.len(), + 2, + "every markable row carries a box, ticked or not: {viewport:?}", + ); + assert!( + recorder.contains("[x]"), + "the box never reached the terminal" + ); +} + +/// **T04 — a single-answer picker is untouched: the spacebar is a query +/// character, as it has always been.** +/// +/// The constraint the whole opt-in exists for. Nine call sites never mark +/// anything, and none of them can be edited to cope — so a space on any of them +/// has to do exactly what it did before this release: narrow the list. +#[test] +fn t04_the_spacebar_is_still_a_query_character_on_an_ordinary_picker() { + let mut picker = providers(); + assert_eq!(picker.key(space()), Outcome::Idle); + assert_eq!( + picker.query(), + " ", + "the space was swallowed by a marked set no ordinary picker has", + ); + assert!( + picker.marked().is_empty(), + "an ordinary picker marked a row: {:?}", + picker.marked(), + ); + // A space is a real filter character on these labels, which is why it cannot + // be spent: `Any OpenAI-compatible endpoint` has one and `OpenRouter` does + // not. + assert_eq!( + picker.matching(), + 1, + "the space did not filter, so it was not treated as a query character", + ); + assert_eq!( + picker.rows()[picker.selected()].label, + "Any OpenAI-compatible endpoint" + ); + + // And the box is not drawn on it either: the column belongs to the plural + // picker alone. + let mut picker = providers(); + let (mut screen, _recorder) = support::screen_of(80, 24, 8); + screen + .draw(|frame| picker.render(frame, frame.area(), &DARK)) + .expect("frame"); + assert!( + !screen.viewport_text().contains("[ ]"), + "a single-answer picker grew a checkbox column: {:?}", + screen.viewport_text(), + ); +} + +/// **T04 — a mark made in a filtered list is the caller's own row index.** +/// +/// The expensive defect, one state along. Three call sites index a slice raw with +/// what this widget hands back; a plural answer carrying filtered positions +/// panics at those and acts on the wrong rows at the others — once per marked +/// row, silently. +#[test] +fn t04_a_mark_is_an_index_into_the_callers_rows_and_not_the_filtered_view() { + let mut picker = providers().accepting_several(); + // `h` admits `Anthropic` alone — row 1 of the caller's list, and position 0 + // of the filtered one, which is exactly what a filtered index would report. + picker.key(key(KeyCode::Char('h'))); + assert_eq!(picker.matching(), 1); + + picker.key(space()); + assert_eq!( + picker.marked(), + vec![1], + "the marked row is a position in the filtered view, not a row", + ); + assert_eq!(picker.rows()[picker.marked()[0]].label, "Anthropic"); +} + +/// **T04 — a marked row the query hides is still marked.** +/// +/// The release's recorded open question, answered: marks are held against +/// unfiltered rows and survive the query. The alternative makes the filter +/// destructive — an operator marking five rows out of four hundred narrows the +/// list to find each one, and every narrowing would throw away the marks made +/// under the last. +#[test] +fn t04_a_marked_row_survives_the_query_that_hides_it() { + let mut picker = providers().accepting_several(); + picker.key(key(KeyCode::Down)); + picker.key(space()); + picker.key(key(KeyCode::Down)); + picker.key(key(KeyCode::Down)); + picker.key(space()); + assert_eq!(picker.marked(), vec![1, 3]); + + // `h` admits `Anthropic` alone, so row 3 is not on the screen at all. + picker.key(key(KeyCode::Char('h'))); + assert_eq!(picker.matching(), 1); + assert_eq!( + picker.marked(), + vec![1, 3], + "a keystroke that hid a marked row un-marked it", + ); + + // And a query that admits nothing at all, which is the state that has no + // match set to read a mark out of. + picker.key(key(KeyCode::Char('z'))); + assert_eq!(picker.matching(), 0); + assert_eq!( + picker.marked(), + vec![1, 3], + "an empty result forgot the marks" + ); + + // Widening brings the rows back, still marked, still drawn as marked. + picker.key(key(KeyCode::Backspace)); + picker.key(key(KeyCode::Backspace)); + assert_eq!(picker.query(), ""); + assert_eq!(picker.matching(), 4); + assert_eq!(picker.marked(), vec![1, 3]); + + let (mut screen, _recorder) = support::screen_of(80, 24, 8); + screen + .draw(|frame| picker.render(frame, frame.area(), &DARK)) + .expect("frame"); + let viewport = screen.viewport_text(); + let checked: Vec<&str> = viewport + .lines() + .filter(|line| line.contains("[x]")) + .collect(); + assert_eq!(checked.len(), 2, "{viewport:?}"); + assert!( + checked[1].contains("Any OpenAI-compatible endpoint"), + "the row that was hidden came back unticked: {checked:?}", + ); +} + +/// **T04 — a heading cannot be marked, for the reason it cannot be chosen.** +/// +/// No path that *moves* the marker leaves it on a heading, so this puts it there +/// through [`Picker::focus`], which can — the same belt-and-braces `Enter` and +/// `Tab` keep, reached by the one door that actually opens. +#[test] +fn t04_a_heading_cannot_be_marked() { + let mut picker = Picker::new( + "Which command?", + vec![ + Row::heading("a group"), + Row::new("first"), + Row::new("second"), + ], + ) + .accepting_several(); + + assert!( + picker.focus(0), + "the heading is a row the marker can be put on" + ); + assert_eq!(picker.selection(), Some(0), "the marker is on the heading"); + picker.key(space()); + assert!( + picker.marked().is_empty(), + "a heading was marked: {:?}", + picker.marked(), + ); + // The control: the same key on the row below it does mark, so the assertion + // above is about headings rather than about a spacebar that never works. + assert!(picker.focus(1)); + picker.key(space()); + assert_eq!(picker.marked(), vec![1]); +} + +/// **T04 — the row demand does not move as the marker moves or the query +/// filters.** +/// +/// The driver re-places the viewport whenever the demand changes, and a +/// re-placement is a terminal tear-down and a cursor query — on a surface that is +/// open while a turn is in flight. So a demand that followed the *focused* row's +/// unfold height would tear the terminal down on every arrow key between a short +/// preview and a tall one. `rows_wanted` reserves the largest configured unfold +/// instead, which is a function of the configuration and of nothing the operator +/// is doing. +/// +/// **Asserted by comparing demands, never by a clock.** N1 forbids a sleeping or +/// clock-reading test anywhere under `tests/` — and it sweeps for the spellings +/// as plain strings, in comments too, so they are not written out here either. A +/// timing would be measuring the wrong thing regardless: the property is that a +/// number does not change, and a number is what is compared. +/// +/// The list's own length is subtracted, because that half of the demand is +/// *supposed* to follow the query — a filter that admits two rows asks for two +/// rows. What must be constant is everything else: the head, and the reservation. +#[test] +fn t04_the_demand_does_not_move_with_the_marker_or_the_query() { + let mut picker = Picker::new( + "Which one?", + vec![ + Row::new("alpha"), + Row::new("beta"), + Row::new("gamma"), + Row::new("delta"), + Row::new("epsilon"), + ], + ); + // Two unfolds of very different heights, which is the whole fixture: with one + // height per picker there was nothing here to oscillate. + picker.set_unfold(0, 1); + picker.set_unfold(3, 7); + + // The demand minus the rows the query admits: the head plus the reservation. + let overhead = |picker: &Picker| { + picker + .rows_wanted() + .saturating_sub(u16::try_from(picker.matching()).expect("a small list")) + }; + + let opening = overhead(&picker); + assert_eq!( + opening, 8, + "one row for the head and seven for the tallest unfold, whatever is focused", + ); + + let mut seen = vec![opening]; + let mut markers = vec![picker.selection()]; + let mut lists = vec![picker.matching()]; + // Down onto `delta`, which is the seven-row unfold, and past it; then a query + // that hides *both* unfolding rows; then back out again. + for stroke in [ + key(KeyCode::Down), + key(KeyCode::Down), + key(KeyCode::Down), + key(KeyCode::Down), + key(KeyCode::Up), + key(KeyCode::Char('m')), + key(KeyCode::Char('a')), + key(KeyCode::Backspace), + key(KeyCode::Backspace), + key(KeyCode::Home), + ] { + picker.key(stroke); + seen.push(overhead(&picker)); + markers.push(picker.selection()); + lists.push(picker.matching()); + } + + // The fixture has to actually exercise both halves, or the constant below is + // a constant about nothing. + assert!( + markers.contains(&Some(0)) && markers.contains(&Some(3)), + "the marker never visited both unfolding rows: {markers:?}", + ); + assert!( + lists.iter().any(|count| *count != 5), + "the query never filtered anything: {lists:?}", + ); + assert!( + markers.contains(&Some(2)), + "the marker never left the unfolding rows: {markers:?}", + ); + + assert!( + seen.iter().all(|demand| *demand == opening), + "the reservation moved as the operator did, which re-places the viewport \ + on a keystroke: {seen:?}", + ); + + // And what is *drawn* does follow the marker, which is the other half of the + // pair: the reservation is the largest, the open block is the focused row's. + assert!(picker.focus(0)); + assert!(picker.unfolded_now(), "row 0 opens a block of its own"); + assert!(picker.focus(2)); + assert!( + !picker.unfolded_now(), + "a row with no unfold configured opened one", + ); +} + +/// **T04 — an unfold reserves the rows ratatui will paint, not the lines the +/// caller counted.** +/// +/// `crate::rows::wrapped` is the one measurement in this crate, and this is the +/// fixture that tells it apart from the `lines.len()` it replaced: two logical +/// lines that wrap to more than two rows at the width they are drawn at. A gate +/// written against the line count fails here, which is the point — that count has +/// already cost this product two defects, both of them content painted over rows +/// something else had been promised. +#[test] +fn t04_a_wrapped_unfold_reserves_the_measured_height() { + const WIDTH: u16 = 32; + let preview = vec![ + Line::from("the first line of this preview is long enough to wrap several times over"), + Line::from("and the second one wraps as well"), + ]; + let height = io_cli::rows::wrapped(&preview, WIDTH); + assert!( + height > u16::try_from(preview.len()).expect("two lines"), + "the fixture does not wrap at {WIDTH} columns, so it cannot tell a \ + measured height from a counted one: {height} rows for {} lines", + preview.len(), + ); + + let mut picker = Picker::new("Which one?", vec![Row::new("aaa"), Row::new("bbb")]); + picker.set_unfold(0, height); + assert_eq!( + picker.rows_wanted(), + 2 + height + 1, + "the demand does not carry the measured height", + ); + + let (mut screen, _recorder) = support::screen_of(WIDTH, 24, 16); + // The area's own origin, because an inline viewport does not start at row + // zero — the rectangle `opened` reports is absolute and the viewport text is + // indexed from the top of the area. + let top = std::cell::Cell::new(u16::MAX); + screen + .draw(|frame| { + top.set(frame.area().y); + picker.render(frame, frame.area(), &DARK); + }) + .expect("frame"); + + let opened = picker.opened().expect("the block was reserved"); + assert_eq!( + opened.height, height, + "the reserved block is not the height the text was measured at", + ); + assert_eq!(opened.width, WIDTH); + + // The rows are really reserved, not merely promised in a rectangle: the row + // below the unfolding one is drawn `height` rows further down than it would + // otherwise be. + let viewport = screen.viewport_text(); + let lines: Vec<&str> = viewport.lines().collect(); + let first = lines + .iter() + .position(|line| line.contains("aaa")) + .expect("the unfolding row"); + let second = lines + .iter() + .position(|line| line.contains("bbb")) + .expect("the row below it"); + assert_eq!( + second - first, + usize::from(height) + 1, + "the block took {} rows and the text needs {height}: {viewport:?}", + second - first - 1, + ); + assert_eq!( + opened.y, + top.get() + u16::try_from(first).expect("a small viewport") + 1, + "the block does not open directly beneath the row that owns it", + ); + for row in &lines[first + 1..second] { + assert!( + row.is_empty(), + "the reserved rows are the caller's to draw into and must be left \ + blank: {row:?}", + ); + } + + // A second row with a height of its own, which is what one height per picker + // could not express: each keeps its own, and the taller is what is reserved. + picker.set_unfold(1, 2); + assert_eq!( + picker.rows_wanted(), + 2 + height + 1, + "the shorter unfold replaced the taller one instead of joining it", + ); + picker.key(key(KeyCode::Down)); + screen + .draw(|frame| picker.render(frame, frame.area(), &DARK)) + .expect("frame"); + assert_eq!( + picker.opened().expect("the second block").height, + 2, + "the second row opened the first row's height", + ); +} diff --git a/tests/plugins.rs b/tests/plugins.rs index a7527fd..078cbfe 100644 --- a/tests/plugins.rs +++ b/tests/plugins.rs @@ -1856,3 +1856,105 @@ fn n3_two_writes_in_one_second_of_the_same_length_are_the_documented_limit() { prove", ); } + +// --------------------------------------------------------------------------- +// `ids` and `by_id` — the handle a bundle's name gives on its entry +// --------------------------------------------------------------------------- +// +// `io plugin add ` installs by name and then tells the operator that +// removing it takes the same name. `manage::plan` answers that word by asking the +// disk about a directory first and matching declared ids only after — over the +// pairs `ids` builds here, because resolving them inside `plan` would mean +// `Config::plugins()`, a full re-parse of every declared manifest that +// `tests/dependencies.rs` confines to `src/resolved.rs` by exact path. + +/// **Every declared bundle is in the pairs** — loaded, switched off, and refused. +/// +/// The refused one is the point: it is the entry an operator most wants gone and +/// the one they cannot fix from a manifest that does not parse, and its id is the +/// directory's own name where io-harness could read no manifest at all. +/// +/// Sabotage: build the list from `view.plugins` alone. Under it only this test and +/// its sibling in `tests/manage.rs` fail, and what ships is a broken entry +/// `/plugin` lists under a name that removes nothing. +#[test] +fn ids_carries_every_declared_bundle_including_the_ones_that_did_not_load() { + let (_dir, root) = root(); + bundle(&root, "bundles/rust-review", MINIMAL); + bundle( + &root, + "bundles/off", + &MINIMAL.replace("rust-review", "switched-off"), + ); + empty_bundle(&root, "bundles/ghost"); + std::fs::write( + root.join(LOCAL_FILE), + "[[plugin]]\npath = \"bundles/rust-review\"\n\n\ + [[plugin]]\npath = \"bundles/off\"\nenabled = false\n\n\ + [[plugin]]\npath = \"bundles/ghost\"\n", + ) + .expect("the configuration"); + + let config = Config::discover(&root).expect("the configuration"); + let pairs = pluginview::ids(&pluginview::view(&config.plugins())); + + for (id, at) in [ + ("rust-review", "bundles/rust-review"), + ("switched-off", "bundles/off"), + ("ghost", "bundles/ghost"), + ] { + assert!( + pairs + .iter() + .any(|(name, dir)| name == id && dir.ends_with(at)), + "`{id}` at `{at}` is not among the declared pairs, so `plugin remove \ + {id}` has nothing to match: {pairs:?}", + ); + } + // One pair per entry and no entry counted twice. Filtered to this root: the + // machine running the suite has a user-scope file of its own, which + // `Config::discover` reads too and which is nothing to assert about. + let ours = pairs + .iter() + .filter(|(_, dir)| dir.starts_with(&root)) + .count(); + assert_eq!(ours, 3, "one pair per declared entry: {pairs:?}"); +} + +/// **Every bundle of that name comes back, and never just the first.** +/// +/// An id is unique among the bundles io-harness *loaded*; two declared +/// `enabled = false` share one whenever an operator is swapping `tools-v1` for +/// `tools-v2`. A helper that answered with one of them would decide, here, which +/// `[[plugin]]` entry gets deleted — silently, and the caller could not tell that a +/// choice had been made at all. +/// +/// Pure, over a slice written out in the test: the point is the matching, and a +/// fixture on disk would be asserting `Config::discover` again. +/// +/// Sabotage: `.find()` in place of `.filter()`. Only this test fails. +#[test] +fn by_id_answers_with_every_bundle_of_that_name() { + let declared = vec![ + ("twin".to_string(), PathBuf::from("/bundles/one")), + ("other".to_string(), PathBuf::from("/bundles/two")), + ("twin".to_string(), PathBuf::from("/bundles/three")), + ]; + + assert_eq!( + pluginview::by_id(&declared, "twin"), + vec![Path::new("/bundles/one"), Path::new("/bundles/three")], + "both bundles of that name, in the order they were declared", + ); + assert_eq!( + pluginview::by_id(&declared, "other"), + vec![Path::new("/bundles/two")], + "the unique name answers with the one bundle, so the assertion above is a \ + match rather than a constant", + ); + assert!( + pluginview::by_id(&declared, "twi").is_empty(), + "a name is matched whole: a prefix of one is not a bundle", + ); + assert!(pluginview::by_id(&[], "twin").is_empty()); +} diff --git a/tests/resume.rs b/tests/resume.rs index 74dd532..baf4105 100644 --- a/tests/resume.rs +++ b/tests/resume.rs @@ -61,6 +61,17 @@ //! re-enters the loop, and the rest of the budget goes on the approach that was //! just refused. //! +//! * **The batched-ask block** is 0.33.0's F11, and it is written against +//! `Store::put_questions` — io-harness's own batch writer — rather than against +//! a hand-built row, because a fixture is exactly what would hide the defect. +//! The batch's `choices` column is *empty*: the store writes the offers only +//! into the new `questions` column, and renders the ask itself into the +//! `question` column as numbered prose. So a fixture that set `choices` would +//! pass while the real row a run parks has nothing to pick. Sabotage the block +//! by dropping `questions` in `resume::pending_for`: every offer disappears, the +//! multi-select flag with them, and the surfaces go back to drawing a wall of +//! numbered text. +//! //! * **The two wrong-owner blocks** catch a driver that trusts the ids it is //! handed. Sabotage either by dropping the ownership check: io-harness refuses //! it too, but as one `Error::Resume` whose `Display` is written for a library @@ -72,8 +83,9 @@ mod support; use io_cli::resume::{self, Failure, Pending}; use io_harness::provider::{CompletionRequest, CompletionResponse, ToolCall}; use io_harness::{ - ApproveAll, Ignore, Plan, PlanStep, PlanVerdict, Policy, Provider, RecoveryDecision, - RunOutcome, Session, StepRecord, Store, TaskContract, ToolRecovery, ASK_QUESTION_TOOL, + ApproveAll, Choice, Ignore, Plan, PlanStep, PlanVerdict, Policy, Provider, Question, + RecoveryDecision, RunOutcome, Session, StepRecord, Store, TaskContract, ToolRecovery, + ASK_QUESTION_TOOL, }; use support::Scripted; @@ -232,7 +244,14 @@ async fn a_paused_question_is_reported_with_its_own_row_and_nothing_is_driven() question_id: fixture.question_id, question: QUESTION.to_string(), context: None, - choices: vec![CHOICE_A.to_string(), CHOICE_B.to_string()], + choices: vec![Choice::new(CHOICE_A), Choice::new(CHOICE_B)], + // An ordinary `ask_question` is not a batch, and the whole equality + // below is what holds 0.72.0's batch field to that. A reader that + // synthesised a one-element `questions` for every question — the + // tempting way to give one surface one code path — fails here, and + // would have `sessions::note` and `exec::listed` describing every + // single ask as a batch of one. + questions: vec![], step: before, }, "the classification carries the row's own id and the agent's own words", @@ -861,3 +880,327 @@ fn entering_a_session_releases_the_lock_on_the_one_being_left() { "the root written into the owner record must be read for the session being entered", ); } + +// --------------------------------------------------------------------------- +// F11 — a batched ask reads back as the batch it is +// --------------------------------------------------------------------------- + +/// A run parked on a batch, **written by io-harness's own batch writer**. +/// +/// `Store::put_questions` and not a hand-built row, and the difference is the +/// whole of what these blocks test. That writer renders the ask into the +/// `question` column as numbered prose and leaves the `choices` column *empty* — +/// the offers go only into the new `questions` column — so a fixture that set +/// `choices` itself would be green against a reader that never learned to look at +/// `questions`, which is exactly the reader this release replaced. +fn parked_batch(questions: &[Question]) -> (tempfile::TempDir, Store, i64, i64) { + let dir = tempfile::tempdir().expect("a temp dir"); + let store = Store::open(dir.path().join("runs.db")).expect("a store on disk"); + let run_id = store + .start_run("port the parser", &dir.path().display().to_string()) + .expect("a run row"); + store + .record(run_id, &StepRecord::new(1, "asked the operator", "ok")) + .expect("a trace row"); + let question_id = store + .put_questions(run_id, 1, questions) + .expect("a batch is parked as one row"); + (dir, store, run_id, question_id) +} + +/// The batch a classification is read out of, or a panic naming what came instead. +fn read_back(store: &Store, run_id: i64) -> (i64, String, Vec, Vec) { + match resume::pending_for(store, run_id).expect("the store answers") { + Pending::Question { + question_id, + question, + choices, + questions, + .. + } => (question_id, question, choices, questions), + other => panic!("a run parked on a batch is parked on a question, and got {other:?}"), + } +} + +#[test] +fn a_parked_batch_carries_every_question_with_its_own_offers_in_order() { + let batch = [ + Question::new("Which database?") + .with_context("Both are configured.") + .with_choices([Choice::new("postgres").describe("the one CI runs")]), + Question::new("Which platforms?").with_choices(["linux", "windows"]), + ]; + let (_dir, store, run_id, question_id) = parked_batch(&batch); + let (id, question, choices, questions) = read_back(&store, run_id); + + // One row and one id. A batch is not a second handle, and a classification + // that grew one would be handing the resume driver an id no + // `resume_*_with_answer` takes. + assert_eq!(id, question_id); + + // **The store's shape, asserted rather than assumed.** This is the line that + // makes the fixture have to be the harness's own writer: the batch row has no + // offers of its own, so every offer below can only have come through + // `questions`. + assert!( + choices.is_empty(), + "a batch row's `choices` column is empty, and this fixture proves it: {choices:?}", + ); + + assert_eq!( + questions + .iter() + .map(|q| q.question.as_str()) + .collect::>(), + ["Which database?", "Which platforms?"], + "both questions, in the order the agent asked them", + ); + assert_eq!( + questions[0].choices, + vec![Choice::new("postgres").describe("the one CI runs")], + "the first question keeps its own offer and what was said about it", + ); + assert_eq!( + questions[1] + .choices + .iter() + .map(|c| c.label.as_str()) + .collect::>(), + ["linux", "windows"], + "and the second keeps its own, which are not the first's", + ); + assert_eq!( + questions[0].context.as_deref(), + Some("Both are configured."), + "per-question context survives too", + ); + assert_eq!(questions, batch, "and nothing else about them changed"); + + // **Why the assertions above are not satisfied by the prose.** The `question` + // column holds both texts already, as `1. …\n2. …`, so a `contains` over it + // would pass for a reader that carried no questions at all. Both facts are + // asserted here so the distinction cannot quietly stop being true: the + // rendering has the numbers, and the questions do not. + assert!( + question.contains("1. Which database?") && question.contains("2. Which platforms?"), + "the store renders the whole ask into the question column: {question}", + ); + assert!( + !questions[0].question.starts_with("1. ") && !questions[1].question.starts_with("2. "), + "and the carried questions are the agent's own words rather than a re-parse \ + of that rendering", + ); +} + +/// **The multi-select flag, and the one place it is readable.** +/// +/// `PendingQuestion` has no `multiple` column: the flag lives inside each +/// `Question` of the batch, so it survives a park only for a batched ask. +/// `Store::put_question` — the singular writer the run loop calls for +/// `ask_question` — writes no `questions` value at all, so a singular +/// multi-select's flag is lost by io-harness before io-cli ever sees the row. +/// That is stated in `resume::Pending` and is not worked around here. +#[test] +fn a_batched_question_asked_as_a_multi_select_still_says_so_when_it_is_read_back() { + let batch = [ + Question::new("Which platforms?") + .with_choices(["linux", "windows"]) + .multiple(), + Question::new("Which release channel?").with_choices(["stable", "nightly"]), + ]; + let (_dir, store, run_id, _) = parked_batch(&batch); + let (_, _, _, questions) = read_back(&store, run_id); + + assert!( + questions[0].multiple, + "a question asked as pick-several is still pick-several after a park", + ); + // The half that makes the line above mean something. A reader that defaulted + // every question to `multiple` — or one that read the flag off the batch + // rather than off each question — passes the assertion above and fails here, + // and would offer a pick-several form for an answer that must be one thing. + assert!( + !questions[1].multiple, + "and a pick-one is still pick-one; the flag is per question, not per batch", + ); +} + +/// **The back-compatibility io-harness promises, proved rather than assumed.** +/// +/// A row written by 0.71.0 and earlier holds `choices` as a JSON array of plain +/// strings and has no `questions` or `answers` columns at all. `Store::put_question` +/// with undecorated offers writes exactly those bytes today — `Choice`'s +/// hand-written `Serialize` emits a bare string when there is nothing extra to say, +/// which is the guarantee that keeps a 0.72.0 store readable by a 0.71.0 binary — +/// so this fixture is the old shape rather than an imitation of it. +/// +/// Sabotage it by adding a second `choices` reader to io-cli: io-harness's +/// deserializer already accepts both spellings, and a reader here that understood +/// only one would drop every offer in every store written before this release. +#[test] +fn a_question_row_in_the_pre_0_72_0_shape_reads_back_with_its_offers() { + let dir = tempfile::tempdir().expect("a temp dir"); + let store = Store::open(dir.path().join("runs.db")).expect("a store on disk"); + let run_id = store + .start_run("port the parser", &dir.path().display().to_string()) + .expect("a run row"); + let old = Question::new(QUESTION).with_choices([CHOICE_A, CHOICE_B]); + + // The column this writes, in the bytes 0.71.0 wrote. Asserted here because the + // rest of the test would otherwise be claiming to exercise a compatibility path + // it had no evidence it was on. + assert_eq!( + serde_json::to_string(&old.choices).expect("choices serialize"), + format!("[\"{CHOICE_A}\",\"{CHOICE_B}\"]"), + "undecorated offers are written in the plain-string spelling, which is what \ + makes this row the old shape", + ); + + let question_id = store + .put_question(run_id, 1, &old) + .expect("a parked question"); + let (id, question, choices, questions) = read_back(&store, run_id); + + assert_eq!(id, question_id); + assert_eq!(question, QUESTION, "the agent's own words, unrendered"); + assert_eq!( + choices, + vec![Choice::new(CHOICE_A), Choice::new(CHOICE_B)], + "the plain strings read back as labelled offers with nothing else claimed \ + about them", + ); + assert!( + choices + .iter() + .all(|c| c.description.is_none() && c.preview.is_none()), + "and nothing was invented to fill the fields the old spelling never had", + ); + assert!( + questions.is_empty(), + "the columns 0.72.0 added are null on this row, and null is not a batch of \ + one: {questions:?}", + ); +} + +/// **What the widened `choices` was widened for**, and the gate that was missing. +/// +/// Every other fixture in this suite builds offers from bare `&str`, so `description` +/// and `preview` are `None` everywhere and `Pending::Question::choices` being +/// `Vec` rather than `Vec` is unproven by all of them. Sabotage this +/// by mapping the row's offers to their labels on the way into the classification: +/// the whole rest of the file stays green while the two fields a surface renders are +/// dropped between the store and the screen. +#[test] +fn an_offers_description_and_preview_reach_the_classification_intact() { + const WHY: &str = "Gitignored, so the change stays on this machine."; + const SHOWN: &str = "[provider]\nkey = \"...\""; + + let dir = tempfile::tempdir().expect("a temp dir"); + let store = Store::open(dir.path().join("runs.db")).expect("a store on disk"); + let run_id = store + .start_run("tidy the configuration", &dir.path().display().to_string()) + .expect("a run row"); + let described = Choice::new(CHOICE_B).describe(WHY).preview(SHOWN); + + // The object spelling, which is the half of `Choice`'s serializer the previous + // test does not reach. Without this the fixture could be on the bare-string + // path and the test would be a second copy of the back-compatibility one. + assert!( + serde_json::to_string(&described) + .expect("a described choice serializes") + .starts_with('{'), + "an offer that carries a description is written as an object, and that is the \ + path this test is about", + ); + + store + .put_question( + run_id, + 1, + &Question::new(QUESTION).with_choices([Choice::new(CHOICE_A), described]), + ) + .expect("a parked question"); + let (_, _, choices, _) = read_back(&store, run_id); + + assert_eq!(choices.len(), 2); + assert_eq!( + choices[1].description.as_deref(), + Some(WHY), + "the sentence saying what taking this offer means survives the store", + ); + assert_eq!( + choices[1].preview.as_deref(), + Some(SHOWN), + "and so does the block showing what it would do", + ); + // The undecorated sibling keeps its emptiness, so the two fields above cannot + // be a reader filling every offer from the same source. + assert!( + choices[0].description.is_none() && choices[0].preview.is_none(), + "an offer with nothing extra to say still says nothing extra", + ); +} + +/// **The session list says something true about a batch**, which the sentence it +/// used to write did not. +/// +/// `sessions::note` quoted the row's `question` column. For a batch that column is +/// the numbered rendering, so the picker's one-line detail was handed several +/// newline-separated questions — cut after `1.` and read as the whole ask, or +/// wrapped in a row that has no second line. +#[test] +fn the_session_list_counts_a_batch_rather_than_quoting_the_rendering_of_it() { + let batch = [ + Question::new("Which database?"), + Question::new("Which platforms?"), + Question::new("Which release channel?"), + ]; + let (_dir, store, run_id, _) = parked_batch(&batch); + let pending = resume::pending_for(&store, run_id).expect("the store answers"); + + // The mark is unchanged on purpose: a batch is one parked row waiting on one + // answer, and the state column is four characters an operator reads at a glance. + assert_eq!( + io_cli::sessions::mark(&pending), + Some(io_cli::sessions::QUESTION_MARK), + ); + + let note = io_cli::sessions::note(&pending).expect("a parked batch has something to say"); + // `"3 questions"` rather than a bare digit, which any id or step number in a + // future version of this sentence would satisfy on its own. + assert!( + note.contains("3 questions"), + "the note must say how many answers the operator owes: {note}", + ); + // What stops the assertion above from being satisfied by the prose that used + // to be pasted here. Any of the question texts appearing, or a newline + // appearing, means the rendering leaked into a single-line detail. + assert!( + !note.contains('\n'), + "a picker detail is one line, and the batch's rendering is not: {note:?}", + ); + for asked in [ + "Which database?", + "Which platforms?", + "Which release channel?", + ] { + assert!( + !note.contains(asked), + "quoting the ask is what this replaced — {asked:?} is in {note:?}", + ); + } + + // And a single question is still quoted, so the change is to the batch case + // alone rather than to every question in every list. + let (_single_dir, single, single_run, _) = parked_batch(&[Question::new(QUESTION)]); + let alone = resume::pending_for(&single, single_run).expect("the store answers"); + let one = io_cli::sessions::note(&alone).expect("a parked question has something to say"); + assert!( + one.contains(QUESTION), + "one question is still quoted in the operator's own reading order: {one}", + ); + assert!( + !one.contains("1. "), + "and quoted as the agent asked it rather than as the store rendered it: {one}", + ); +} diff --git a/tests/skillview.rs b/tests/skillview.rs index d382fab..fe1d318 100644 --- a/tests/skillview.rs +++ b/tests/skillview.rs @@ -1006,13 +1006,22 @@ fn f1_installing_a_second_claimant_to_one_name_is_refused() { write(&source, &skill("io-mcp", "a second claimant")); let refusal = skillview::install(&home, &source).expect_err("the name is taken"); + // **Which guard fired, not merely that one did.** Three of `install`'s + // refusals interpolate `{name}`, so `contains("io-mcp")` is satisfied by the + // path-component refusal and by the destination one as well and pins nothing. + // The claimed-name sentence is the only one that says a skill *already + // answers* to it, and the only one that names the directory it answers in. assert!( - refusal.contains("io-mcp"), - "the refusal names the name rather than the file: {refusal}", + refusal.contains("already answers to `io-mcp`"), + "the refusal is the claimed-name one, naming the name: {refusal}", ); assert!( - !skills::dir(&home).join("mine.md").exists(), - "and nothing was copied in", + refusal.contains(&skills::dir(&home).display().to_string()), + "and names the directory the claimant is in: {refusal}", + ); + assert!( + !skills::dir(&home).join("io-mcp.md").exists(), + "and nothing was copied in under the name it resolves to", ); assert!( discovered(&skills::dir(&home)).len() == 1, @@ -1029,17 +1038,21 @@ fn f1_installing_a_second_claimant_to_one_name_is_refused() { /// file and the assertion below would fail. #[test] fn f3_a_bundles_skill_is_not_ours_to_remove() { + // **The id is a word that appears nowhere in the path.** With the bundle + // declared as `pack` inside `bundles/pack`, every refusal that prints the path + // at all satisfies `contains("pack")` — including the ones that know nothing + // about bundles — so the assertion could not fail and pinned nothing. let (dir, _home) = home(); let bundle = dir.path().join("bundles").join("pack"); let theirs = bundle.join("skills").join("packed.md"); write(&theirs, &skill("packed", "a bundle's own")); - let plug = ("pack".to_string(), bundle.clone()); + let plug = ("shipyard".to_string(), bundle.clone()); let refusal = skillview::remove(&theirs, std::slice::from_ref(&plug)) .expect_err("a bundle's file is not io-cli's to delete"); assert!( - refusal.contains("pack"), - "the refusal names the bundle: {refusal}", + refusal.contains("shipyard"), + "the refusal names the bundle by the id it was declared under: {refusal}", ); assert!(theirs.exists(), "and the file is still there"); } @@ -1129,3 +1142,287 @@ fn f2_the_removal_confirmation_declines_at_row_zero() { by label", ); } + +// --------------------------------------------------------------------------- +// 0.33.0 F15, F16 — the folder layout, added under the name it resolves to and +// managed as the folder it is. +// +// The whole `is_bundle` branch was untested before this release and nothing +// anywhere created a `SKILL.md` under io-cli's own skills directory, which is +// why `install` naming its destination from the source's file name shipped: it +// wrote `skills/SKILL.md`, and `remove` and `disable` then refused that file +// forever with a sentence about deleting a folder that was not there. +// --------------------------------------------------------------------------- + +/// **F15.** A `/SKILL.md` source lands as `.md` and comes back out. +/// +/// Sabotage: name the destination from `source.file_name()` again. The install +/// writes `skills/SKILL.md`, this test's `remove` is refused, and the assertion +/// on where it landed fails first. +#[test] +fn f15_a_folder_skill_is_added_under_its_resolved_name_and_removed() { + let (dir, home) = home(); + let source = dir + .path() + .join("elsewhere") + .join("ultraship") + .join("SKILL.md"); + write(&source, &skill("ultraship", "the layout skills ship in")); + + let at = skillview::install(&home, &source).expect("a folder skill is added"); + assert_eq!( + at, + skills::dir(&home).join("ultraship.md"), + "named from the name the run resolves, never from the source's file name", + ); + assert_eq!( + discovered(&skills::dir(&home)), + vec!["ultraship".to_string()], + "and the run resolves the same name off what was written", + ); + + let gone = skillview::remove(&at, &[]).expect("and it is io-cli's to take away"); + assert_eq!(gone, at); + assert!(!at.exists(), "gone from the skills directory"); +} + +/// **F15, no frontmatter.** The name comes from the source's parent directory. +/// +/// io-harness's `default_name` reads `foo/SKILL.md` as `foo`, so io-cli must too: +/// a destination named from the file stem would be `SKILL.md`, and one named from +/// a `describe` that stopped at the stem would be `SKILL.md` under another +/// spelling. Sabotage: drop the `SKILL` case from `describe` and this lands as +/// `SKILL.md`. +#[test] +fn f15_a_folder_skill_with_no_frontmatter_takes_its_directorys_name() { + let (dir, home) = home(); + let source = dir + .path() + .join("elsewhere") + .join("ultraship") + .join("SKILL.md"); + write(&source, "Do the thing.\n"); + + let at = skillview::install(&home, &source).expect("a nameless folder skill is added"); + assert_eq!(at, skills::dir(&home).join("ultraship.md")); + assert_eq!( + discovered(&skills::dir(&home)), + vec!["ultraship".to_string()], + "which is the name the harness resolves for the source too", + ); +} + +/// **F15, the trust boundary.** A resolved name that is not one path component is +/// refused, and nothing is written. +/// +/// The name is somebody else's frontmatter and it is about to be joined onto io's +/// own home: `Path::join` reads `../escape` as an escape and an absolute path as a +/// replacement for everything to its left. `crate::import` refuses the same value +/// for the same reason and the refusal is that function, called rather than +/// restated. Sabotage: drop the call and `/escape.md` appears — one level +/// above the directory this verb is allowed to write in. +#[test] +fn f15_a_resolved_name_that_is_not_one_path_component_is_refused() { + let (dir, home) = home(); + let source = dir.path().join("elsewhere").join("theirs").join("SKILL.md"); + write(&source, &skill("../escape", "somebody else's frontmatter")); + + let refusal = skillview::install(&home, &source).expect_err("a name is not a path"); + assert!( + refusal.contains("single path component"), + "the refusal says what is wrong with the name: {refusal}", + ); + assert!( + !home.join("escape.md").exists(), + "and nothing was written outside the skills directory", + ); + assert!( + !skills::dir(&home).exists() || discovered(&skills::dir(&home)).is_empty(), + "nor inside it", + ); +} + +/// **F16.** A folder skill inside io-cli's own skills directory is removed as a +/// folder — which is every skill `/import` has ever written. +/// +/// Sabotage: unlink the file instead of the directory. The empty directory is left +/// behind, `discovered` still passes because the harness skips a directory with no +/// `SKILL.md` in it, and the assertion on the folder is what goes red. +#[test] +fn f16_a_folder_skill_in_io_clis_own_skills_directory_is_removed() { + let (_dir, home) = home(); + let folder = skills::dir(&home).join("ultraship"); + let path = folder.join("SKILL.md"); + write(&path, &skill("ultraship", "the shape /import writes")); + assert_eq!( + discovered(&skills::dir(&home)), + vec!["ultraship".to_string()], + "the run is offered it, so the surface has to be able to take it away", + ); + + let gone = skillview::remove(&path, &[]).expect("io-cli removes what io-cli laid out"); + assert_eq!(gone, folder, "the folder went, not the file inside it"); + assert!( + !folder.exists(), + "no empty directory left behind, holding nothing discovery can see", + ); + assert!(discovered(&skills::dir(&home)).is_empty()); +} + +/// **F16, the toggle.** A folder skill disables as a folder, is still listed while +/// it is off, and enables back into place. +/// +/// The listing half is the load-bearing one: `disabled_files` reading only the +/// top-level `*.md` files would take a parked folder skill off the very surface +/// that turns it back on — a one-way door dressed up as a toggle. Sabotage: drop +/// the directory arm of that walk and `listed` panics with the skill missing. +#[test] +fn f16_a_folder_skill_in_io_clis_own_skills_directory_is_disabled_and_enabled() { + let (_dir, home) = home(); + let folder = skills::dir(&home).join("ultraship"); + let path = folder.join("SKILL.md"); + write(&path, &skill("ultraship", "the shape /import writes")); + + let parked = skillview::disable(&path, &[]).expect("disable moves the folder"); + assert_eq!(parked, skills::disabled_dir(&home).join("ultraship")); + assert!( + parked.join("SKILL.md").is_file(), + "the file moved with the folder it belongs to", + ); + assert!( + !folder.exists(), + "and nothing was left in the skills directory" + ); + assert!( + discovered(&skills::dir(&home)).is_empty(), + "which is the off-switch: the run's walk cannot see into `disabled/`", + ); + + let view = skillview::view(&home, &skills::dir(&home), &[]); + assert!( + !listed(&view, "ultraship").enabled, + "still listed, and listed as off", + ); + + skillview::enable(&parked.join("SKILL.md"), &[]).expect("and back again"); + assert!(path.is_file(), "the folder is where it started"); + assert_eq!( + discovered(&skills::dir(&home)), + vec!["ultraship".to_string()] + ); +} + +/// **F16, the other sense of the word.** A bundle's folder skill is refused by +/// both levers, and the bundle is left exactly as it was found. +/// +/// `refuse_bundle` and `is_bundle` are two different senses of one word and only +/// one of them changed. This is the case where that matters: a bundle's +/// `/SKILL.md` sits inside a directory whose own layout is +/// `/skills//SKILL.md`, so the *shape* test says folder skill and says +/// it correctly — the only thing standing between somebody else's directory and +/// `remove_dir_all` is the containment test. +/// +/// Sabotage: delete the `refuse_bundle` call from either lever. Under it this test +/// deletes or moves a whole directory of a bundle's, and the snapshot is what +/// catches it. +#[test] +fn f16_a_bundles_folder_skill_is_refused_by_both_levers() { + let (dir, home) = home(); + std::fs::create_dir_all(skills::dir(&home)).expect("the skills directory"); + let declared = dir.path().join("acme").join("skills"); + let theirs = declared.join("helper").join("SKILL.md"); + write(&theirs, &skill("helper", "a bundle's own, in folder form")); + // The id is a word the path does not contain, so a refusal that merely printed + // the path could not satisfy these assertions. + let plug = ("shipyard".to_string(), declared.clone()); + let before = snapshot(&declared); + + let refusal = skillview::remove(&theirs, std::slice::from_ref(&plug)) + .expect_err("a bundle's directory is not io-cli's to delete"); + assert!( + refusal.contains("shipyard"), + "the refusal names the bundle: {refusal}", + ); + let refusal = + skillview::disable(&theirs, std::slice::from_ref(&plug)).expect_err("nor to move"); + assert!( + refusal.contains("shipyard"), + "and so does the other lever's: {refusal}", + ); + + assert_eq!(snapshot(&declared), before, "the refused levers touched it"); + assert!( + !declared.join(skills::DISABLED).exists(), + "io-cli made a disabled/ inside a bundle it does not own", + ); +} + +/// **F16, the migration.** The stray `skills/SKILL.md` that 0.32.0's `skill add` +/// wrote is a loose file, and removing it removes the file. +/// +/// Sabotage: go back to deciding the folder shape from the basename. This file +/// answers `true` to it, `remove` refuses, and the operator can never take it +/// away. +#[test] +fn f16_a_stray_skill_md_that_declares_a_name_is_a_file_and_not_a_folder() { + let (_dir, home) = home(); + let stray = skills::dir(&home).join("SKILL.md"); + write( + &stray, + &skill("ultraship", "what `skill add` used to write"), + ); + + // Disabling it is refused, and for the destination rather than the file: moved + // under its own name it would land as `disabled/SKILL.md`, and a directory + // holding a `SKILL.md` is exactly what `Skills::discover` walks into — the + // off-switch would put the file back in front of the model under the name + // `disabled` and take every other parked skill's hiding place with it. + let refusal = skillview::disable(&stray, &[]).expect_err("a loose SKILL.md cannot be parked"); + assert!( + refusal.contains(skills::DISABLED), + "the refusal is about where it would land: {refusal}", + ); + assert!( + !skills::disabled_dir(&home).exists(), + "and no `disabled/` was made on the way to refusing", + ); + + let gone = skillview::remove(&stray, &[]).expect("a loose file is unlinked"); + assert_eq!(gone, stray, "the file, and not the directory it sits in"); + assert!( + skills::dir(&home).is_dir(), + "and the skills directory is still there", + ); +} + +/// **F16, and the reason the shape test is not only about position.** A stray +/// `skills/SKILL.md` with no frontmatter resolves to the *directory's* name, which +/// makes it indistinguishable from a folder skill by the file alone — so it is +/// refused, and the refusal names the directory rather than one that is not there. +/// +/// This is the assertion that keeps `remove` from ever calling `remove_dir_all` on +/// a skills directory. Sabotage: drop the skills-directory case from `own_folder` +/// and this test deletes every skill in the home. +#[test] +fn f16_a_stray_skill_md_with_no_name_is_refused_rather_than_guessed_at() { + let (_dir, home) = home(); + let stray = skills::dir(&home).join("SKILL.md"); + let beside = skills::dir(&home).join("mine.md"); + write(&stray, "Do the thing.\n"); + write( + &beside, + &skill("mine", "the operator's own, sitting next to it"), + ); + + let refusal = skillview::remove(&stray, &[]).expect_err("io-cli did not lay this out"); + assert!( + refusal.contains(&skills::dir(&home).display().to_string()), + "the refusal names the folder it is talking about: {refusal}", + ); + assert!(stray.exists(), "and deleted nothing"); + assert_eq!( + discovered(&skills::dir(&home)).len(), + 2, + "least of all the skills sitting beside it", + ); +} diff --git a/tests/structure.rs b/tests/structure.rs index 2f03127..812698f 100644 --- a/tests/structure.rs +++ b/tests/structure.rs @@ -5,9 +5,11 @@ //! written so that a later release cannot reintroduce fullscreen or a clear-based //! redraw without turning a named test red. //! -//! O1 — and one assertion that is not about bytes at all: the order of two calls -//! in `src/main.rs`. Nothing under `tests/` links the binary, so a decision made -//! in the driver is one no test can drive; this file reads the driver instead. +//! O1, F14 and F17 — and three assertions that are not about bytes at all: the +//! order of two calls in `src/main.rs`, the writes reachable from a key handler, +//! and the third argument two doors hand `manage::plan`. Nothing under `tests/` +//! links the binary, so a decision made in the driver is one no test can drive; +//! this file reads the driver instead. mod support; @@ -200,3 +202,147 @@ fn f6_the_migration_report_is_recorded_rather_than_said() { put it on the footer's row, where the first keystroke replaces it", ); } + +/// **F14.** Neither arrow key writes a configuration file without a confirmation. +/// +/// **Counted, not `contains`ed.** `configure::write` is called from two doors and +/// nineteen places, so asking whether the driver contains it answers yes forever +/// and answers nothing: the sabotage that matters adds a twentieth, on a +/// keystroke, and every existing site keeps a `contains` green through it. The +/// number is the assertion, and a release adding a write has to come here and say +/// which door it belongs to. +/// +/// **Where the nineteen are, so the number is a claim and not a constant.** One in +/// `import_written`, the offer's own confirmation. Sixteen in `loop_over`: twelve +/// under the picker's single `Outcome::Chosen` arm — Enter on a row, which is what +/// a confirmation *is* in this product — and four under an `Action::` arm of a +/// typed command (`Action::Manage`, two `Action::Profile` verbs and +/// `Action::Config` with a value), where the Enter that submitted the line is the +/// consent. One in `refresh_prices`, the act `prices.as_of`'s descent offers, one +/// keystroke below the row it writes. One in `manage_main`, the headless +/// `io config|mcp|plugin` door, which has no keyboard at all. Nineteen sites, no +/// tenth surface, and none of them on a bare arrow. +/// +/// **The arrow arm is read as a region, not searched for as a word**, because a +/// count alone cannot see a write moved *into* the arm from somewhere else. The +/// slice runs from the `Pick::Config` interception to the general picker arm below +/// it, which is exactly the code a `Left` or `Right` runs before every other +/// surface gets the key. +/// +/// **The comment stripping is load-bearing and proves itself here.** This release +/// deliberately left the word `cycle_setting` in a historical comment in +/// `src/main.rs` — so the `cycle_setting` assertion below passes *only* while the +/// stripper strips. A stripper that stopped stripping fails this test rather than +/// quietly weakening every other assertion in this file. +/// +/// Sabotage, each of which fails here and nowhere else: restore a `cycle_setting` +/// that writes on the keystroke (the count moves to twenty, and the name comes +/// back in code); call `configure::write` inside the arrow arm (the region); +/// route `refresh_prices` onto an arrow (the region, and the count does not move). +#[test] +fn f14_no_arrow_key_writes_a_configuration_file() { + let text = driver_without_comments(); + + assert!( + !text.contains("cycle_setting"), + "`cycle_setting` is named in the driver's code: it wrote a scope file on a bare \ + `Left`/`Right` and 0.33.0 removed it. If this fails with no such function in \ + `src/main.rs`, the comment stripper has stopped stripping and every assertion \ + in this file is weaker than it reads", + ); + + const OPENS: &str = "if let Some((open, Pick::Config(paths))) = picker.as_mut() {"; + const CLOSES: &str = "if let Some((open, kind)) = picker.as_mut() {"; + let from = text + .find(OPENS) + .expect("the idle loop intercepts the arrows over a `/config` row"); + let to = text[from..] + .find(CLOSES) + .map(|at| from + at) + .expect("the general picker arm follows the interception"); + let arm = &text[from..to]; + + // The slice is the arrow arm and not some other `if let`: both codes are + // matched inside it, and a region that lost them is a region pointing + // somewhere else — which would make every assertion below vacuous. + assert!( + arm.contains("KeyCode::Right") && arm.contains("KeyCode::Left"), + "the sliced region does not test either arrow, so it is not the interception", + ); + assert!( + !arm.contains("configure::write"), + "the arrow interception writes a configuration file. An arrow is not a \ + confirmation: it opens the values and `Enter` on one of them decides, which is \ + the shape every other managed surface in this product already has", + ); + assert!( + !arm.contains("refresh_prices("), + "the arrow interception starts the price refresh, which writes `prices.as_of` — \ + a write on a bare keystroke that moves no call count, because the call site \ + already existed one descent below", + ); + + assert_eq!( + text.matches("io_cli::configure::write(").count(), + 19, + "the driver's configuration writes moved. Each one is a confirmed door — see \ + this test's own documentation for the nineteen and what confirms them — so a \ + new one is either a door that needs naming there or a write on a keystroke", + ); +} + +/// **F17.** Both `manage::plan` doors hand it a resolved bundle list. +/// +/// `plan(root, request, declared)` gained its third argument this release so +/// `plugin remove ` can resolve a word against what is declared instead of +/// demanding a path. **The revert is one `&[]` away and turns nothing red**: an +/// empty list is legal, is what every other verb on the headless door correctly +/// passes, and returns the verb to path-only behaviour with the whole suite green. +/// That is the widening shape — the gate does not fail, it stops being a gate. +/// +/// So both spellings are pinned, and the resolve behind them is counted. +/// `pluginview::ids` is called twice in the driver and nowhere else: once in +/// `Action::Manage`, from the session's already-loaded holdings, and once in +/// `manage_main`, inside the `PluginVerb::Remove` arm that is the only headless +/// request needing it. Replacing either call with `Vec::new()` keeps the argument +/// named `declared` and fails the count; replacing either argument with `&[]` +/// fails its spelling. +/// +/// Sabotage: pass `&[]` at either site, or drop either `pluginview::ids` call. +/// Comments cannot satisfy any of it — the text is read with comments stripped. +#[test] +fn f17_both_manage_doors_pass_a_resolved_bundle_list() { + let text = driver_without_comments(); + + assert_eq!( + text.matches("io_cli::manage::plan(").count(), + 2, + "there are two doors onto `manage::plan`, the session's `/mcp|/plugin|/config` \ + and the headless `io mcp|plugin|config`; a third is a third place the argument \ + can be got wrong", + ); + assert!( + text.contains("io_cli::manage::plan(&root, &request, &declared)"), + "the session door no longer hands `plan` its resolved bundles, so \ + `/plugin remove ` is back to refusing everything that is not a path", + ); + assert!( + text.contains("io_cli::manage::plan(root, &request, &declared)"), + "the headless door no longer hands `plan` its resolved bundles, so \ + `io plugin remove ` is back to refusing everything that is not a path", + ); + assert!( + !text.contains("&request, &[]"), + "a door passes `plan` an empty declared list. That compiles, it is what every \ + non-`remove` request on the headless door correctly passes, and it silently \ + returns `plugin remove ` to path-only", + ); + assert_eq!( + text.matches("io_cli::pluginview::ids(").count(), + 2, + "each door resolves the declared bundles it hands in — the session from its \ + loaded holdings, the headless door inside the `PluginVerb::Remove` arm. A \ + `declared` bound to `Vec::new()` keeps every spelling above and takes the \ + resolve away", + ); +} diff --git a/tests/triage.rs b/tests/triage.rs index 2b658e3..a2093f9 100644 --- a/tests/triage.rs +++ b/tests/triage.rs @@ -42,15 +42,14 @@ fn variant(name: &str) -> String { #[test] fn the_table_names_every_kind_the_locked_harness_declares() { let declared = support::harness_event_kinds(); - assert_eq!( - declared.len(), - 51, - "the locked io-harness declares fifty-one event kinds; found {}", - declared.len(), - ); - let triaged: Vec<&str> = TRIAGE.iter().map(|(name, ..)| *name).collect(); + // **The two name sets are asserted before the count, and that order is the + // point (0.33.0).** The count stood first until this release, so a pin that + // grew a kind failed here saying a number had moved and stopped — the list + // naming *which* kind was new never ran. That is the least useful half of + // this test failing in place of the most useful one, and it is exactly what + // happened when io-harness 0.72.0 added `QuestionsAsked`. let untriaged: Vec<&String> = declared .iter() .filter(|name| !triaged.contains(&name.as_str())) @@ -70,6 +69,41 @@ fn the_table_names_every_kind_the_locked_harness_declares() { gone.is_empty(), "these names are no longer io-harness event kinds: {gone:?}", ); + + // And the count last, which now catches only what the two sets above cannot: + // a harness that declared the same names twice, or a parser that stopped + // reading the enum and handed back a shorter list that happens to be a subset. + assert_eq!( + declared.len(), + 52, + "the locked io-harness declares fifty-two event kinds; found {}", + declared.len(), + ); +} + +/// **0.33.0 — the batched ask is a line, and a `Silent` row here would route its +/// only fact to nothing.** +/// +/// `crate::intent::Answerer` implements `Responder::answer` and nothing else, so +/// io-harness's `answer_all` walks a batch and the overlay draws one question at a +/// time — exactly as it does for `question_asked`. "These arrived together" is the +/// whole content of this variant and no surface in this product says it. +/// +/// Sabotage: copy `question_asked`'s `Silent` onto the row. That is the plausible +/// wrong answer, it is the one a reader of the table above would reach for, and it +/// fails here by name rather than as a sentence missing from a transcript. +#[test] +fn a_batched_ask_is_a_line_because_no_overlay_carries_the_batch() { + assert_eq!( + triage::disposition("questions_asked"), + Some(Disposition::Line), + "a batched ask reaches no surface unless the transcript draws it", + ); + let route = triage::route("questions_asked").expect("the questions_asked row"); + assert!( + route.contains("numbering"), + "the route does not say what makes the batch legible as one: {route}", + ); } #[test] @@ -86,7 +120,7 @@ fn the_table_has_no_duplicate_and_every_row_records_a_route() { "{name} has no route recorded, so nobody can check whether its fact reaches anyone", ); } - assert_eq!(seen.len(), 51); + assert_eq!(seen.len(), 52); } /// A `Line` kind with no arm behind it is the old defect wearing the new table's @@ -97,27 +131,68 @@ fn every_line_kind_has_an_arm_in_the_renderer() { let source = std::fs::read_to_string("src/events.rs") .expect("this crate's source is readable") .replace("\r\n", "\n"); + + // **The renderer's match, sliced out before anything is searched — and the + // twelve spaces alone are not enough to find it.** + // + // Two assertions live in this indentation. A bare `contains` was satisfied by + // the variant's name appearing in a doc comment, which is how this test went + // green through the whole of 0.14.0's sabotage pass with the `Dialed` arm + // deleted: the `Sandbox` arm's prose names `EventKind::Dialed` to explain why + // it draws nothing itself, and that mention alone answered the question. The + // indentation closed that one. It did not close the second: `Events::commit` + // opens with a *different* match on `&event.kind`, the one that decides + // `self.thinking`, and its arms sit at the same twelve spaces — so it names + // `EventKind::Reasoning` and `EventKind::Token` whatever the renderer below it + // does, and deleting either arm from the renderer left this test green. The + // comment that used to stand here claimed the indentation was unique to the + // renderer's arms. It never was. + // + // So the renderer is identified by the statement immediately above it, which + // belongs to no other match in the file, and the slice ends at the first + // top-level item after `Events::commit`. What is searched is the renderer and + // nothing else. + const OPENS: &str = "let dash = theme.glyphs.dash;\n match &event.kind {"; + const CLOSES: &str = "\nfn leader("; + let from = source + .find(OPENS) + .expect("`Events::commit` opens its renderer match under `let dash`") + + OPENS.len(); + let to = source[from..] + .find(CLOSES) + .map(|at| from + at) + .expect("`fn leader` follows the impl `Events::commit` belongs to"); + let renderer = &source[from..to]; + + // The slice starts *after* the thinking match, and that is the whole of what + // makes the search honest. `self.thinking = true` is written in that match and + // nowhere else, so finding it here would mean the slice had swallowed it and + // every assertion below had gone back to being satisfiable from two arms it + // does not care about. + assert!( + !renderer.contains("self.thinking = true"), + "the slice reaches back into the match that decides `thinking`, whose arms \ + name `EventKind::Reasoning` and `EventKind::Token` at the same indentation", + ); + assert!( + renderer.contains("\n EventKind::"), + "the slice holds no match arms at all, so it is not the renderer", + ); + for (name, disposition, _) in TRIAGE { if *disposition != Disposition::Line { continue; } - // **At the match arm's own indentation, and not anywhere in the file.** - // A bare `contains` was satisfied by the variant's name appearing in a - // doc comment, which is how this test went green through the whole of - // 0.14.0's sabotage pass with the `Dialed` arm deleted: the `Sandbox` - // arm's prose names `EventKind::Dialed` to explain why it draws nothing - // itself, and that mention alone answered the question. That is the same - // defect this test was written in 0.11.0 to close, wearing the new - // table's clothes exactly as the comment above says — a name with no arm - // behind it. `tests/glyphs.rs` already reads arms this way, by the twelve - // spaces every match arm in that file sits at and no `use`, doc line or - // expression does. + // At the match arm's own indentation, within the renderer's own match: a + // name with no arm behind it is the defect this test was written in 0.11.0 + // to close, and a mention in a comment is not an arm. let arm = format!("EventKind::{}", variant(name)); let declared = format!("\n {arm}"); assert!( - source.contains(&declared), - "{name} is triaged as a line and `{arm}` has no arm in src/events.rs, so it commits \ - nothing at all — a mention in a comment is not an arm", + renderer.contains(&declared), + "{name} is triaged as a line and `{arm}` has no arm in the renderer in \ + src/events.rs, so it commits nothing at all — a mention in a comment is not an \ + arm, and neither is the arm in the match that decides `thinking`", ); } } @@ -357,7 +432,7 @@ fn the_dispositions_are_the_three_the_contract_names() { .count(); assert_eq!( lines + status + silent, - 51, + 52, "every kind is exactly one of the three: {lines} lines, {status} status, {silent} silent", ); // The release's own claim, and the reason it exists: most kinds are not diff --git a/tests/values.rs b/tests/values.rs index 782d5fb..61f9e23 100644 --- a/tests/values.rs +++ b/tests/values.rs @@ -197,15 +197,31 @@ fn f12_a_stated_shape_shows_an_example() { } } -/// The machine-written key says so rather than offering to be typed. +/// The machine-written key says so rather than offering to be typed, and points at +/// where the act actually is. +/// +/// **The second assertion is 0.33.0's.** This sentence used to send an operator to +/// "the last row of `/config`", and since the refresh moved one descent below +/// `prices.as_of` that instruction names a row that is not there any more — +/// directions to a door that has been moved are worse than no directions. #[test] fn f12_the_machine_written_key_says_it_is_not_typed() { let s = scopes("", "", ""); - let said = configure::shape_of("prices.as_of", &s.config()).expect("it says something"); + let config = s.config(); + let said = configure::shape_of("prices.as_of", &config).expect("it says something"); assert!( said.contains("rather than typed"), "prices.as_of must say it is written by machinery: {said}" ); + assert!( + !said.contains("last row"), + "the refresh is no longer a row of the bare `/config` list, and this sends \ + the operator to it: {said}" + ); + assert!( + configure::descent(&config, "prices.as_of").is_some(), + "the sentence describes a descent this key does not offer" + ); } // F13 — a write lands in the file that already decides the key. @@ -260,61 +276,73 @@ fn f13_the_deciding_file_is_the_one_actually_in_force() { ); } -// The cycling keys, and the property that makes them possible. +// The arrow keys, and the two sentinels the surface they open matches on. +// +// `configure::cycled` was asserted here through 0.32.0 — the arrow's old +// next-value-along. 0.33.0 removed the write-on-a-keystroke it fed and left the +// function called by nothing, so it went the way every other tested-but-uncalled +// function in this product has gone: deleted, with its tests. What the arrow does +// now is open a descent, which is `value_rows` in the driver and is gated by +// `tests/structure.rs`. -/// A boolean and a closed enum cycle; a number does not. +/// **The two rows of `prices.as_of`'s descent carry keys no configuration file can +/// also name.** +/// +/// The rows travel as a parallel `Vec` the driver matches on by value, and +/// through 0.32.0 the decline row's entry in it was the bare label `leave it`. +/// TOML accepts `"leave it" = true` as a quoted key and `configure::settings` +/// sweeps every key out of `Config::origins()` onto the bare `/config` list, so +/// such a key would have been handed a row of its own whose Enter reached the +/// do-nothing arm and reported nothing — a setting an operator could see and could +/// not change, with no message saying why. `!` is the character that makes that +/// impossible, and it is why the act beside the decline row was already spelled +/// `!refresh-prices`. /// -/// A number's ladder cannot be shown on the row it is changed on, and a held arrow -/// would write the file once per key repeat. +/// Sabotage: put `store::LEAVE_IT` back in `descent`'s `keys` vector, or drop the +/// `!` from either sentinel. Both fail here, and neither is caught anywhere else — +/// the labels the picker draws do not change under either edit. #[test] -fn n4_only_booleans_and_closed_enums_cycle_in_place() { - assert_eq!( - configure::cycled(&Kind::Flag, Some("false"), true).as_deref(), - Some("true") - ); - assert_eq!( - configure::cycled(&Kind::Flag, Some("true"), true).as_deref(), - Some("false"), - "a closed set wraps rather than stopping" - ); - let effects = Kind::Choice(configure::effects()); - assert_eq!( - configure::cycled(&effects, Some("allow"), true).as_deref(), - Some("ask") - ); - assert_eq!( - configure::cycled(&effects, Some("allow"), false).as_deref(), - Some("deny"), - "backwards from the first option wraps to the last" +fn the_two_descent_sentinels_are_distinct_and_cannot_be_a_configuration_key() { + assert_ne!( + configure::DECLINE, + configure::REFRESH_PRICES, + "two rows answering to one key means the first arm found wins and the other row is dead" ); - for kind in [ - Kind::Number { signed: false }, - Kind::Model, - Kind::File, - Kind::List, - Kind::Text, - Kind::Machine, - ] { - assert_eq!( - configure::cycled(&kind, Some("x"), true), - None, - "{kind:?} must not cycle in place" + for sentinel in [configure::DECLINE, configure::REFRESH_PRICES] { + assert!( + sentinel.starts_with('!'), + "{sentinel:?} is a key a configuration file could also name, and the driver \ + matches these by value" ); } -} + // The label is deliberately *not* a sentinel — it is the word the operator + // reads — which is the whole reason the key beside it has to be one. + assert!( + !io_cli::store::LEAVE_IT.starts_with('!'), + "the decline label is what an operator reads and must stay a plain phrase" + ); -/// An unset boolean turns on with one press of the forward key. -#[test] -fn n4_an_unset_flag_enters_the_list_at_its_far_end() { + let s = scopes("", "", ""); + let (_, rows, keys) = + configure::descent(&s.config(), "prices.as_of").expect("`prices.as_of` has a descent"); + assert_eq!( + keys, + vec![ + configure::DECLINE.to_string(), + configure::REFRESH_PRICES.to_string() + ], + "the keys the driver matches on are the two sentinels, decline first" + ); assert_eq!( - configure::cycled(&Kind::Flag, None, true).as_deref(), - Some("true"), - "forward on an unset flag enters at the last option, which turns it on" + rows.len(), + keys.len(), + "the vectors are parallel and indexed by the same row: a row with no key \ + reaches `paths.get(index)` as `None` and does nothing at all" ); assert_eq!( - configure::cycled(&Kind::Flag, None, false).as_deref(), - Some("false"), - "and backward enters at the first" + rows[0].label, + io_cli::store::LEAVE_IT, + "row 0 still reads as the decline every other confirmation in this product opens on" ); } @@ -323,11 +351,11 @@ fn n4_an_unset_flag_enters_the_list_at_its_far_end() { /// /// The owner's literal request was toggles on the spacebar, and it cannot be /// built: `Picker` consumes every printable as a fuzzy filter, so a two-word query -/// would toggle a setting on its own space. This asserts the filter still owns the +/// would open a descent on its own space. This asserts the filter still owns the /// space, which is what makes the arrows the right binding rather than a /// preference. /// -/// Sabotage: bind the cycle to Space. Under it this fails, and it fails on the +/// Sabotage: bind the descent to Space. Under it this fails, and it fails on the /// query an operator types to find a key among thirty-seven. #[test] fn n4_the_picker_filter_still_owns_the_space() { @@ -350,7 +378,7 @@ fn n4_the_picker_filter_still_owns_the_space() { "the space must reach the query, or a two-word search is impossible" ); - // And the arrows the cycling uses are still free: neither moves the marker + // And the arrows the descent uses are still free: neither moves the marker // nor chooses a row, so intercepting them takes nothing from any surface. let before = picker.selected(); for code in [KeyCode::Left, KeyCode::Right] { diff --git a/tests/viewport.rs b/tests/viewport.rs index b4a171f..ae9ff09 100644 --- a/tests/viewport.rs +++ b/tests/viewport.rs @@ -228,7 +228,7 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use io_cli::app::{App, Command}; use io_cli::term::SCROLLBACK_RESERVE; use io_cli::theme::DARK; -use io_harness::{EventKind, Question, RunEvent}; +use io_harness::{Choice, EventKind, Question, RunEvent}; /// A terminal big enough that nothing here is testing the ceiling. const TERMINAL: u16 = 40; @@ -260,17 +260,15 @@ fn press(app: &mut App, code: KeyCode) -> Command { /// The question that filled the whole viewport through 0.31.0. fn five_choices() -> Question { - Question { - question: "which column should the migration drop?".to_string(), - context: Some("the table has 40 rows and one caller".to_string()), - choices: vec![ - "created_at".to_string(), - "updated_at".to_string(), - "deleted_at".to_string(), - "archived_at".to_string(), - "expired_at".to_string(), - ], - } + Question::new("which column should the migration drop?") + .with_context("the table has 40 rows and one caller") + .with_choices([ + "created_at", + "updated_at", + "deleted_at", + "archived_at", + "expired_at", + ]) } /// **O16 — the question overlay.** @@ -308,6 +306,89 @@ fn o16_the_question_overlay_grows_the_viewport_and_gives_it_back() { ); } +/// The same question, with one of its offers carrying a preview. +/// +/// Deliberately the *same* offers and the same context, so the head and the row +/// list are identical to `five_choices` and the only thing that can move the +/// demand is the block. +fn five_choices_previewing(preview: &str) -> Question { + Question::new("which column should the migration drop?") + .with_context("the table has 40 rows and one caller") + .with_choices([ + Choice::new("created_at"), + Choice::new("updated_at"), + Choice::new("deleted_at").preview(preview), + Choice::new("archived_at"), + Choice::new("expired_at"), + ]) +} + +/// A question overlay open on `question`, on a running turn. +fn asking(question: Question) -> (App, tokio::sync::oneshot::Receiver>) { + let mut app = running(); + let (answer, reply) = tokio::sync::oneshot::channel(); + app.open_intent(io_cli::intent::Asked { question, answer }); + (app, reply) +} + +/// **F6 — a preview grows the viewport to hold it, the growth does not follow the +/// marker, and it still stops at the terminal less the reserve.** +/// +/// The pair differs by nothing but the preview, so the extra rows are the block +/// and cannot be a longer question or an extra offer. +/// +/// Sabotage: reserve the *focused* row's unfold rather than the largest +/// configured one, under which the demand changes on every arrow key — and every +/// change re-places the viewport, which is a terminal tear-down and a cursor query +/// per keystroke while a turn is in flight. Drop the block from the demand +/// altogether and the first assertion fails. +#[test] +fn f6_a_preview_grows_the_viewport_and_stops_at_the_reserve() { + let (bare, _a) = asking(five_choices()); + let (previewed, _b) = asking(five_choices_previewing( + "ALTER TABLE ledger\n DROP COLUMN deleted_at;\n-- 40 rows\n-- one caller", + )); + + let plain = bare.viewport_wanted(80, TERMINAL); + let grown = previewed.viewport_wanted(80, TERMINAL); + assert!( + grown > plain, + "the preview asked for no rows of its own: {grown} against {plain}", + ); + assert!( + grown < TERMINAL - SCROLLBACK_RESERVE, + "the fixture is already at the ceiling, so the assertions below would \ + hold whatever the demand was", + ); + + // **The demand does not move with the marker.** Up walks off the free-text + // row, onto an offer with no preview, then onto the one that has it. + let (mut walking, _c) = asking(five_choices_previewing( + "ALTER TABLE ledger\n DROP COLUMN deleted_at;\n-- 40 rows\n-- one caller", + )); + for step in 0..4 { + press(&mut walking, KeyCode::Up); + assert_eq!( + walking.viewport_wanted(80, TERMINAL), + grown, + "the demand changed on arrow key {step}, which re-places the viewport \ + and queries the cursor once per keystroke", + ); + } + + // And a preview taller than the terminal gets what there is. + let tall = (0..200) + .map(|n| format!("-- line {n}")) + .collect::>() + .join("\n"); + let (huge, _d) = asking(five_choices_previewing(&tall)); + assert_eq!( + huge.viewport_wanted(80, TERMINAL), + TERMINAL - SCROLLBACK_RESERVE, + "growth for a preview did not stop at the reserve", + ); +} + /// **O16 — the queue.** Four queued messages ask for four rows, which is O7's /// "all four are listed" expressed as the demand that makes it possible. #[test]