Skip to content

io-cli 0.17.0 - #46

Merged
aakashpawar1999 merged 13 commits into
mainfrom
develop
Aug 26, 2026
Merged

io-cli 0.17.0#46
aakashpawar1999 merged 13 commits into
mainfrom
develop

Conversation

@aakashpawar1999

Copy link
Copy Markdown
Contributor

Release PR for io-cli 0.17.0 — say something while it works.

A prompt typed during a turn is kept instead of destroyed, drawn in a queue above the composer, editable there, and fired when the turn ends. /steer sends a queued line into the turn that is still running, delivered at the next step boundary. Ctrl+C means exactly what it meant. /context says what is in the model's window, read off the request that carried the turn. /compact folds when you say so and reports what folded. /mcp says how many tools a server offered. ctx N% is true for the first time for anyone who configured their own window.

io-harness 0.67 → 0.69; both breaking changes land in tests, and no package was added to Cargo.lock.

Feature PR #458/8 green on three platforms, no flake. git diff --quiet feat/0.17.0 develop is clean, so this is the tree the gates ran on.

Suite 68 binaries / 899 passed / 0 failed / 24 ignored, clippy and fmt exit 0, five live pty captures of the built binary against a real endpoint.

Record sealed and pinned at sha256:4a155fef2fe54e9b016c96362f9d9b229bfb7e9439297a7d75af79985166c4a4, ultraship validate exit 0 over 52 checks. .ultraship/ is gitignored whole here, so the record and its evidence live on disk rather than in this diff.

0.67 -> 0.69, which is the newest published version. Two breaking changes
reach this crate and both land in tests rather than src:

  * `EventKind::Mcp` gained `tools: Option<u32>` in 0.68.0 — how many tools
    a server offered, set on the event announcing it reaching the run and
    `None` on every other form. Every construction of the variant names it
    now, with `Some(n)` on the announcement and `None` on a call.
  * `SteerInbox::pending` returns `Steering` instead of `(Vec<String>, bool)`
    in 0.69.0, so the third thing an operator can send — a fold — did not
    have to grow the tuple. The interrupt test binds the struct, and asserts
    that an interrupt is neither a message nor a fold.

Nothing else moves: no dependency was added by either harness release, the
feature list is untouched, and the suite is unmoved at 61 binaries / 792
passed / 0 failed / 24 ignored.
…used

io-harness 0.68.0 put the count on the wire: `EventKind::Mcp` carries
`tools: Option<u32>`, set only on the event announcing a server reaching the
run, over the server's whole listed catalogue, and `None` on every other form.
That closes the deferral 0.16.0 recorded as US-IO-CLI-0.16.0-I01.

Offered and asked-for are two different questions and are now two different
numbers. `Some(0)` is a server that stated it offers nothing; `None` is an
event with nothing to say about the count. Collapsing them would make a server
that has only ever answered CALLS — every one of whose events carries `None` —
report offering no tools while visibly using them, so a count is assigned only
from a `Some` and a later `None` can neither write a zero nor erase one that
was heard.

The module doc that said the fact could not be obtained is corrected in the
same commit that obtains it: a doc comment asserting a property is a claim the
next reader trusts.

Also fixes the one site 0.16.0's rename missed. `/status` said "offering N
tools" over a number that has counted CALLS since 0.10.0, while the status line
itself has said `mcp N/M calls` since. It says "answering N calls" now — two
different numbers under one word is worse than either alone, and doubly so now
that `/mcp` draws a real offered count beside it.
… order

A prompt typed while a turn is in flight already reached the composer and drew
there — `App::key` has never had a running guard — and the `Enter` that
followed returned a submit into the driver's catch-all arm, which discarded it
while the composer that held it had already cleared. The keystroke looked
accepted and the prompt was gone. That is the defect this release exists to
remove, and it had no test because nothing under tests/ links the binary.

So the guard lives in `App::compose`, not in the driver: a mid-turn Enter
queues the text and returns `Command::None`. That is the only place deciding
what a mid-turn prompt becomes, which is also the seam mid-turn steering needs.
`/` and `!` still fall through to the driver's existing refusal — a queue that
held shell lines would put a second construction site behind the spawn ban.

