Skip to content

io-cli 0.32.0 - #82

Merged
aakashpawar1999 merged 23 commits into
mainfrom
develop
Aug 30, 2026
Merged

aakashpawar1999 merged 23 commits into
mainfrom
develop

Conversation

@aakashpawar1999

Copy link
Copy Markdown
Contributor

The release PR for io-cli 0.32.0.

The agent could ask you a question and you could not answer it. The answer composer was drawn only where there was room left over, and a question with a context line and five choices filled all eight rows of the viewport — so what you saw was a question, some inert bullets, and nothing to type into. The same question reached the screen twice, and the second copy was labelled warning:.

That fixed eight-row viewport is one cause behind four defects, so the viewport becomes the size of what it has to show. Everything else follows from it.

Verified before the seal

  • cargo test — 101 binaries, 1,726 passed, 0 failed, 36 ignored
  • cargo test --doc — 1 passed
  • cargo fmt --all -- --check, cargo clippy --all-targets -- -D warnings — clean, CI's exact flags
  • Live provider suite — 36 arms, 35 green in the pass, the 36th green on re-run
  • Argv door driven against the real binary, including a live marketplace install
  • Eleven sabotage arms, every one with a site and killing its own gate
  • CI green on ubuntu, macos and windows, plus msrv

Each gate layer caught something the others could not

  • The sabotage pass found two criteria — including the release's headline fix — implemented, documented and ungated. if true in place of O3's condition failed nothing in 1,718 tests.
  • The adversarial review (two reviewers, split by area) found three HIGH and seven lesser; both reviewers independently found the same two.
  • The live suite caught a regression 1,724 offline tests were green over: growing a picker's viewport re-acquired the cursor round trip 0.13.0 removed. Only a real pty can see it.

Recorded as iterations, with the developer's approval

  • US-IO-CLI-0.32.0-I11 — /config cannot run mid-turn in any form; O12 is ten commands
  • US-IO-CLI-0.32.0-I12 — O16 narrows to the three overlays

The sealed record, its lock pin and validate exit 0 over 96 checks rode the feature branch into develop, before this merge.

`Screen::replace` and `Screen::rewind` shared an identical tail — erase from
a row down, give the terminal back, take a fresh viewport, and fall back to
the session's own height if the new one cannot be placed. Both lived on the
stdout-backed impl, because both built their replacement with
`attach_with`, which enables raw mode and asks a real tty where its cursor
is. Nothing under `tests/` could reach either, so no test in the suite had
ever executed a viewport re-placement — the one operation 0.32.0's growing
viewport is made of, and the one N5 is about.

`Screen::replace_from(row, height, attach)` takes the constructor as an
argument and holds the whole sequence once, on the generic impl.
`Screen::replace` and `Screen::rewind` become its two callers.

`tests/support::replace` supplies a recorder-backed constructor sharing the
same `Recorder`, which is what makes the property observable: N5 is about
the byte stream across a growth, and a fresh recorder per screen would throw
away the only evidence there is.

`tests/viewport.rs` asserts it over a growth, a shrink back to the floor, a
grow-to-grow that never passes through the floor, a terminal resize while
grown, the erase ordering as the literal CUP+ED bytes, and the ceiling on an
80x24. Committed rows survive all six exactly once.

Also adds `SCROLLBACK_RESERVE`: the rows of conversation that stay visible
above a grown viewport. Four, so an 80x24 keeps a twenty-row viewport with
the last exchange still on screen.
Every overlay in this product measured itself wrong, and all of them the
same way. `Intent::render` and `Review::render` push logical lines, take
`lines.len()` as the height they occupy, and hand them to a `Paragraph`
**with wrapping switched on**. A question, a context line or a plan step
longer than the terminal is wide occupies more rows than it is lines, so the
paragraph consumes the whole area while the count still says there is room —
which loses the choices and the footer, and then draws the composer on top
of rows the paragraph has already painted.

