Skip to content

io-cli 0.13.1 - #38

Merged
aakashpawar1999 merged 21 commits into
mainfrom
develop
Aug 24, 2026
Merged

io-cli 0.13.1#38
aakashpawar1999 merged 21 commits into
mainfrom
develop

Conversation

@aakashpawar1999

Copy link
Copy Markdown
Contributor

[0.13.1] - 2026-08-24

The session answers every keystroke.

The prompt froze when it grew past two rows, and it is fixed. Pressing the
newline key a second time — or running /clear, or expanding a large pasted block
back to its full text — could stop the session dead for seconds, and on a measured
run it stopped for 5.7 seconds and then answered nothing at all. All three do the
same thing underneath: they re-place the inline viewport, which needs the terminal
to itself for a moment, and the keyboard reader was taking it straight back every
time it let go. A reader now stands aside while a placement wants the terminal.
The same keystroke, measured on the same script against the same binary: 5.7
seconds of silence before, 11 milliseconds after.

A prompt written on more than one line is read back as more than one line. A
two-line prompt was echoed as one run-together row, because a rendered line is one
row and a newline inside it draws as nothing.

/attach takes the path you actually have. Three things were wrong with it at
once. A path dragged in from Finder arrived quoted and the quotes were never taken
off, so the extension read as png" and io said your screenshot was not an image.
The quoting escaped every non-ASCII character, which includes the narrow no-break
space macOS puts in every screenshot's name — so the path named no file even
unquoted. And a path outside the workspace was refused outright, which is where
screenshots live. /attach ~/Pictures/shot.png now works. A path inside the
workspace still goes through the session's policy, unchanged; a path outside it is
read directly, because that is the operator's own file and the same boundary !
already crosses.

The prompt wraps, and there is one cursor. tui-textarea scrolls sideways
rather than wrapping and paints its own block cursor, while everything io-cli
measures assumes a wrap — so a long prompt was drawn clipped at the left, with
two cursor blocks on it in two different places, and the viewport had grown for
rows nothing used. The composer draws its own wrapped rows now: text that reaches
the right edge continues on the next line, the window follows the insertion
point, and the only cursor on screen is the terminal's own.

