Skip to content

io-cli 0.3.0 - #15

Merged
aakashpawar1999 merged 9 commits into
mainfrom
develop
Aug 17, 2026
Merged

aakashpawar1999 merged 9 commits into
mainfrom
develop

Conversation

@aakashpawar1999

Copy link
Copy Markdown
Contributor

0.3.0 into main. Everything in this range was reviewed and merged through #14; this is the release merge.

The release: the operator can read what the agent did to their files without leaving the terminal. Diffs come from Store::edits — io-harness already renders and stores one for every edit, so io-cli computes none and the @@ numbers on screen are the file's own. Word-level emphasis, syntax highlighting in io-cli's own theme tokens, tool cells with a result and an honestly-labelled duration, the transcript on Ctrl+T, /expand, and /copy over OSC 52.

One new dependency, the first since 0.1.0: syntect with default-features = false and exactly ["default-syntaxes", "regex-fancy"], which keeps oniguruma — a C library — out of the musl and MSVC cross-compiles. Asserted by test. +810,395 bytes compressed, measured.

Verification: 189 passing / 0 failing / 9 ignored, sabotage arms per criterion, an adversarial review that found one real bug, and a live run against a real provider that found two more — including an approval diff that would otherwise have shipped dead.

The record is sealed and pinned at sha256:c9190620d61c600fd920cc5e404bbf2b13886bbe3b88eda9d4895cd27329776d.

io-harness renders a unified diff for every edit its tools make and keeps
it in the run's durable trace, so io-cli reads one rather than computing
one. `Edit.hunk` carries the file's own `@@` line numbers, which is the
fact a diff computed here from two strings could not produce — io-cli
never sees the file.

The read is anchored on `Step` and never on `ToolCall`: the harness
documents `ToolCall` as emitted before the tool's result is known, while
`Step` lands after the step is committed to the store. The two events are
one line apart in a transcript, which is what would make a read of a row
that does not exist yet invisible until it was a bug.

`Store::edits` answers for the whole run, so `diff::for_step` filters;
drawing what it returns would re-render every earlier edit at every later
step. An absent hunk keeps its counts and says `no diff stored` — `None`
means the row predates hunks, the previous contents were not kept, or the
diff exceeded the snapshot cap, and not one of those is "nothing changed".

Tone::Added and Tone::Removed fill the diff_add and diff_delete tokens the
theme has carried since 0.1.0. Neither takes a word: the `+` and the `-`
the harness already put on the line are what survives NO_COLOR.
A run of removals pairs with the run of additions immediately after it,
and only when the two runs are the same length. Anything else draws
without emphasis.

The timidity is the point. io-harness's own diff module records that an
edit_file is one contiguous replacement by construction, but a write_file
that rewrote two distant regions arrives as ONE hunk spanning both. A
greedy rule would then emphasise the difference between lines that have
nothing to do with each other, which is worse than no emphasis: it invents
a relationship and paints it.

Bold rather than a background colour. A background paints the full cell
width of every character it covers, which survives being copied out of the
terminal as trailing whitespace. Theme::emphasis returns Style::default()
under NO_COLOR along with everything else, because a modifier is still a
presentation-only channel and the marker is what carries the meaning there.

Falls back to a plain line when the two sides share neither head nor tail:
emphasising all of a line is the same as emphasising none of it.
The ninth direct dependency, and the first added since 0.1.0. The feature
set is the load-bearing part rather than the version:
default-features = false drops regex-onig, which is oniguruma — a C
library that would put a native build step in the path of the musl and
MSVC cross-compiles that produce two of the four release artifacts, and
would fail inside the release workflow rather than in a test.
default-themes is off because the colours are io-cli's own, which is what
keeps a highlighted diff and the rest of the interface one aesthetic and
NO_COLOR deciding from one place. tests/dependencies.rs asserts the
feature list, not just the name.

Green still means added. The obvious combinations both lose something:
washing the changed line leaves no colour for the code, syntax colouring
it leaves none for the change. Instead the marker keeps the diff colour,
the parts both sides share are syntax coloured, and the words that
actually changed are drawn in the diff colour and emphasised — so the
add/remove colour now points at the exact words instead of washing the
line. A line with no partner takes the plain wash and no emphasis:
bolding every word says "look here" about all of them.