The correction is not a more careful count of lines. `rows::wrapped` asks
ratatui itself, through the same `Paragraph::line_count` that
`Screen::commit` has always used to size `insert_before`. That method is the
wrapper rather than a model of it, so the measurement and the render cannot
disagree. The feature it needs was already enabled and already used here; no
dependency moves.

`rows::elide` keeps what fits and ends with a count of the rows dropped —
rows, not items, because a wrapped step that is not drawn is three rows
missing from the screen, and "1 more step" understates a surface the
operator is being asked to approve. The search starts at `room` lines rather
than at the end, since every line takes at least one row, which bounds it by
the height of a viewport instead of the length of the content.

Deliberately not a fourth spelling of an idiom there are already three of.
`approval.rs` carries two and `queue.rs` a third; all three keep their own,
because their tests assert their exact shapes and none of them wraps. What
is new here is the wrapped case none of them handles.
Two lines were why every surface in this release drew into eight rows.
`App::viewport_wanted` returned `VIEWPORT_HEIGHT` outright whenever a turn
was running or an overlay was open, and `paint_picker` re-placed the
viewport only when no picker was up. A question with five choices, a
twelve-step plan, four queued messages and a four-hundred-row picker all
arrive during a turn, so all four were clipped by the same guard.

The re-placement itself is not new: the composer has grown the viewport
since 0.7.0. What is new is letting the surfaces that need rows ask for
them.

`App::viewport_wanted` is now the one owner of the demand and
`term::viewport_for` the one owner of the limit, so the session's own
sizing and the driver's picker path cannot disagree about the ceiling.
That ceiling is the terminal's height less `SCROLLBACK_RESERVE`, which
exists so the exchange a surface is about stays visible, not to ration
rows.

The ordinary session's demand is derived from the layout rather than
guessed, and reproduces the old constant exactly at an idle prompt.

`App::viewport_height` is deleted. It returned the constant, had no
production caller, and with a viewport that moves it was misleading.

`n2_a_queue_of_any_depth_leaves_the_frame_the_same_height_as_an_empty_one`
asserted the opposite and is replaced. It was 0.17.0's guard against
growth walking the scrollback; that reason is now discharged against the
byte stream in tests/viewport.rs, so what is left is the cost it bought,
which was the operator not seeing what they had queued. The first queued
message is still free — the layout releases the blank above the activity
line, which is where 0.17.0's one visible row came from.
The operator saw a question, some inert bullets, and nothing to type into.
`Intent::render` drew the composer only `if area.height > head`, and a
question with a context line and five choices filled all eight rows — so
on a real session the text area was never rendered at all.

The offers and the free-text answer are now rows of the same `Picker`.
`Enter` on an offer sends that string verbatim, from the caller's own
unfiltered rows rather than re-read off the screen. Taking the marker to
the last row unfolds the composer directly beneath it, and `Enter` there
sends what was typed. One marker, one list, both ways to answer.

The unfold is a general `Picker` mechanic — a row may open a block beneath
itself while it holds the marker, and the widget records where it put it,
because only the widget knows where the row landed once the list has
scrolled. It ships with its one caller rather than ahead of one.

Typing is always the answer and the marker follows it. That is the one
place this surface differs from every other picker, and it is deliberate:
filtering is worth something over four hundred models and nothing over
five offers, where the expensive act is answering.

The marker opens on the free-text row, never on the agent's first offer.
An overlay that opened with an offer marked would turn a reflexive Enter —
the key just pressed to submit the prompt that started the turn — into
silent agreement with a suggestion nobody had read.

`Tab` accepts the marked row and `Shift+Tab` steps back, in every picker in
the product. Both spellings of shift-tab are matched, because a picker
reads raw key events and never goes through the binding table. Until now
both fell into the catch-all and did nothing.

A picker also says how many rows it did not draw. Thirty-six commands in
eight rows drew five and said nothing, so a list filtered to nothing looked
like one scrolled to its end.
`triage.rs` marked `question_asked` a committed line while marking
`approval_requested` and `plan_proposed` silent, so the transcript wrote
the question and the overlay redrew it through `Tone::Warning` — whose word
is literally "warning". Two renderers, neither aware of the other, and the
operator asked twice with the second copy mislabelled.