The queue is its own field rather than an overload of the picture queue or of
`submitted`, which is the current turn's prompt and exists so a rewind can put
it back. It drains one prompt per turn: three queued lines are three turns in
the order they were typed, each its own exchange in the scrollback and each
interruptible. Joining them would answer three questions in one breath.

A turn the operator stopped drops what was waiting. One press of the stop key
should not start the next three turns.

Queueing announces through `say` rather than `record`: the prompt enters the
transcript when it runs, as its own echo, and a line here would be two entries
for one prompt.

`src/main.rs` also carries the driver wiring for `/context` in this commit,
because both changes edit the same file.
…quest

io-harness enumerates no context window: the prompt composer is private, the
event announcing a composed prompt carries a byte count and deliberately no
text, and the tool catalogue offered to the model is behind no public accessor
at all. What it does hand the caller is the `CompletionRequest` itself, whose
`system`, `tools` and `messages` are public — and io-cli already routes every
provider through one generic seam. So a decorator over that seam records the
request on its way past and `/context` renders what was actually sent, tools
this crate never registered included.

The maker is wrapped rather than the provider it makes, so a `/model` switch
keeps reporting instead of quietly reverting to a provider nothing watches. All
twelve trait methods are delegated: nine are defaulted, and a default firing
here would change behaviour silently — the trace would lose the provider's
name, attachments would start being refused, and streaming would stop.

The sections are a disjoint partition of the request rather than six
overlapping wholes, because the harness puts the instructions inside the system
block, the MCP tools inside the tool list and the recalled memory inside the
user turn. Reporting each whole would double-count and the page would exceed
the request it describes. Every count is io-harness's own `estimate_tokens`,
the same estimator its assembler uses, and the denominator is the budget the
operator's own configuration put on the contract.

The window is forgotten wherever the run is: a window that outlives its
conversation describes a turn the operator has left.

Four gates moved, and none was relaxed to a tolerance:

  * the hand-written command list, the README table and the palette's narrowing
    counts all take `/context` — the gates that make adding a command a
    decision somebody makes.
  * `f6_both_arms_are_handed_one_contract` counts a fourth `contract::session`
    call. It is a reader bound as `reading`, not a turn bound as `contract`,
    which is the distinction the assertion above it rests on.
  * `f7_no_source_file_loops_over_provider_responses` exempts the decorator's
    forwards BY PATH and adds a new assertion keeping the exemption a boundary:
    a forward hands the call to the inner provider and must never read what
    comes back. The forwards are subtracted rather than the file skipped, so the
    module is still held to the no-top-level-loop rule — the half of that gate
    that matters most. Same shape as 0.7.0's amendment of the spawn ban.
io-harness 0.67.0 opened the two entry points that take the caller's contract
AND a steer inbox on one call. Until then a turn could carry a contract or be
steered and never both, which is why 0.11.0 dropped steering rather than an
oversight. Both arms now take `_steered`, positionally identical to what they
took before plus the inbox. The inbox is a parameter of the harness's drive
call and not a field of `TaskContract`, so the contract builder is untouched
and the gate asserting an unconfigured session reproduces the harness's own
default contract is not at risk.

`/steer` is a word rather than a default, and the reason is that a delivered
steer emits no event this interface can draw — the harness records it in the
run's trace, not on the observer stream. A line sent automatically would leave
the screen with no echo of any kind, which is the shape of the keystroke this
release just stopped losing, and worse, because a queue is drawable state and a
steer is not. `Steer::say` also has no undo: default-send would make every note
typed to oneself mid-turn an instruction to the agent.

So the operator is told three things and never a fourth. The words go into the
transcript, because that is the only place they survive. The footer says "sent
— the turn reads it at its next step", never "delivered": `Ok` means the
channel took it and cannot mean a step read it. And anything still in the inbox
when the turn ends is reported as not delivered, which is the one delivery fact
this interface can state.

`Ctrl+C` does not move. It stays on the observer's cancellation flag rather than
`Steer::interrupt`: both end in a cancelled outcome, but changing the mechanism
changes which path records it, for no operator-visible gain, on the one key a
configuration file is refused permission to rebind.

The queue is now on screen and on the status line.