The scope table needs `storage` as well as `keyword`. Sublime's
vocabulary files let, const, fn, struct and pub under storage.type and
storage.modifier, leaving keyword for control flow and operators, so a
table without it highlights `=` and misses every declaration on the line.
Found by a test rather than by reading the vocabulary.

Three theme tokens, nine to twelve. A comment did not get a fourth: a
comment is muted and muted already exists, and a token whose value would
always equal another's can drift out of agreement with itself.

The grammar table loads behind a OnceLock reached only from the diff
renderer, so a session that never edits a file never pays for it. N4
asserts that structurally rather than by timing anything.
Two modules, not yet reachable from a key — the bindings and the slash
commands follow. Registered now so their tests run with the suite rather
than sitting outside it.

clipboard: OSC 52, because the product is used over SSH and a local
clipboard call would set the clipboard on the wrong machine. The base64
is twenty lines rather than a tenth dependency. There is deliberately no
success type: no terminal answers an OSC 52 write, several cap the
payload silently, and tmux ignores it entirely without set-clipboard on,
so `describe` returns a sentence stating what was sent and how large it
was. A call site has nothing it could render as "copied". Encoding is
also what makes the payload safe rather than merely compact — an
unencoded ESC or BEL in transcript text would terminate the OSC string
early and dump the remainder on screen.

transcript: renders a whole conversation as scrollback lines, never
viewport ones. Turns a branch left behind are rendered AND labelled:
TranscriptTurn.on_path is the only place that fact is reachable and
Session::history deliberately does not return them, so dropping them
silently is the defect the criterion exists to catch.

Both Transcript and TranscriptTurn are #[non_exhaustive], so a test
cannot build one. tests/transcript.rs therefore drives three real turns
through a scripted provider into an in-memory store and branches the
middle one away, which is how the branched-away case is proved rather
than described.
`cell` takes the terminal's width and drops word-level emphasis to the
line below EMPHASIS_FLOOR. Nothing is truncated — a committed line wraps,
which the renderer already guarantees. What is lost at eighty columns is
findability: a bolded fragment in the middle of a line that now occupies
three rows is harder to locate than a whole row that is red. So the
emphasis becomes the line, which is the same information at a scale the
terminal can still show.

A hundred rather than eighty because the floor is about the wrap and not
about the supported size: eighty columns is supported, and ninety wraps
just as badly.

The header's facts are asserted present at eighty columns with a path
longer than the terminal. 0.2.0 paid for that lesson once already — a row
can "fit" precisely because ratatui clipped the half of the sentence the
release existed to show.
TOOL CELLS. A tool call is one line — the tool, its target, what came
back and how long it took — live in the viewport while it runs and
committed when the step lands. Nothing is committed at announce time,
because EventKind::ToolCall is emitted before the tool's result is known.

The pending store is a Vec and never an Option: read_batch announces
every call of a parallel read up front before running any of them, so two
calls before one step is the ordinary case, not a hypothetical. Refused
does NOT close a cell — the harness feeds the refusal back to the model as
an observation and the step still commits — so it marks the step instead,
and it survives arriving with no open cell at all, which it does for a
dial or an MCP spawn at step 0. Refused.act and .target are never matched
against a call: act is a policy act and target is the resolved path, while
ToolCall.target is the raw argument the model wrote.

The result is the harness's own decision segment when the segments pair
one-to-one with the announced calls, and the step's verdict otherwise —
the workspace loop pushes extra segments for spawned children, so the
alignment is checked rather than assumed. The duration is io-cli's
OBSERVATION of the interval between two events and is printed with a `~`
to say so; an Mcp event's millis is the one harness-measured tool duration
in the enum and prints without one. An interrupted call reads unfinished
and carries no duration at all, because io-cli does not know one.

flush() and flush_text() are split, and the split is load-bearing: the
mid-step arms must commit the assistant tail WITHOUT closing a live call,
or a refusal one line before a step reports the opposite of what happened.

THE APPROVAL DIFF. product.yaml's approval requirement ends "and, for a
write, the diff", and 0.2.0 shipped that clause unmet. Edit::with_hunk is
the harness's own renderer, so io-cli still computes no diff; what it
supplies is the old side, read from disk, because the write has not
happened yet. Absolute targets only — `io -C <dir>` sets the workspace
root without changing this process's working directory, so a relative
path could name a different file that happens to exist here and the
overlay would show a confident diff against something the agent never
touched. A file that is not there yet reads as all addition; one that
cannot be read falls back to showing the content plainly.