`question_asked` becomes silent, like its two siblings.

The transcript line survives where nothing else will draw it, and that
condition is two facts rather than one. An `Answerer` is attached to every
contract io-cli builds, so "no responder is registered" never happens in
io-harness's sense; what varies is whether this process kept the receiver.
The resume continuation drops it and then drives a real turn, so a resumed
run that asks a new question has no overlay anywhere and the durable line
is the only thing that renders it. A `--plain`-only gate would have made
that question invisible, which is a worse defect than printing it twice.

So `Events` gains `answering`, defaulting to false. A path that forgets to
set it prints the question twice; a path that wrongly claimed an overlay
would not print it at all, and the default is the direction that fails
safely.

Also corrects this file's own doc, which named io-harness 0.69 while the
pin has been 0.71 for six releases.
`Tone::Refused`'s own doc says it means an act the permission boundary
turned down, and it is the one tone whose word an operator has to be able
to trust. It was being spent on a mistyped `/effort` argument, on a run
that simply is not in the store, on three watch errors, and on this crate's
own `/memory` bookkeeping — none of which went near a boundary.

Corrected, each with the prose that argued for it: the plan proposal and
question headers stop being warnings; a declined question and a plan sent
back for revision stop being warnings, because declining is a real answer
and a revision is the surface working; a run absent from the store becomes
a fact and the watch errors beside it become errors; and the profile-switch
rejection becomes a refusal like all seven sibling arms in its own match,
where it was the one drawn as a program failure.

`/profile`'s unknown verb keeps `Tone::Refused`, and the comment now says
why the two differ rather than citing `/effort` as its precedent: a wrong
`/effort` word buys the wrong amount of reasoning, a wrong `/profile` verb
deletes something.

`outcome_help` told an operator that a run awaiting an answer or a plan
could not be answered by this release and to say it in their next prompt.
`Intent::resumed`, `Review::resumed` and the `/resume` continuation have
all existed since 0.23.0, so the product had spent nine releases
documenting the absence of a capability it shipped, in the one place an
operator reads when they are stuck. The arm is split, because `/resume`
answers a parked plan and an approval belongs to a turn that is over.

The two tests that pinned the old wording are re-pointed at the real bytes
and one now also forbids the sentence that was false.
`io_harness::NAMESPACE` is `__`, and for a skill that name reaches the
model: `Skills::catalog` puts it in the system prompt and `read_skill`
resolves it by equality, so the model can only ask for the string it was
shown. Renaming the separator would move the system prompt, the tool
dispatch, every event target, both crates' suites and every consent surface
at once.

So nothing is renamed. `naming::display` translates at the moment a name is
drawn and `naming::wire` translates back a name an operator typed, and
between them `__` never reaches a person and `:` never reaches io-harness.
First occurrence only in both directions, because a bundle id cannot
contain the separator — the first one is always the join.

Applied to every name io-harness qualified, not to skills alone. `/plugin`
lists a bundle's agents, MCP servers and policy layers; `/status` lists its
policy layers. None of those are skills, and all of them were showing the
operator a separator that appears in no documentation they will ever read.

`invoke_skill` was the reason a skill was not a command: it wrote
`use the {name} skill: ` into the composer, and with no leading slash that
submits as an ordinary prompt — so whether the skill ran depended on how
the model read an English request. It now writes `/bundle:skill`, and
`commands::parse` resolves that back to the catalogue name.

The parse needs no skills list. No command contains a colon, so the shape
is unambiguous; the parse recognises the shape and the driver, which
re-walks the inventory every turn boundary, resolves it. A skill whose name
collides with a command resolves to the command, because the static table
is matched first.

`Chosen::Skill` keeps the wire name, so the translation lives at the two
edges and nowhere in between.
A prompt typed mid-turn was queued and then nothing happened to it until
the turn had ended. A correction typed thirty seconds into a ten-minute
turn waited nine and a half minutes and then ran as a turn of its own,
against the work it was meant to change.