The surface follows the fleet view and not the three modal overlays — those are
modal because a run is blocked inside a harness callback waiting for an answer,
and nothing here is blocked. It stays out of `App::modal()`, takes `Up`, `Down`
and `Esc` only while open, and lets every other key through.

**It takes the blank row, and that is the whole reason it is visible.** At the
viewport a running turn actually holds — eight rows — the streaming tail, the
activity line, the rule and the three-row footer take six and the composer keeps
its floor of one. There is no spare row above the composer, so a surface drawn
from that allowance would have drawn nothing on every real session while passing
its tests; and the allowance is widest on SHORT terminals, so it would have
appeared on those and never on tall ones. Growing the viewport costs every
session a row of scrollback forever, and taking the composer's own row is what
this layout has refused since 0.1.0. The blank carries nothing by its own
argument, the queue carries something, the frame is the same height either way,
and the blank returns when the queue empties. Where it has exactly one row that
row goes to the line that runs next, with the rest counted on the end of it — a
surface that says three are waiting and names none of them has spent its only
row on the half of the answer the status line already carries.

The depth is on the status line AND the footer, from one method, because the
footer is what any terminal of seven rows or more is given — a field on the line
alone is a field no operator sees, which is how 0.12.0 shipped an invisible
planning field. It is not cleared by `forget_run`: the queue belongs to the
session and outlives the run, and blanking the count would stop reporting
prompts that still fire.

Recorded as US-IO-CLI-0.17.0-I02.
The queue is editable, `/compact` is real, and the interrupt has a gate.

**Editing.** The mark lives on a `Cursor` in the queue's own module and not on
`App`: a selection is a fact about the surface drawing it, meaningless with the
surface shut, read by nothing but the rows — and on `App` it would be a fifth
field about a list that already has three, which every mutation would then have
to remember to keep honest. The verbs take the queue by reference, so `App` is
still its one owner. The index is stored raw and every read clamps, because the
queue drains from the front underneath it.

`Enter` at an empty prompt takes the marked line OUT of the queue and into the
composer — out rather than copied, so a turn ending mid-edit cannot run it
twice. `Enter` puts it back at its own position, emptied it is a drop, and `Esc`
restores the text as it was taken, which is 0.13.1's rule in the one place this
release can honour it. Because the take needs an empty prompt, a half-typed line
is never overwritten. A turn ending under an edit forgets the position and not
the text: re-inserting would put a second copy behind a drain that is about to
start, and both would run.

`Up` stays prompt history. The surface's arrows are scoped to the open surface
AND an empty prompt, so a recall in progress and the arrows inside a multi-line
prompt never reach it at all. Bound at the bare composer they would have worked
perfectly for anyone with something queued and silently cost history to everyone
else — a feature nobody asked to trade, broken by a release about a different
one.

**`/compact`.** The roadmap said io-harness had no fold-now call and would have
to lower the threshold and hope. It has two: `TaskContract::fold_now` for the
next turn and `Steer::fold()` for the turn already running. So a request is a
request and a fold is a fold, and one type carries both states because the
defect this exists to prevent is a request wearing an outcome's words.

Four documented conditions make an accepted request fold nothing, and the
request is spent under all four. Two can be predicted — the timing, and folding
configured off, which is read from the contract and said before anything leaves
the process. Two can only be observed: an interrupt at the same boundary wins,
and a conversation shorter than the fold's floor has no prefix to stand in for.
So nothing but `EventKind::Compacted` may say a fold happened, and when the turn
ends without one the line says the request was spent rather than that a fold
occurred. What the summary replaced is named from the store, which keeps the
observations a fold only stops sending.

**The interrupt.** Eight new tests pin every branch of it, including the one no
test could reach before: the mechanism. It is a source gate over the driver with
comments stripped — the driver names `Steer::interrupt` in the comment saying
why it is not used — asserting the cancellation flag is what the key sets and
that the inbox is not, plus the live half through the real bridge so the gate
does not rest on a variable's name. `tests/interrupt.rs` said in its own module
doc that `Ctrl+C` "must reach `Steer::interrupt`", which was the opposite of
what the driver has ever done.