At the tightest size the one row available goes to `+3 -1` rather than to
the first line of the change, because the size of a write is the decision.

MINIMAL. `diff = "minimal"` in [app.io-cli] drops the context and keeps
the changed lines and the @@ header — a change with no line numbers does
not say where it is, which is not a smaller diff but a worse one. Absent
means unified, so no existing configuration file needs touching.

FIXES FROM REVIEW. The tail drain rendered its events but never their
diffs, so a Step still queued when the turn's future won the select lost
its diff silently — and the last step of a turn is exactly the one that
loses that race. A hunk is now capped at MAX_BODY_LINES: highlighting is a
parse per line on the same loop that delivers keystrokes, and
Screen::commit hands insert_before a u16 height that silently clamps, so a
26,000-line hunk made Ctrl+C unreachable and then truncated. And a
`\ No newline at end of file` marker no longer ends a run of removals,
which had been silently costing word-level emphasis on exactly the lines
where a file's last line changed.
…eyboard

Ctrl+T commits the whole conversation into the terminal's own scrollback,
branched-away turns included and labelled — TranscriptTurn.on_path is the
only place that fact is reachable and Session::history deliberately does
not return them.

/expand commits the last step's stored detail upward, read back out of
the run's durable trace. That is the other half of collapsing a tool
cell: the output went to the trace when it happened, so the screen never
had to be the archive.

/copy and /copy diff put the last answer or Store::patch of the whole run
on the system clipboard over OSC 52 — the clipboard of the machine the
operator is sitting at, not the one they are ssh'd into. Screen::escape
writes the sequence straight to the terminal rather than through commit,
because a clipboard sequence in the scrollback is text somebody scrolls
past rather than an instruction the terminal acted on. The line printed
afterwards says what was sent and how large it was and never that it
succeeded: nothing acknowledges an OSC 52 write, so a success message
would be a claim this product cannot support.

Everything that shows more of something commits upward. The viewport is
four rows and cannot grow and there is no alternate screen, so that is one
answer to "show me more" rather than three.

The command and key inventory tests were restated rather than relaxed:
both spell out the full list, so adding a command stays a decision
somebody makes rather than a line somebody adds.
F12 replaced the human gate the first three releases each ended at, and
it earned its keep on the first run.

THE APPROVAL DIFF WOULD HAVE SHIPPED DEAD. Request.target arrives
RELATIVE to the workspace — `notes.txt`, not `/tmp/x/notes.txt` — so the
absolute-only guard never found the file and quietly fell back to showing
the proposed content. Every hand-written test passed, because every
fixture used an absolute tempdir path. It now resolves against the
session's own root, which is not the process's working directory: `io -C
<dir>` sets one without changing the other, and a relative name resolved
against the wrong one could diff a file the agent never touched.

THE OVERLAY CLIPPED THE FACT IT EXISTS TO SHOW. Repeating the path on the
diff row meant that at eighty columns with a long path, ratatui cut the
row — silently, as it always does — and what went was `+2 -0`. That is
0.2.0's lesson arriving a second time in the same overlay. The row now
leads with the counts and omits the path, which the question row above
already carries, and every line is fitted rather than left to be clipped.

Two more the live output showed, both readability rather than
correctness. A step line printed its tool call as raw escaped JSON
directly under a cell that had just said the same thing legibly; the
names are kept and the arguments dropped, and 0.1.1's F5 still reads as
decision, then what it ran, then what came back. And a tool with no path
argument arrived as `git_diff git_diff`, because io-harness falls the
target back to the tool's own name.

tests/surfaces.rs covers the three surfaces that had no test of their
own: a commit leaves the viewport at its fixed height while the content
reaches the terminal, a hunk past MAX_BODY_LINES says how many lines it
cut, Ctrl+T leaves the composer holding what was typed, the transcript
labels a branched-away turn, and the OSC 52 bytes that leave the process
decode back to the payload while the message never claims success.
io-cli 0.3.0 — diffs, tool cells, transcript and clipboard
@aakashpawar1999
aakashpawar1999 merged commit e20a628 into main Aug 17, 2026
15 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