Both turn arms have carried a `SteerInbox` since 0.17.0, so this needed no
io-harness entry point — only a default. The queue is now handed to the
running turn at each step boundary.

At the step boundary rather than on the keystroke, and that is not just
where the contract puts it: flushing the moment the operator pressed Enter
would empty the queue into the inbox instantly, and the queue surface —
their only view of what they have typed and not yet had answered — would
never have anything in it. Held until a step lands, the list is visible for
exactly as long as it is genuinely waiting.

`App::deliver_queued` holds the loop, the ordering and the transcript
records. It is in the library because nothing under tests/ links the
driver, and this loop decides whether an operator's words are kept.
`/steer` and the automatic drain are its two callers, so they cannot
diverge; `/steer`'s own sentences are unchanged.

On a refusal the message goes back to the FRONT of the queue. Putting it
back with `queue_prompt` appends, which would have sent everything queued
behind it first and had the agent read the operator's corrections in an
order nobody wrote.

A message delivered into a turn that reaches no further step boundary is
still in the inbox, and the existing post-loop drain re-queues it to run as
its own turn.

`f5_the_queue_is_sent_on_the_operators_word_and_not_before` asserted the
opposite and was rewritten, not deleted. It counted `steer.say(` sites, and
the second one this adds is exactly the sabotage it existed to catch — so
it worked. Its "no echo" argument is answered, because every delivered
message is now recorded into the transcript; its "no undo" argument was
already true of the queue, since an unsent line ran as its own turn a
moment later either way. It now counts two named sites so a third is still
caught.
The mid-turn loop refused every slash but `/compact` and `/steer`, so
`/status` was declined for the same reason `/clear` is and the sentence told
the operator to interrupt their turn in order to read a report. And `/` and
`@` reached `App::key`, where they are ordinary characters, so the palette
typed a literal slash and said nothing.

Ten commands now run mid-turn: /status /context /cost /stats /help /theme
/copy /expand /fleet /image. The decision is `commands::runs_mid_turn` in
the library, with the partition written out in both directions and checked
to cover COMMANDS exactly, because nothing under tests/ links the driver and
this is a list about what may happen while a turn holds the session, the
store and the provider.

`/config` is refused in every form. Its bare picker carries a row that
re-reads the provider catalogue, writes prices into a scope file and
reassigns the running turn's Config; filtering that one row out is the
bare-from-argued split the contract refuses to make. Recorded as an
iteration with the developer's approval.

Neither `opens_palette` nor `complete::opens` changes — only the call sites.
The pickers reachable mid-turn are exactly those whose outcome touches
nothing the turn holds: the palette and path completion write into the
composer, and `/theme` resolves a theme.

A turn in flight holds `&mut Session` for the whole select loop, so five of
the ten could not have read it. Their facts are captured before the turn
borrows the session, into `TurnFacts`. That is not an approximation: the
root does not move, and the head and the last on-path turn are both the turn
before this one until this one commits, so mid-turn the snapshot is the live
reading. `status::committed` takes root/id/head, and expand, step_detail and
to_copy take the captured turn — better signatures regardless, since a
report has no business holding a session it might mutate.

`commit_image` becomes one implementation behind both doors. This product
has twice shipped a surface answered one way by its key and another by its
name; `/fleet` was the standing instance, and this release closes it.
`EventKind::Token` carries text and no count — providers do not bill per
chunk — so nothing moved the token field until a step committed. The one
number telling an operator what a turn was costing them sat still for the
whole of it, beside a clock that ran.

It is now accumulated from the streamed deltas through io-harness's own
`estimate_tokens`, the same estimator `/context` already uses, so io-cli
holds no tokenizer and nothing enters the tree. `App::tick` already redraws
on the hundred-millisecond tick, so the figure moves with the clock without
a second timer.

Drawn `~1.2k tok` while it is an estimate and `1.2k tok` once it is not. One
column, in both glyph sets, legible under NO_COLOR — a number that reads as
settled and is not would be worse than the frozen count it replaces. The
tilde says approximately rather than still-arriving, which is the honest
claim: what is uncertain is the figure, not whether more is coming.