**Gates.** The clock rule now covers `SystemTime::now` and `.elapsed()` as well
as `Instant::now` — the criterion always named three and the gate swept for one
— walks `src/` recursively rather than one level, and refuses an aliased or
glob-imported clock, scoped to import lines so prose arguing about time does not
redden. The dependency sweep now reads build-dependencies and every
target-conditional table: `nix` is on the forbidden list and cfg-guarded is
exactly how a sandbox crate arrives. Dev-dependencies are pinned separately.

Two gates were wrong rather than the code, and both are corrected here rather
than worked around: the decorator gate matched a contiguous string that rustfmt
splits across two lines, and a steer gate asserted a fold was not offered — both
written against a half-built tree.

And two tests asserted the wrong thing about behaviour older than this release.
A drop notice naming which line went is the product working; `Up` with text
typed has always recalled history and stashed the draft. Both now assert what
F3 is actually owed — that the surface did not take a key belonging to the
composer.
…p lying

**`ctx N%` has been wrong since the field was added, and silently.** It divided
by `ContextBudget::default()` with no remaining allowance, so an operator who set
`[run.context]` — or `[run] max_tokens`, which the harness takes a share of — saw
a percentage of a window they do not have. The comment above it claimed the
denominator was "io-harness's own declared budget, asked of the harness rather
than copied here", which was the shape of the answer without being the answer.

The window now comes from the contract the turn is running under, through
`Budgets::in_force` — which the driver already assigns at startup and once a
turn, so the denominator costs no new driver line and cannot drift from the other
budgets. No window means no field, rather than a fallback: a loud absence beats a
plausible wrong number, which is the whole lesson of this defect.

And it no longer waits for a fold. The numerator is the per-step assembly the
harness already makes durable — `ContextEvent::assembled.est_tokens`, read once
per step on the same anchor `commit_edits` uses, not once per event. `/status`
now names the window even before the first turn, because that half is the
contract's and is knowable at an idle prompt; it is the half an operator checking
`[run.context]` came to the page for, and the old sentence withheld it.

Note it is `[run.context]` and not a top-level `[context]` table — the root
refuses unknown keys, so the table the first draft of this contract named would
have been rejected outright.

**And roughly twenty sentences across fourteen files said a turn cannot be
steered.** They were the most dangerous thing in the tree for this release: a
reader trusting them would conclude the work needed a dependency change it did
not. Corrected in the release that falsifies them, along with the claim that
containment decides whether a turn can be steered — it decides fan-out, and as of
this release that is the only thing it decides.

Sentences that are still TRUE were left alone, and that is the harder half:
`Session::turn` and `turn_steered` really do still build their own contracts, the
interrupt really does stay on the observer's flag, and a delivered steer really
cannot be confirmed from the event stream. One test's title was backwards in the
other direction — it said a steered turn is never cancelled through the observer,
which is exactly the mechanism.

Two claims found while sweeping, neither about steering: `src/events.rs` argued
from a contract lineage this crate does not have (the conclusion held — nothing
here calls `with_verification`), and eight files still described a viewport of
four rows, which has been eight since 0.13.0.

`tests/docs.rs` gains a gate for this release's version of the mistake: every
comment line under `src/` and `tests/`, plus the README, against the phrases that
assert a turn cannot be steered. Comment-lines-only is what makes it safe — the
absent-lists inside existing tests are code and do not redden — and it has a
positive half too, because a gate made only of absences passes on a file that
says nothing.

The README gained the queue's keys in prose, outside the generated table. They
are borrowed while the surface is open rather than bound all session, which is
why they are not in the key table, and the paragraph says so. Before this the
release's headline feature had no documentation surface at all.
**Found by driving the binary, ninth release running.** `/context` totalled
4,363 tokens of 24,000 while the status line one keystroke away read `ctx 0%`.
Neither number was wrong on its own: the page measures the whole request, and the
field measured the observation section — which is what `ContextBudget` bounds and
what `Compacted::after_tokens` reports, and which early in a turn is a rounding
error next to the tool catalogue. The pair was wrong. Two surfaces disagreeing
about a word, on one screen, one keystroke apart, is worse than either number is
useful — and the percentage is what makes an operator open the page, so the page
is the one it has to match.

