Repository navigation
io-cli 0.3.0 - #15
Merged
Merged
io-cli 0.3.0#15
Conversation
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
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.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 onCtrl+T,/expand, and/copyover OSC 52.One new dependency, the first since 0.1.0:
syntectwithdefault-features = falseand 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.