The estimate is discarded at Step and Finished, never added to a settled
number, and cleared in `forget_run` so it cannot survive into a conversation
it was not measured in.

All three token renderers go through one helper. `Status::line` and
`Status::footer` are separate and the binary draws the footer on any
terminal seven rows or taller, so a field added to one is a field nobody
sees — 0.8.0's spend field and 0.12.0's planning field both paid for that.
Four surfaces dropped content silently, and on three of them the silence is
indistinguishable from there being nothing more.

`/stats` capped its per-table breakdown at a bare literal 5 and said
nothing. That is not a viewport problem at all — it is a committed page with
unlimited rows, so a database with nine tables had four of them dropped on a
page with room for every one. The cap is now named and the page says how
many smaller tables it held back.

The fleet view dropped rows with no count, and messages sort after every
child so they are the first thing off the bottom — on the one surface whose
entire job is saying what a fan-out is doing. It now counts them, and asks
the viewport for the rows it needs so that on a real session it does not
have to.

`f5_the_view_takes_the_composers_rows_and_not_the_status_line` keeps its
fixed four rows after an attempt to grow it: raising the viewport flips the
status line onto its three-row renderer, which is a different code path, and
the test would have stopped asserting the small-terminal layout it is named
for. It now also asserts the count, and the demand side has a test of its
own.
`Config::plugins()` looks like an accessor and is not: it is
`Plugins::load(&self.plugin_decls, &self.dir)`, a fresh read of every
declared bundle's manifest off disk, parsed, validated and trust-checked, on
every call. io-cli made that call twice on the build path of every turn,
twice more each time `/plugin` opened, and again for `/skills` — and
`Skills::discover` beside it reads the whole body of every skill file to
keep a name and a description, then throws the body away.

io-harness says once per run and is right. Every io-cli turn is a run, so a
session paid the cost on every message, scaling with the number of installed
bundles that the last three releases made it easy to grow.

`src/resolved.rs` holds the set. `contract::configured`, `contract::hooks`,
`contract::session`, `contract::server_notices` and `pluginview::view` all
take the resolved `&Plugins` instead of reaching for it. The driver resolves
at startup under `block_in_place`, before the event loop exists, and again
only when the declared manifests' stamps have moved.

The call is confined by exact path to that module, in the shape this suite
already uses for the two permitted process spawns. The property is not
"there is a faster way" but "there is no other way" — a cache in front of a
call anyone may still make stops being one the first time somebody reaches
past it.

Asserted as a call count and never as a duration: a wall-clock assertion is
flaky and says nothing about why a path is fast. The gate found a real
redundancy while being written — `drive` resolved for the session and then
`server_notices` resolved again a few lines below — and the confinement
caught a `#[cfg(test)]` module reaching around it.

`Resolved::loaded()` is not named `plugins()` on purpose: the gate bans the
bare method call by name, and an accessor sharing it would make the ban
unenforceable without knowing every receiver in the crate.

The freshness rule is stated rather than implied. Two writes inside one
second that leave the length unchanged are not distinguishable by a stat,
and that limit is asserted, not just documented.
This release is entirely an interface one, and nothing proves that except
asserting it.

O15 is a source property rather than a byte diff, and the test says why: a
byte comparison of two binaries' NDJSON needs a provider and this suite has
none. What makes the output identical is that none of this release is
reachable from `src/exec.rs` — no display translation, no wrapped-row
measurement, no token estimate, no mid-turn list, no viewport — and that is
checkable exactly. The line is still io-harness's own `RunEvent` serialized
whole. The end-to-end comparison belongs to the live suite.

The headless diff bears it out: twenty-two lines, all of them the plugin
threading, and `src/bridge.rs` untouched.

