Read agent trajectories: records inside a document, a record that opens into itself, motion that can read it - #14
Merged
Merged
Conversation
The grouping, the two-level row arithmetic and the summary painting were dialect-agnostic but not format-agnostic: they reached records through the line index, so a second record format could not use them. They now live in src/source/record/, over a Records trait that says how many records are indexed, how to reach record i, and whether it opens. jsonl keeps what costs a parse -- a record's own tree -- and implements the trait in lens.rs. ops.rs takes the group operations with it, so a format that implements Records gets zR, zM, Tab, Y-on-a-group, fold round-trip and search-reveal rather than a plan it cannot fold. No behaviour change: rendered frames are byte-identical with and without --lens, and the test count is unmoved.
A lens meant a file of lines. An ATIF trajectory is one JSON document whose records are the elements of its steps array, so a lens now says where its records live: Lines, as every dialect did before, or a named member. The array is a second Store over the existing lazy structural index, so a step is parsed when it is painted and opening the file still costs a stat. Everything above the Store -- grouping, folding, row arithmetic, search, yanking, the outline -- is the seam from the previous commit, unchanged and now shared by both record formats. Nothing in the document is lost: the top-level keys the array does not hold become the session record above the first step, and open into their tree. The atif dialect reads opencode's ATIF-v1.7: a step that says something is a message and carries its tool calls as a count, a step that only works folds into a run with its neighbours.
A lens row was a headline and nothing else, so reading a conversation meant opening every message in turn to see any of it. A summary row for a message is now the message's own first line, and the rest of it follows underneath, wrapped to the width and indented to the same column. It is one wrap split in two, so the opening words are on the screen once. Enter opens the whole message; zt opens the raw record, whatever the message is doing. Mechanics are untouched and stay one line. A message is clipped to six rows until it is opened, and the row that ends a clip says what it is not showing -- a lens may fold, never hide. The price is that a row's height now depends on the width. A resize re-lays the bodies without re-reading a record, and a mark into a lensed document is the record rather than the row, so the cursor comes back to the step it was on rather than to a row number that moved.
A trajectory is a list of blocks -- a message with what it said, or a folded run of mechanics -- and stepping through it a terminal row at a time is the wrong unit: j moved a third of a message. A source can now say it reads in blocks, and only a record file under a lens does. There, j and k move between blocks and the viewport frames the block it lands on: whole if it fits, first row at the top if it does not. Tab is the next message, since j does what Tab used to. Every other format sees a default of false and moves exactly as it did. Block motion is the default unit, never the only one: Ctrl-E and Ctrl-Y scroll a row whatever the blocks are, which is how a message taller than the screen is read. Adding them turned up a decoder hole -- lookup rejected any event carrying a ctrl bit, so kitty's CSI u form of Ctrl-E, and of Ctrl-C, resolved to nothing. A key that is already Key::Ctrl is no longer rejected for saying so twice. The status bar names the block, and says >=N while grouping is still catching up, because that total both grows and shrinks as it does.
Opening a run is the reader asking to see what is inside it, and j then stepped straight over the whole thing: the steps it had just revealed were reachable only by Ctrl-E, one row at a time. A shut run is still one block, because that is what makes it a summary. An open one is its own row and then a block per step, so j walks what opening it put on the screen and k walks back out. The block total grows when a run opens, and the status bar says so. Tab is unchanged and still steps by message: every member of a run is a step, so descending would only ever land it on mechanics.
A record had two states: the headline, and the raw tree. So a reader who wanted to know what a tool was called with had to read JSON, and a message whose body happened to fit had no state to open at all -- the key fell through and the tree appeared unasked. A record now opens into itself first. Enter descends a level a press: the headline, then the record read as a record -- what was said in full, what it was thinking, and its tool calls as calls -- then the raw tree, then back. zt still goes straight to the tree from any level, for when JSON is what you came for. A call is a row, and it opens too: its arguments one per line, and what it returned, clipped like a message with the count it left out. Reasoning is text and now reads as text rather than as the word "thinking". This is a seam change, not an ATIF one: a dialect says what a record's parts are and nothing about rows, and the agent dialect answers as well, so a Claude Code session opens the same way. What a lens may never do is unchanged -- every argument, every result and every byte is still reachable, and every clip says what it hid.
A closed block is exactly one row, so where a document is collapsed "next block" and "next row" are the same motion. They part only where something is open -- and there the difference is precisely the rows the reader just asked to see. One j over an opened record leapt thirty rows of it. So j and k move one visible row, in every format, and Tab and S-Tab are the jump, framing the block they land on. Anything that can be opened can now be read with the same key that got you there. Ctrl-E and Ctrl-Y go: they existed only to escape block motion. Tab jumps by block rather than by message. A message is a block, so nothing a message jump reached is lost, and the mechanics a trajectory is mostly made of stop being reachable only a row at a time.
A folded row says how much it is not showing, and that count was appended after the text: a headline that filled the width pushed its (49 lines) past the right edge, where only a sideways scroll would find it -- on the row whose whole job is to say there is more. The count is the half that must survive, so the text gives way to it. It is cut to leave room and marked, and the line itself is untouched, so scrolling right still reads the rest of it exactly as before.
Enter walked three rungs -- the clip, the record open, then its JSON -- so getting from the bytes back to the reading meant a lap of the whole ladder, and a key that was meant for reading kept ending up in a tree. Enter is now the two levels a reader moves between, and r is the record's own bytes from either of them. Neither undoes the other: with a tree open, Enter toggles the rows underneath it and leaves the tree where it is, and r is its own way back. zt is gone; one key per job. A record row answers for Enter whether or not it has anywhere to go, so the key can no longer fall through to the outline and open the very tree this commit took off the ladder -- which is what it did on a record whose message already fitted and that made no calls, half the records in a real trajectory.
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.
--lenscould read a file of lines. An ATIF agent trajectory is one JSONdocument whose records are the elements of its
stepsarray, and each step isa whole turn: a message, a thought, several tool calls and their results at
once. Reading one meant the generic tree, where half of what you see is
machine output.
The commits
2d133e2record: lift the lens seam above any one record format.Grouping, row arithmetic, folding, search and painting stop belonging to
.jsonland sit behind a trait. Byte-identical output.08adbd5lens: read an agent trajectory whose records live inside adocument. A lens says where its records are; the array is a second store
over the existing lazy index, so a step is parsed when it is painted. The
document's other top-level keys become the session record rather than being
dropped.
4f21dd0lens: put what was said under the row that says who said it.The summary row is the message's own first line and the rest follows under
it, clipped with a truthful remainder.
38284efpager: move by block where a document reads in blocks.abe52fbrecord: let a block boundary descend into a run that is open.f244400lens: open a record into what it did, not into its JSON.A record opens into itself first: what was said in full, what it was
thinking, and its tool calls as calls. A call is a row and opens too, into
its arguments and what it returned.
ztstill goes straight to the tree.A seam change, not an ATIF one -- the
agentdialect answers as well, so aClaude Code session opens the same way.
6c1e757pager: move the cursor by row and put the jump on Tab.Reading real trajectories showed the block cursor was wrong: one
jover anopened record leapt thirty rows of it. A closed block is exactly one row, so
block motion differs from row motion only where it skips what the reader
just opened.
j/kmove a row;Tab/S-Tabare the jump.The last two commits are what the first attempt at this got wrong, found by
reading real trajectories rather than by any test.
Verification
clippy --all-targets -D warningsclean; all five cross-targets check;musl static build;
tools/soak_pty.pyclean.against 1.8 ms on 55 KB.
characters), the check that caught text painted nowhere on a wide terminal.
jalone from the row above it, pinnedmechanically rather than by inspection.
Known, and not fixed here
tools/soak_json.shandtools/soak_csv.sheach report one failure. Bothpredate this branch -- verified by building master -- and are the scripts
asserting that a directory is an error, which the directory reader made
false.
index (~630 B against ~315 B).
· 2 tool callsclause: that column is themessage itself, and the calls are now rows under it.
--tocstill printsthe clause.
jpresses.Tabclears it in one.