io-cli 0.13.1 - #38
Merged
Merged
Conversation
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.
io-cli 0.13.1
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
[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 blockback 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.
/attachtakes the path you actually have. Three things were wrong with it atonce. 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.pngnow works. A path inside theworkspace 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-textareascrolls sidewaysrather 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+BackspaceandCtrl+Wused 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.
/attachis gone; drag a picture onto the prompt or paste it. A command wassomething 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:
/attachis answered the way any other word thatis not a command is —
there is no /attach. The commands are:— and the listunder it is the truth.
A marker deletes with the space written for it, so one press removes one
thing under every backspace —
Option+Backspaceused to eat1]off the end and