N6 extends the narrow suite to the two overlays this release rebuilt, at
80x24 and at the floor, in both glyph sets — the elision is one character in
Unicode and three in ASCII, so a surface reserving the wrong number of cells
clips in exactly one of them. The question overlay must keep something to
type into even at the floor, because an overlay that cannot be typed into is
the defect this release exists to fix.
`docs/CONTRACT.md` gains four things a script may depend on: the ten
commands that run mid-turn and the rule that decides the set, `/config`
refused in every form and why, the provisional token figure's spelling and
the fact that the estimate and the settled number are never summed, and the
display-versus-wire split — including that `io exec`, `io plugin` and
`io skill` report the wire name, because a script addresses the wire.

Four guide pages follow the code: keys.md gains a section naming both halves
of the mid-turn partition, skills.md the colon form and the round trip,
the-session.md the growing viewport with its four-row reserve stated as a
number, and plugins.md when io re-reads bundles and the same-second limit it
cannot see.

The key table is generated from `commands::KEYS` and gated in both
directions, so `Tab` went into the shipped table, the guide and the coverage
list together — the gate caught them disagreeing on the first attempt.
Both were written, documented and green, and neither had a test that could
fail. The pass is what found them.

O3 is the release's headline fix — a question drawn once rather than twice,
and never as a warning. Replacing its condition with `if true`, which is
exactly the defect it guards, failed nothing in a suite of 1,718. It is now
asserted in both directions, and the second matters more: suppressing a
question everywhere is worse than printing it twice, so a resumed run and a
plain session must both still commit the line.

O11 is `Tab`. It was bound in `Picker::key`, listed in the shipped key
table, and written into the guide — and removing the arm entirely broke no
test. Both spellings of shift-tab are asserted too, because a picker reads
raw key events rather than going through the binding table, so a terminal
sending `BackTab` and one sending `Tab`+SHIFT both arrive here as
themselves.

Eleven arms in total now, every one with a real site, every one killing the
gate it was aimed at and nothing else.
`cargo fmt --all` over the release's own changes, and `Cargo.lock` picking up
the 0.32.0 bump from the manifest. CI runs `cargo fmt --all -- --check`, so
this is the difference between a green gate and a red one rather than a
matter of taste.
Two reviewers, split by area. Both independently found the skill regression
and the false freshness claim, which is the strongest signal either could
have given.

HIGH — mid-turn `/` and `@` ran in front of `App::key`, which is what hands
a keystroke to an open overlay. The three modal overlays each own their own
composer, so `app.composer` stays empty and `armed()` stays false for their
whole lifetime: answering an agent's question with a path, or any text
containing `@`, opened the palette instead and the question vanished. That
is the release's headline feature, broken by the release's own new branch.
`App::modal()` exists for exactly this and was not consulted.

HIGH — choosing a non-bundle skill from the palette became unreachable.
`invoke_skill` now writes `/<name>`, and `parse` recognises a skill only by
its colon — which bundle skills have and io-cli's own and the operator's own
do not. Those printed "there is no /x" plus the whole command listing. It
worked in 0.31.0, when the sentence submitted as an ordinary prompt. The
driver now resolves any word against the inventory, with
`commands::names_a_command` as the precedence rule so a skill can never take
a command's name.

HIGH — the display translation was applied to every tool-call target on the
reasoning that no path contains `__`. `__init__.py`, `__pycache__`,
`__tests__`, `__mocks__`, `__snapshots__`. `read src/__init__.py` was drawn
`read src/:init__.py`, a path that does not exist, in the one place an
operator checks what the agent touched — on the first turn in any Python or
Jest repository. Gated on `read_skill`, whose target is a name.

`set_answering(true)` was never restored, so after the first turn the durable
question line was suppressed for the rest of the process — including
`watch_child`, which draws no overlay at all, and the post-loop drain. A
question reached no surface anywhere. The flag's own doc names `false` as the
safe direction.

`Resolved::stale` called `config.plugins()`, which is the full read. The
release halved a cost it claimed to have removed, and said so in three
places. It now re-stats only the paths it recorded — each manifest, and every
file `Config::sources` names, so a bundle declared or undeclared is still
seen.