Both now come from one snapshot through one helper, by the same calls: the
request's sections, totalled, over the window the contract declares. The trace
read is kept as the fallback rather than deleted, because it answers a real
window — a step lands before the completion call after it is snapshotted, so on
the very first step the section the trace records is the only number there is.

Re-cut the binary and re-drove the capture: `ctx 18%` against `4363 tokens of
24000`. The test asserts the field IS the page's total over the page's window
rather than that the two are close, and asserts the fixture is not the degenerate
agreement of two zeroes — which is the shape the defect wore on screen.

The live evidence is in the release's evidence directory: four captures, their
scripts and their timelines. It includes the run where a steer was sent and NOT
delivered, because the turn reached its last step before the next boundary — the
interface said so, which is the one delivery fact it can state.
… them

Clippy's type-complexity rule caught the tuple this release grew to three, and
its judgement is the right one: two facts read fine positionally and the third —
the offered count 0.68.0 put on the event — is where `entry.2` stops saying what
it holds at the call site. `asked` and `offered` are exactly the pair a reader of
this module must not confuse, so they are named.

No behaviour change; the same events fold in the same way.
Every check was green — 68 binaries, 893 tests, clippy clean, the live captures
taken — and a read-only review of the diff found four real defects. All four are
the same shape, and it is the shape that was being hunted: **the tests exercise
each feature once, and the defect only appears the second time.**

**`/clear` never forgot the window.** `/resume`, `/fork` and the rewind all drop
the recorded request beside their `forget_run`; `/clear` did not — and it is the
site `Seen::forget`'s own doc names first. So `/context` drew a whole conversation
the operator had just discarded, on a session with no turns in it, while the `ctx`
field beside it was blank because `forget_run` did clear that. Two surfaces
disagreeing about one fact, which is what this release exists to stop, reappearing
through a door nobody had opened yet.

**A `/steer` on a turn's last step destroyed the queue.** Sending REMOVES each
line to hand it over, and the send cannot fail while the inbox is alive, so the
footer said "3 sent" and meant it. If the turn then ended before another step
boundary, the lines were reported as not delivered and dropped — three prompts'
worth of work gone, from the release whose headline is that a mid-turn prompt is
no longer destroyed. The window is not narrow either: `/steer` is typed exactly
when the agent looks close to done, which is exactly when there is no boundary
left. They now go back to the FRONT of the queue, in order, and run as the next
turns. A turn the operator STOPPED still drops them, because one press of the stop
key must not start another — the old comment claimed a composer was waiting to
catch them, and nothing ever put them in it.

**`ctx N%` divided by the whole run budget rather than what is left.** io-harness
assembles against the unspent remainder — and `context::window`'s own doc says so,
in this crate, above the function no call site used that way. With 40k budgeted
and 30k spent the real window is the floor while io-cli still printed the flat
maximum and a share several times too small: under-reporting pressure exactly when
there is pressure. Only moves when `[run] max_tokens` is set, which is why the
fixtures missed it.

**The `… N more` row counted what was outside the window rather than under it.**
Nine queued, four rows, marked on the eighth: the rows read 6, 7, 8 and the count
said six more, five of which were above. Correct on the first draw, when nothing
is marked and the window starts at the top; wrong from the first arrow. The test
that pinned it had baked the wrong number in.

Plus three smaller ones. The fleet view and the queue could be open together, so
the queue took the blank row for a surface drawn in its place and `Enter` reached
into a queue behind a view covering it — a surface acting while invisible.
`/steer do the thing` missed its guard and got the mid-turn refusal, which tells
the operator to interrupt the turn first: the opposite of what the command does.
And the fleet's own `Esc` guard reads a field this function has already emptied,
so it was always true — pre-existing, found because the queue's version of the
same guard gets it right and its comment describes the trap by name.

Six new tests, each driving the SECOND use rather than the first.

The review also confirmed what holds: no mutation of the queue leaves the status
count stale, the one `unreachable!` is genuinely unreachable, no indexing path in
the surface can panic, a mark cannot survive a drain, the decorator delegates all
twelve provider methods with no default firing silently, and `Ctrl+C` is untouched
on every path.
io-cli 0.17.0 — say something while it works
@aakashpawar1999
aakashpawar1999 merged commit bddf7b8 into main Aug 26, 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