Pasting the same block again toggles it both ways. Expanding a collapsed
paste used to leave the block in the prompt with its placeholder gone, so the
next paste of the same clipboard appended a fresh one — [pasted text #2], then
#3, then #4, piling up after text that was already there.

A pasted block deletes as one thing on every backwards deletion.
Option+Backspace and Ctrl+W used to eat [pasted text #8, 464 characters]
one word at a time, and a placeholder is matched by its exact text — so the first
press had already stopped it standing for the block it named.

The composer is one row at rest and grows to what a prompt needs. The second
row was there for a paste too big to read in one and was empty for every prompt
anybody types.

/attach is gone; drag a picture onto the prompt or paste it. A command was
something you had to be told about before you could use the feature. Pasting the
same file again toggles between the marker and the path it stands for. The word
is not kept as a hidden alias: /attach is answered the way any other word that
is not a command is — there is no /attach. The commands are: — and the list
under it is the truth.

A marker deletes with the space written for it, so one press removes one
thing under every backspace — Option+Backspace used to eat 1] off the end and

Growing the composer past two rows, running /clear and expanding a large
paste all re-place the inline viewport, which needs the terminal to itself
for a moment. The keyboard reader held the stdin lock across every poll and
took it straight back on release, and std::sync::Mutex promises no fairness,
so the placement waited on a scheduling accident: 5.7 seconds on a measured
run, after which the session answered nothing at all.

The reader now asks before it takes. A flag says a placement wants the
terminal, from the moment it asks until it is finished with it, and the
reader declines for as long as it is set rather than queueing on the lock —
a reader queued there is a reader holding the terminal again the instant the
placement lets go. The poll interval drops from 40ms to 10ms, which is the
only lever on the hand-over: a reader cannot be interrupted mid-poll.

The mechanism moves out of main.rs into src/stdin.rs, because no integration
test links a binary. The poll and the read stay inside one critical section
by construction: the guard is never handed out, only wrapped around work.

Measured on the same script against the same build: 5,750ms before, 11ms
after, and 1,905ms with only the flag removed.
A Line is one row and a newline inside a span draws as nothing, so a prompt
written as "abc" and "def" was committed to the scrollback as "abcdef" and
the operator could not read back what they had sent. The goal is split on
its own newlines: the mark opens the first row, the rest are indented under
the first character so the block reads as one thing said once.
Three defects, stacked, and the first hid the other two.

The composer wrote a pasted path through format!("{path:?}"). Debug quoting
escapes every character Rust considers unprintable, which includes the U+202F
narrow no-break space macOS puts in every screenshot's name — so what landed
on the prompt was \u{202f} as six literal characters. attach::prepare then
never took the quotes off, so Media::source_type_for read the extension as
png" and io answered that the operator's screenshot was not an image.

Underneath both: Workspace::resolve refuses every absolute path, so a file
outside the repository could not be attached at all — which is where
screenshots live. A path inside the root still goes through the session's
policy, however it is spelled, so a rule that denies it still denies it. A
path outside it is read directly: /attach is the operator's own action about
their own file, which is the boundary ! already crosses when it runs their
shell line. A leading ~ is expanded.

The 0.13.0-era test asserting the opposite is rewritten in place, with the
supersession stated in the test rather than left as a silent deletion.
The streaming row was under the activity line, so the newest words the agent
had written read as a footnote to a spinner rather than as the continuation
of the transcript directly above them. The order is now the live row, a row
of air, the activity line, and the composer under it. On a viewport too short
for the blank the blank is what goes, and the order still holds.
The row order in both places the viewport is described, the widened /attach
in the section that documented the policy it used to state, and the freeze
named in the operator's words with the measured before and after.
tui-textarea does not wrap — it scrolls sideways — and it paints its own
insertion point as a reverse-video cell. Everything io-cli measures assumes
a wrap: the viewport height it asks for, the rows the composer claims, the
caret it places. So a prompt long enough to wrap was drawn clipped at the
left, with two cursor blocks on it in two different places, and the viewport
had grown for rows the widget was never going to use.

The editing stays the widget's; the picture is this crate's. One function
wraps the lines and says where the insertion point is among them, and the
render, the height and the caret all read it. The window follows the
insertion point, so editing the middle of a long prompt keeps it on screen.
A caret past a row that is exactly full rests in that row's last cell rather
than opening one below it, which is what the terminal's own cursor does.

Also: pasting the same block again toggles it both ways. Expanding used to
drop the placeholder while leaving the block in the paste list, so the next
paste of the same clipboard matched nothing and appended a fresh
placeholder — #2, then #3, then #4, piling up after text already there.
Every cell of the region prints nothing, which is what stops a row of spaces
erasing the placement — and it also means nothing erases what the terminal
was already showing there. Those rows were the viewport a moment ago,
scrolled up, so a picture that did not cover its whole box was drawn into
the old composer and status line: half an image with a stale prompt through
it and a rule across it.

ESC[0J from the region's first cell, before the placement and never after.
Below it is the viewport, which the next frame repaints in full anyway.
A model that cannot look at pictures ended a turn with two lines of a
routing layer's vocabulary:

    error: escalated_terminal
    error: provider error (Request, HTTP 404): {"error":{"message":"No
    endpoints found that support image input","code":404}}

Neither says the one thing the operator can act on. A sentence now goes in
front of the harness's own line — never in place of it, so an unrecognised
failure still arrives whole — for the six conditions met most: a model that
takes no images, an empty account, a rate limit, a rejected credential, a
model the gateway will not route, and a conversation past the context
length. Matched on what the provider says rather than on a status code,
because the same condition arrives as a 404, a 400 and a 422 from three
gateways.

escalated_terminal and escalated_retryable get the sentence io-harness's
bare escalated already had. Only the bare spelling was matched, and the
terminal one is what an operator actually meets.
The arm excluded Alt, so Option+Backspace — the delete-word every macOS
reader has in their fingers — fell through to the widget and ate the
placeholder one word at a time, leaving "[pasted text #8, 464 chara" on the
prompt. A placeholder is matched by its exact text, so the first press had
already stopped it standing for the block it named, and the prompt would
have sent a sentence where a file should have been.

Backspace with any modifier and Ctrl+W now take the whole placeholder when
the cursor is at the end of one, and are the widget's own deletion when it
is not.
Four changes an operator asked for after running the build.

**A rule over the composer.** The footer has opened with one since 0.1.0 and
the prompt had a boundary on one side only, so it read as the tail of
whatever the turn had last written. The viewport is nine rows now; the rule
is the second row given up on a short terminal, after the blank.

**Notices live in the footer.** Stopping one turn used to commit three rows
into the terminal's permanent scrollback — "stopping at the next step
boundary", "stopping now", "stopped" — in warning colour, between two
answers, forever. None of them is part of the conversation: each answered a
key that had just been pressed. A notice now takes the footer's last row,
replaces the one before it, and is gone at the next keystroke. What still
reaches the transcript is what belongs to the record: what was authorised,
what was answered, and why a turn failed.

**A turn stopped before it did anything is taken back whole.** No step,
nothing streamed, nothing on screen but the echo of the prompt: the first
esc abandons it, the rows come off the screen, and the prompt goes back in
the composer ready to edit or send again. Nothing is said, because nothing
happened. Only rows still on screen — what has scrolled past belongs to the
terminal.

**An attachment is [Image #1].** A picture is no longer drawn when it is
attached or when the turn is submitted: the marker is what the prompt
carries, what the agent is told and what the transcript keeps, and it deletes
as one thing exactly as a pasted block does. /image 1 draws it when somebody
wants to see it — a fresh copy at the bottom, because a committed row belongs
to the scrollback and nothing here can reach back into it. /image stops being
a second spelling of /attach.

Also: pasting a block after editing its expanded text reuses the block's own
number instead of minting #2, #3, #4.
/attach is removed. A path pasted onto the prompt that names an image is
staged and marked [Image #1] there and then — which is what an operator
already does in every other window they talk to a model in, and what a drag
from Finder produces. Pasting the same file again toggles between the marker
and the path it stands for; the marker still deletes as one thing under every
backspace. /image N is unchanged, and the word "attach" still parses, to the
sentence /image says when it is given nothing, so a reader who learned the
command is answered rather than met with silence.

A picture dropped while a turn is running waits for the turn to let go of the
session and is staged the moment it does, rather than being dropped or
half-attached.

Also: the composer is one row at rest instead of two. The second row was
there for a paste too big to read in one and was empty for every prompt
anybody types — a blank row between the rule and the line being written. It
still grows to what a prompt needs.
…on /clear

Three things an operator found in the marker.

The marker is written as "[Image #1] " so the next word does not run into the
bracket, and the cursor sits after that space — so a deletion took the space
first and a word-wise one then ate "1]" off the end, leaving "[Image #" on the
prompt. The space is counted with the marker now, so one press removes one
thing under every backspace.

The path a repeat paste toggles to is quoted, for the reason any pasted path
is: a path with a space in it is two words to everything downstream.

/clear resets the numbering. "[Image #4]" in a conversation holding one
picture counts something the reader cannot see; the attachments belonged to
the conversation that ended, and so did any half-written prompt standing
against it.
A drop of several files is one paste, and the terminal writes the paths on
one line separated by spaces — with any space inside a name escaped — or one
per line. Read as a single string none of that is a path, so a multiple
selection dropped on the prompt did nothing at all. The whole text is still
tried first, so a name with an unescaped space in it is still one path, and a
split is only accepted when every piece of it names something that exists.

The esc-undo erased the rows it took back and left ratatui's inline viewport
anchored where it had been, so the composer and the footer went on drawing at
rows that were now blank: the prompt stranded mid-screen, the footer painting
over a row it no longer owned. The rows above moved, so the viewport moves
too — it is placed again at the cursor the erase left behind, which is the
only way an inline viewport moves at all.
A drop escapes the spaces inside a name; a Finder copy does not. So a
screenshot arrives as eight words, and two of them pasted together are
sixteen with nothing marking where one path ends and the next begins — which
is why copying two pictures came out as "[pasted text #1, 210 characters]".
The filesystem is what says where the boundary is now: the longest run of
words from here that names a file, then start again after it. A run that
names nothing ends the attempt, so a sentence mentioning a file is still a
sentence, and a paste of more than sixty-four words is not asked about at
all.

A file:// URL is read as the path it carries, percent-decoding included, and
the empty authority Windows writes before a drive letter is dropped.

And `~` is a home directory on Windows too: HOME or USERPROFILE, rather than
HOME alone, under which `~` expanded to nothing there and named no file.
A copy of four screenshots came out as "[pasted text #1, 210 characters]".
The scan split the paste into words and joined them back together with a
space to ask the filesystem about each candidate — and macOS writes a narrow
no-break space, U+202F, between the time and the AM in every screenshot's
name. That character is whitespace to split_whitespace and is not a space to
the filesystem, so every rebuilt candidate named a file that does not exist
and the whole paste fell through to text.

Word boundaries are byte offsets now and a candidate is a slice of the
original, so whatever separated two words is exactly what is asked about. The
test uses the shape a real copy has: single-quoted, space-separated, U+202F
in every name.

Also adds IO_DEBUG_PASTE, which appends every paste and the paths found in it
to a file. A paste is the one input this product cannot reproduce from the
outside — what lands on the prompt is whatever the terminal made of whatever
the pasteboard held — and this release spent two rounds guessing at it.
The version bump, the sealed record and the last gate's finding.

The live suite caught a real regression the unit suite could not see: moving
io-cli's own sentences to the footer took /plan and /contain with them, and a
mode report is not a notice — it outlives the keystroke that asked for it and
carries the sentence saying how to change it, which does not fit the one row a
notice has. Both go to the record. App::say is for what answers a key and is
gone at the next one; App::record is for what belongs to the conversation.

Gates: cargo test --release, 57 binaries and 664 tests, 0 failures. The live
suite, 24 passed against OpenRouter. clippy and fmt clean. The nightly rustdoc
gate caught a doc comment orphaned onto the wrong function and a link to a
private item — the fifth release running that it has found something stable
cargo doc cannot see. The same eleven dependencies. No secret is tracked.

Measured on the binary that ships: worst key-to-frame 11 ms over the newline
script, 10 ms over an 88,799-character paste expanded in place, 12 ms over
/clear. Against 0.13.0 on the first of those, 5,750 ms and a session that
answered nothing afterwards.
Windows rejects `"` in a filename outright (ERROR_INVALID_NAME, code 123), so
the file this test is about cannot be created there and the rule it checks
cannot be violated there either. The matrix found it, which is what the matrix
is for.
@aakashpawar1999
aakashpawar1999 merged commit 2e2bb08 into main Aug 24, 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