The token estimate outlived its turn: cleared at `Step` and `Finished`, but a
provider error mid-stream emits neither, so a tilde-marked guess sat on an
idle prompt and bled into the next turn.

The question overlay's row demand oscillated by the composer's height on
every arrow key, and the driver re-places the viewport whenever the demand
moves — a terminal tear-down and cursor query per keystroke, on a surface
open while a turn streams. The block is reserved whenever it is configured.

`rows_wanted`'s chrome disagreed with what `render` reserves whenever the
fleet was open, masked by `Fleet::rows_wanted` counting its summary row
twice. Two compensating errors are not a correct answer.

`Intent`'s `Cancelled` arm was dead and its comment contradicted `App::key`
about what `Ctrl+C` does. `rows::elide` now measures its own suffix rather
than assuming one row, and says so when there is room for nothing else.
`cost_field` has its own doc back.
0.13.0 removed viewport growth from the palette on purpose: re-placing an
inline viewport asks the terminal where its cursor is, and that round trip
lands on `/` — the fastest thing an operator does — with a two-second wait on
a terminal that never answers. Its own note says the fallback is not to
restore it.

0.32.0 restored it, and the live suite refused: `live_f6_the_palette_opens_
without_asking_the_terminal_anything`, 35 passed / 1 failed. That test drives
the real binary through a pty and reads the byte stream. It is the only gate
that could see this — `Screen::replace` re-attaches to a real terminal, so
the `Fixed` backend every unit test uses observes nothing, and the decision
lives in the driver, which nothing links. 1,724 offline tests were green over
it.

The adversarial review predicted it from the source alone, and added the
part the live test does not reach: a picker's demand is `matches.len()`,
which shrinks as a query is typed — so filtering four hundred models would
have issued a cursor query on nearly every keystroke.

The three overlays still grow; they are drawn inside the session's own frame
and re-place at most once as they open and close. A picker gets the elision
this release added instead, which says how many rows it did not draw and
costs nothing.

`Picker::rows_wanted` stays. It is what the elision measures against, and a
later release that finds an affordable way to grow has a correct number
waiting for it. O16 is narrowed to the three overlays by
US-IO-CLI-0.32.0-I12, and the driver-text assertion in tests/viewport.rs is
what keeps the round trip from coming back.
Two count assertions in `rows.rs` used `contains` on a stringified number,
which "116" satisfies for "16" and "16" for "6" — a count assertion that
passes on a wrong count is exactly the vacuity this product keeps finding in
its own gates. They compare the whole rendered row now.

The streaming estimate's doc said "tokens the step now streaming looks like
it has spent". It counts the completion only, because the assistant's text is
the sole per-chunk signal, while the provider's total is prompt plus
completion — so on a large prompt the provisional figure is a small fraction
that then jumps. The tilde already made it honest; the doc now says what is
actually measured.

`io exec` resolves the plugin set twice per run, not once as its comment
claimed: the contract and the hooks are built at different points and
threading one resolution between them would reshape the headless
construction for a process that runs one goal and exits. Bounded and stated
rather than claimed away.
Found by sweeping the shipped prose for claims this release made false —
the sweep that has caught stale text in three previous releases, and which
no gate performs because a `contains` assertion cannot notice a sentence
that is merely no longer true.

Eight rows is the floor now, not the size. The paragraph says so, and says
what the four reserved rows at the top are for.
The same sweep, carried further. `docs/guide/skills.md` said `/skills` and
the palette spell a bundle's skill `<bundle>__<name>` — which this release
changed, and which left that page contradicting a section added to it by
this very release. `docs/guide/plugins.md` said `/plugin` draws the
namespaced string "unchanged".

Both now say what is true: qualified, drawn with a colon, addressed with
io-harness's separator, and reported in the wire form by `io plugin list`
and `io exec` because a script addresses the wire.

A file that disagrees with itself is worse than one that is merely out of
date, and only a by-hand reading finds either.
io-cli 0.32.0 — the README no longer says the viewport is eight rows
@aakashpawar1999
aakashpawar1999 merged commit 0f4a8b0 into main Aug 30, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant