Skip to content

Record each shell command, and wait on output that arrived after you asked - #291

Closed
pcarrier wants to merge 37 commits into
mainfrom
term-journal
Closed

pcarrier wants to merge 37 commits into
mainfrom
term-journal

Conversation

@pcarrier

@pcarrier pcarrier commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Why

A long-lived shell PTY has no command boundary. An agent that types into one today dumps scrollback, sleeps, or matches a regex against the whole grid — including text that was already on screen. blit terminal wait --pattern documented “lines produced after the wait began” and then re-scanned get_all_text() on every update.

What

OSC 133 (FinalTerm) and OSC 633 (VS Code) already say where each command starts, where its output starts, and how it ended. The server turns that into a bounded journal per PTY and addresses output by a monotonic sequence (rotated_lines + row), not a grid row that moves under the reader.

  • Wire: FEATURE_TERM_JOURNAL (bit 28), opcodes 0x500x53. BLIT_TERM_JOURNAL=0 withholds the bit and refuses with PERMISSION.
  • CLI: blit terminal journal, blit terminal output [--wait], history --since CURSOR. Defaults are bounded. --json on all three.
  • wait --pattern: probes the sequence cursor first, then matches only TERM_SINCE text. Falls back to the old grid scan against a server without the bit.
  • Shell integration is opt-in. blit does not inject rc files (that fights starship / oh-my-zsh / kitty). Hooks are in docs/shell-integration.md. A terminal whose shell emits nothing keeps an empty journal at no cost.

RFC: docs/design/term-journal.md.

Note on the branch

This branch currently sits on unpublished local main (extensions, FS_READ, client origin, … — 33 commits ahead of origin/main). The journal work is the tip commit. Rebase onto origin/main hit conflicts in those files; happy to restack once those land, or to isolate the tip if you’d rather this PR not carry them.

Test plan

  • cargo test -p blit-alacritty -p blit-remote -p blit-server -p blit-cli (699 passed)
  • blit terminal start --cols 200, enable the OSC 133 hook, send 'echo hi\n', then journal / output --wait
  • history --since now, produce output, history --since <cursor> returns only the new text
  • wait --pattern against text already on screen does not return immediately
  • BLIT_TERM_JOURNAL=0 withholds the feature bit

Made with Cursor

pcarrier and others added 30 commits August 18, 2026 08:40
The camera support probe encoded nothing, so it never produced an AV1 OBU
to judge; give it a canvas it can actually encode, and hold the live stream
to the same rule the probe applies. On the output side, stop re-routing the
speaker at a sink it is already playing through, and size the jitter buffer
for that sink rather than for the link alone. MPRIS players gain a position
bar with a draggable handle.
A new git request family reports branches and worktrees with the same
paging and cancellation discipline as the existing reads, plus the client
bindings to call it. The opcode range gate now ends at the family's last
opcode, so a new one is not dropped before a handler sees it.
A native child is spawned onto a Wayland socket bound for it alone, and the
compositor stamps every surface arriving there with that application's
identity — self-asserted app_id stays separate from the stamped one. A
client can ask which session it is attached to, and read the environment a
session was given, both refusable by policy (BLIT_ENV=0) rather than by
looking like an old server.
Extensions move out of the SDK's examples directory into their own cargo
workspace that hashes the same wherever the tree lives, built as a declared
task so CI gets its tools, and published in the release where a digest pin
can find them. `ext run` takes a URL whose fragment pins the digest it is
expected to carry, and resolves a transient extension by the name `ext list`
prints. The dev stack serves its own extensions to its own UI.
The systemd extension watches units from inside the server and publishes
what it sees over a channel, paging the journal from that same channel and
following it live, queueing what a slow reader cannot take yet. It survives
the PATH a systemd unit actually has, and says so out loud when it cannot.
The session extension reads desktop entries, starts what a session asks
for, and supervises it: process output is acknowledged, children are
adopted rather than respawned, and what exited cleanly stays exited. An
application can be started without being adopted, and discarded rather than
merely disabled.
A remote gets a Control panel instead of an expanding row, with its
extensions, systemd units and session applications as panels under the
remote itself, and its applications shown next to the clients watching it.
Units filter the way logs do. The browser can read a channel, which is what
the systemd and extension views are built on.
A branches panel in the dock, keyboard-reachable and hash-addressable, that
lets the dock finish switching roots before it redraws.
The Extensions panel derived the registry from the UI's port, three up. A
dev page served over a tunnel has no port to offset from, and the registry
binds loopback and is published nowhere, so those sessions silently asked
install.blit.sh instead. The dev server now proxies /ext to its own
instance's registry, which keeps a second stack's page on that stack's
modules and needs no CORS.

Co-Authored-By: Claude <noreply@anthropic.com>
The journal pane was one 200-line page with Older and Newer buttons: nothing
arrived on its own, and reading either direction meant pressing something. The
watcher has served a live `journalctl --follow` since it was written -- resumed
from a cursor, batched, cancellable -- and the browser simply never opened it.

It does now, from the cursor the loaded page ends on, so the join has neither a
gap nor a repeat. Scrolling to the top pages history in; scrolling to the bottom
pages forward only when nothing is tailing, since a tail already owns that edge.
Autoscroll follows only a reader already at the bottom, so scrolling back to
read does not fight the entries still arriving.

Routing the tail is the part with a trap: a follow batch is shaped like a page
and carries `last` on every one of them, so without taking it before the page
router it would settle a query that asked nothing. The buttons are gone, and so
is the line count -- the only number this side could offer was how many rows
happened to be buffered.

Co-Authored-By: Claude <noreply@anthropic.com>
"Control" named the button, its dialog and its subtitle. Manage says the same
thing about a panel whose tabs install extensions, start applications and stop
units, and it does not read as the verb for driving a machine remotely.

The spec that opens it also asserted the extension registry as the page's port
plus three, which stopped being true when the registry moved behind the dev
server's /ext proxy. It now asserts the registry this page can actually reach --
the origin's /ext under `vite dev`, the published one from the gateway's
production bundle -- because both harnesses run this file, and only one of them
ever has.

Co-Authored-By: Claude <noreply@anthropic.com>
A modifier reaches a Wayland app only as its own key press. Nothing else
in the path carries the state: the browser sends evdev keycodes with no
modifier field, the compositor sends `wl_keyboard.modifiers` solely as a
side effect of a modifier key transition, and it sends it only to
whoever holds focus at that moment. So Ctrl going down while a terminal
pane has focus is a fact the surface focused next can never learn, and
the Ctrl+K aimed at it arrives as a bare k.

Both halves were missing.

The compositor now restates `mods_depressed`/`mods_locked` right after
`wl_keyboard.enter`, the way it already replays a missed `wl_pointer.enter`
at bind time. That covers focus moving between clients, the popup grab
paths, and the scale-change leave/enter -- all of which used to drop the
modifier state on the floor.

The canvas now replays, ahead of the key it qualifies, any modifier the
browser reports held that it never forwarded a press for. A replay has
to pick a physical side (left, as the synthesised chords do) because the
browser's flags do not say; the matching release then arrives on
whichever side was really down, so `handleKey` redirects a modifier
key-up onto the twin it is holding rather than dropping it as an orphan.
The event's own key is never replayed, and the paste chord's deferred
Ctrl and macOS's held-back Alt keep ownership of their keys.

`crates/compositor/tests/keyboard_modifiers.rs` drives a real
wayland-client through it; four of its five cases fail without the
compositor change. The paste tests take a shortcut -- they synthesise
Ctrl+V with no physical Ctrl key-down -- so they now see the replay
supply it.

Co-Authored-By: Claude <noreply@anthropic.com>
Ctrl+click and Shift+click open a link in a new tab or window, and the
app reads that modifier from its key press alone. But clicking is also
how a surface takes focus, so on the click that focuses one the modifier
is always a key this canvas never saw go down -- and the app sees a plain
click.

`sendPointerAt` already focuses the keyboard target on pointer-down,
ahead of the button, and already flushes a pending Alt there for exactly
this reason. Do the same for the modifiers the browser reports held: the
focus leads (an unfocused client is told no modifiers at all), then the
modifier, then the button. `syncModifiers` takes the four flags both
event kinds carry; only a key event exempts its own key from the replay.

Co-Authored-By: Claude <noreply@anthropic.com>
The panel listed applications as names, which is the one thing a desktop
launcher never does: a Steam library of a hundred and thirty entries is
a wall of identical rows, and the row you want is recognised by its
artwork long before its name is read.

Icons are asked for rather than pushed. The catalog is a thousand names
in a few tens of kilobytes; the same thousand icons are tens of
megabytes, so the panel requests the dozen rows it is drawing and the
extension answers one message per id -- with the `icon` field omitted
when there is none, because a silent omission would be
indistinguishable from a reply still in flight.

`icon.rs` resolves an `Icon=` value the cheap way: the best-sized file
of that name anywhere on the icon path, rather than the spec's
theme-inheritance search, which would mean parsing every `index.theme`
before the first icon appeared. Two shell round trips per batch, one
stat sweep and one base64, ranked scalable-first and then the smallest
raster at or above 128px.

The request separator is a newline, not a space: a desktop-entry id is
a filename, and Steam alone installs hundreds with spaces in them.

Where there is no artwork -- no `Icon=`, or a name no installed theme
has -- the row keeps a monogram tile of the same size, so the list has
a left column either way and does not reflow as the icons arrive.

Co-Authored-By: Claude <noreply@anthropic.com>
The list of installable applications was hidden behind the filter box:
with nothing typed it said "132 installed — type to search", which asks
a viewer to name what they want before being shown that it exists. A
launcher shows its shelf.

So the list is always the whole catalog, in a scroller of its own —
its own rather than the overlay's, because it is the only unbounded
thing on the panel and letting it lengthen the dialog would scroll the
search box, the one control for a nine-hundred-row list, off the top.
The filter now narrows a list that is already there, and the twelve-row
cap and its "N more — narrow the search" footer are gone with it.

Artwork could not follow it. Nine hundred rows is tens of megabytes of
icons to draw a dozen tiles, so a catalog row asks for its own only
once it is within a screen of the viewport, and the observer's batching
is what makes a scroll one request instead of one per row. The managed
set is small and always visible, so it still asks outright.

The observer is rooted at the viewport rather than at the list's
scroller, which would read better and cannot be done: a child's `ref`
runs before its parent's, so the rows would find no observer and each
ask outright — the storm this exists to prevent. It is the correct root
regardless, since an intersection is clipped by every scrolling
ancestor on the way up.

Co-Authored-By: Claude <noreply@anthropic.com>
Three faults, one symptom: icons appeared at the top of the list and
nowhere below it.

The lookahead was not one. `rootMargin` grows the *root's* rectangle
and nothing else, and the observer was rooted at the viewport while the
list clips to its own overflow — so a margin of three hundred pixels
bought exactly zero rows of warning, and every batch was requested only
once its rows were already on screen. Rooting it at the list is what
the margin needed, and the reason it was not: a child's `ref` runs
before its parent's, so the rows found no observer. Deferring the
attachment to `onMount` settles that, and the lookahead is now fifteen
hundred pixels — several screens, because a round trip costs a child
process whatever it asks for.

Each of those round trips was two children, not one. Searching and
reading are now a single pass: walking the ranked directories in order
means the first file that exists is the one the ranking would have
picked, so there is nothing to report back and ask about. Expanding the
icon path's globs is the half that does not depend on the question, so
it happens once and is cached. And resolving no longer refreshes the
catalog first — the ids came out of the catalog the panel already
holds, so that read could only ever add a random half-second stall in
the middle of a scroll.

What did arrive could still be lost. The supervisor drops icon replies
past a queue bound, and the panel counted every id it had ever asked
about as answered, so a dropped reply left that row a placeholder for
the life of the channel. Requests now expire, rows stay observed rather
than being released once asked, and the batch is large enough that an
ordinary scroll never reaches the bound.

Measured on a 211-entry catalog, scrolling a screen at a time: before,
0 to 2 of the 12 rows on screen had artwork and it took two seconds to
settle after stopping. Now it is 11 or 12 throughout, with 54 icons
already in hand before the first scroll.

Co-Authored-By: Claude <noreply@anthropic.com>
A remote's Session and systemd tabs exist only while their extension is
serving, and that was sampled once per expansion by connecting to the
channel and hanging up. A photograph cannot answer the question the panel
asks: install an extension and the tab it should add never appeared,
remove one and its tab outlived it until the row was collapsed and
reopened — with the panel that installs extensions one tab away.

So the registry is now followable. CHANNEL_WATCH declares up to 32 names
on an unused client-created channel ID and is answered at once with
NAMES, the declared names which have a listener; NAMES repeats whenever
that answer changes, and UNWATCH releases the ID. A watch names what it
follows rather than asking for the whole registry, so a watcher's traffic
is bounded by its own request and cannot scale with churn it never asked
about — every extension attempt mints and drops a command listener — and
republication is suppressed when the answer is byte-identical.

The bit is its own (26) rather than part of FEATURE_CHANNEL: a WATCH an
older server does not understand is dropped by the family's unknown-kind
skip rule, which reads exactly like a name nobody serves. A client that
cannot see the bit keeps probing, which is all such a server can offer.

Every name claim and release already funnels through two entry points, so
that is where watches are answered from — including watches held by
endpoints the requester has never heard of. A name is released when the
owning endpoint closes, which for an extension is a beat after the
control call that disabled it returns, so the tab goes away with the
teardown rather than with the request.

Applications is now Session, and the strip is ordered so that the two
tabs every server has come first and the two an extension provides
follow: the set grows and shrinks at the end of the row instead of
shuffling what the viewer was aiming at.

Co-Authored-By: Claude <noreply@anthropic.com>
The switcher could reach every terminal, pane, surface, layout, file and
symbol, but not the applications the machine actually has. Starting one
meant opening a remote's Manage panel and finding it in a list. It is
now a section of its own, filtered as you type, and choosing one runs it
on the spot.

Start, not enable: this is trying an application, not adopting it for
every future session. It appears in the Manage panel afterwards as a
running row that is not enabled, which is where it can be kept or
discarded. Applications already running are left out — an entry that
would restart something already up is not what is being offered.

Every connected server contributes, each row subtitled with the server
it belongs to when there is more than one, so two machines' Firefox stay
distinguishable.

The catalog has to be resident for that to work. A channel opened when
Cmd-K opens answers a second late, which is a second after the viewer
typed the name of something the list did not yet know. So the catalog
store holds one supervisor channel per connected server for the life of
the page, armed like the roots watch and re-armed on the connection
generation, since a channel does not survive a re-establish.

The Manage panel now shares that channel rather than opening a second
one. Two mirrors of one supervisor also meant two icon caches filling
with the same artwork.

Rows carry their icons, fetched as they scroll into view — the same
lookahead the Manage panel uses, now shared, along with the two things
about it that are easy to get wrong: the observer must be rooted at the
scrolling element, because rootMargin does not reach past a clipping
ancestor, and rows must register on mount, because a child's ref runs
before its parent's.

Measured on a 212-entry catalog: Cmd-K opens in 72 ms with every
application listed, and starting one from it reaches the supervisor as
`enabled no, phase starting`.

Co-Authored-By: Claude <noreply@anthropic.com>
`CHANNEL_WATCH` added a `ChannelMessage::Names`, which left the systemd
extension's match non-exhaustive: the whole extensions workspace stopped
compiling, so `bin/extensions` produced nothing and no server could be
given a new module. Both `Names` and `Opened` answer something a client
asked, and this extension only ever serves channels — named rather than
wildcarded, so the next server-side message added is a compile error
here too, which is how this one was found.

The panel's watcher subscription was registered with `onCleanup` inside
a `then`. That runs with no reactive owner, so the cleanup was never
called — Solid says exactly that on the console — and every open of the
panel left a subscription behind for the life of the page. Hoisted into
the effect's own cleanup, as the applications panel now does.

Also the two markdown files `bin/lint` reformats on sight, so a run of
it stops reporting a dirty tree.

Co-Authored-By: Claude <noreply@anthropic.com>
Scrolling the applications list left a gap: a run of two dozen rows with no
artwork, ending as abruptly as it began, and stable across scrolls and redraws.
Measured against a real catalog, 212 entries came back 181 with artwork and 31
without — 28 of them consecutive, Portal Reloaded through Spelunky 2.

The icon cache is dropped wholesale past MAX_CACHED_ICON_BYTES, and at
MAX_ICON_BYTES per file that limit lands *part way through one batch*: a request
for 48 Steam icons is more than the whole budget. `resolve_icons` filled the
cache and then read its answers back out of it, so everything the batch had
resolved before the drop came back "no artwork" — which the panel records as
final and never asks about again. Hence a gap exactly one cache-worth long, and
a perfectly good icon on the row after it.

So a batch now holds its own resolutions and answers from those. The cache
stays a cache: losing it costs a re-read, not a wrong answer. Two related
distinctions come with it — a name whose lookup never ran is left unanswered
rather than reported empty, because "ask me later" and "there is nothing to
draw" are different things to a panel that treats the second as final; and the
budget now holds one whole request, so a single scroll cannot clear the cache it
is still filling.

The same catalog now answers 209 of 212. The remaining three are 600 KB source
PNGs — Steam writes full-size artwork into every size bucket, including
16x16 — which the read cap skips on purpose.

Co-Authored-By: Claude <noreply@anthropic.com>
"Live" said only that the ordinary thing was happening: a journal pane tails by
definition, so the badge was there for every reader of every pane and told none
of them anything. The news is when it stops — which the same span already says,
and now says alone.

Nothing else moves. `data-journal-live` stays on the span, because it is how a
test tells a tailing pane from a stopped one now that the pane itself is quiet,
and the two stopped states keep their wording. `systemd.logsLive` has no callers
left; it is still in i18n.ts because that file has other edits in flight.

Co-Authored-By: Claude <noreply@anthropic.com>
`start` set the phase and nothing else. `attempt_due` then refused to
launch anything that was not `enabled`, so the application sat in
`Phase::Idle` — which every panel renders as "starting" — for the rest
of the session, and no process was ever spawned. The verb has never
worked on an application that was not already adopted; it only ever
restarted an enabled one that had stopped. Starting Legcord from Cmd-K,
or from `@session start`, did exactly nothing and said it was starting.

The two verbs differ in one bit, and there was only one bit. `enabled`
answers "should this run in every session from now on" and is
persisted; `transient` answers "run it once, now" and lasts until the
process exits. The supervisor launches either.

Cleared by the exit, so a one-off run is not retried the way an enabled
one is — trying an application is not asking for it to be kept alive —
and by `stop`, or the next reconcile would restart what was just
called off. The two are independent: a transient start on something
already enabled leaves its restart loop alone.

Verified on a scratch server: `@session start legcord` and the same
from Cmd-K both reach `phase running` with a stamped socket and one
window. Firefox still exits 255 on this machine, but that is its own
NSS mismatch — `firefox --version` fails the same way with no blit in
the picture.

Co-Authored-By: Claude <noreply@anthropic.com>
`systemd.logsLive` lost its only caller when the pane stopped announcing that
it was tailing. No other locale ever translated it, so this is the whole key.

Co-Authored-By: Claude <noreply@anthropic.com>
Steam writes one full-size PNG into every size bucket, so those names resolve to
five candidates and the read cap skips all five — a row with a letter tile and
no bug behind it. Worth writing down next to the cap, along with why raising it
is not the fix: base64 and one channel message put the ceiling at 640 KiB, which
would cover Steam's files and nothing else, at nearly 900 KB on the wire apiece.

Co-Authored-By: Claude <noreply@anthropic.com>
`PROCESS_STARTED` carries a status, and a non-OK one was dropped on the
floor. But that message is the only word a refused spawn ever gets —
there is no child, so no exit is coming either — and the application had
already been moved to `Running` optimistically when the request was
sent. It stayed there for the rest of the session, listed as running
with nothing behind it, and `attempt_due` never reconsiders a phase it
believes.

An entry whose `Exec` names a binary that is not installed is the
ordinary way to reach this, and it is not rare: a desktop file outlives
the package that put it there.

Counted as a failed run now, exactly as `reconcile` already treats a
launch it could not even send — an enabled application backs off and
retries, a one-off run stops. Verified both: a missing binary reports
`stopped, last-exit -1` instead of `running`, and enabling it produces
a growing backoff rather than a phantom.

Also the line `bin/lint` reformats in SystemdLogs on sight.

Co-Authored-By: Claude <noreply@anthropic.com>
Both examples asserted that the frame after `CREATE` was `CREATED`. It
is not: the server pushes state whenever it has some, and a compositor
with a cursor on it sends `SURFACE_CURSOR` unprompted, so both failed
with `got 0x29` — five times out of five here, not intermittently. That
is the whole test suite red at its last two steps, and everything after
them unrun.

Both already skipped unsolicited traffic while waiting for `READY`;
they now do the same while waiting for a reply, bounded so a genuinely
wrong opcode fails rather than hangs.

Worth keeping as examples precisely because a reader copies this: a
client that assumes replies arrive in isolation works until the machine
it runs on has a window on it.

Co-Authored-By: Claude <noreply@anthropic.com>
This test has been failing CI on main as `left: 5, right: 0`, and does
not reproduce here — 23 runs, alone and alongside its module. Five is
`EXT_EXIT_PROTOCOL_VIOLATION`, which the host reaches two ways: the
logical connection closing before the guest returned, or an ABI misuse
out of the wasmi host. The bare comparison discarded the one field that
distinguishes them.

The detail rides the assertion now, so the next occurrence names its own
cause rather than needing a runner to reproduce it on.

Co-Authored-By: Claude <noreply@anthropic.com>
Every running extension holds a connection of its own, so the clients list
has always shown it — as "Client 7", indistinguishable from a browser tab and
one click from a Kick that ends the attempt. The server knew better: the
origin it builds at accept time carried the extension id all along and was
dropped on the floor once the connection existed.

Keep it on ClientState and report it when asked. A row now reads "session"
with an extension tag, its attempt number beside the age, and a button that
says Stop attempt, because that is what kicking it does. The task id is a
random handle rather than an ordinal, so it stays in the row's tooltip with
the coordinates that address the attempt elsewhere.

Asking is opt-in through a trailing flags byte, answered with a new
S2C_CLIENT_LIST2: both shipped parsers reject a catalog with bytes left over,
so the wider entry has to be unreadable to them by construction rather than
by convention. Its origin block is length-prefixed so a later kind needs no
third opcode. Verified in all four directions — old client against new server
unchanged, new client against old server degrading to "unknown".

Co-Authored-By: Claude <noreply@anthropic.com>
The fs family had no one-shot read. `FS_FETCH` names a `sync_id`, so reading a
handful of files meant syncing a directory nobody wanted watched — and the
session supervisor took the other way out instead, spawning `/bin/sh` twice: once
for every `.desktop` file at startup, once for every icon a panel asks for. The
shell brought its own tax with it: quoting rules for every path it interpolated,
`base64` to get bytes back through a text stream, and `wc -c` to enforce a size
limit the protocol should have owned.

So `FS_READ` (docs/design/fs-read.md): a list of paths, a per-file ceiling, and a
status plus content per path, with no session and nothing watched. It grants no
new authority — a client with FEATURE_FS could already sync a directory and fetch
out of it — and it rides that same bit rather than a new one, both ends of this
protocol being in this repo.

`FS_READ_FIRST` answers the search-path question: the first of these paths that
exists, in my order of preference, stepping over what is missing, unreadable or
oversized. That is exactly an icon lookup, and it is what lets one message
replace a shell loop over `[ -f ]`.

The listing side needed two flags on `FS_INDEX`'s reserved byte to be usable for
this: `DIRS_ONLY`, because a search path is the shape of a tree without its
contents — an icon theme is fifty directories holding fifty thousand files — and
`FOLLOW_LINKS`, because some trees are made of links. Independently of both, an
entry that is a symlink is now classified by what it points at instead of being
skipped: without that a Nix system `applications` directory indexes as empty,
every `.desktop` in it being a link into the store, which also means file search
has been blind to symlinked files all along.

Measured against a real 212-entry catalog, before and after: the same 212
applications, the same 209 with artwork, the same three whose only copies are
over the read cap. `/bin/sh` no longer appears in the extension at all, and
neither does base64-through-stdout.

One regression comes with it: filling all 212 icons went from 28.7s to 39.5s.
Neither number is the read — a shell child answering 48 icons was already 135ms
per icon — but a request per name is more messages than a child per batch, and
the fix is to let one `FS_READ` carry a group of candidate lists rather than one.

Co-Authored-By: Claude <noreply@anthropic.com>
…ts bytes

Two things were slow, and neither was the read.

`FS_READ` now carries groups: a group is one question, and with `FS_READ_FIRST`
each is answered by its own first readable path, one record per group in group
order. So the icon search asks once per batch instead of once per name. Measured
from inside the guest, the fs half of a 48-icon batch is now 63ms.

That left the guest itself, which is an interpreter, doing several passes over
every byte of artwork at about 325 KB/s:

  - `push_json_string` pushed one `char` at a time. An icon is a 30 KB base64
    data URL with nothing in it that needs escaping, so it now copies runs
    between escapes — which for that string is one copy.
  - `base64` also worked a `char` at a time, into a `String`. It writes bytes
    into a sized buffer now. This was free before the migration off the shell,
    where `base64(1)` did it natively.
  - `flush_queued` cloned each 30 KB payload to send it and then memmoved the
    rest of the queue. It takes and drains instead.

A 48-icon batch went from 8.8s to 4.7s. The whole 212-entry catalog is still
20-30s, because 6.5 MB of artwork through an interpreter is what it is: the same
range as the shell version, which pushed the same bytes.

The way out is to stop moving artwork through the guest at all — answer the panel
with the resolved *path* and let it read the bytes itself, now that a one-shot
read exists. That is a change to what the extension and the panel say to each
other, so it is not in here.

Co-Authored-By: Claude <noreply@anthropic.com>
pcarrier and others added 4 commits August 18, 2026 15:55
An icon crossed the Wasm interpreter as a 30 KB base64 data URL inside a JSON
string, and that cost more than everything else the session extension did: the
bytes went through it at about 325 KB/s, so a screenful was seconds and the whole
catalogue twenty of them. Nothing about that was the search — the reads
themselves were 63ms.

So the supervisor stops carrying artwork and starts naming it. `FS_READ` grew
`FS_READ_NO_CONTENT`, which answers *which* path rather than what is in it — a
stat per candidate — and the icon message carries `path` instead of `icon`. The
panel then reads the file over the same one-shot read and hands the bytes to an
`<img>` as an object URL, which is what it wanted all along: no base64, no JSON
escaping, no copy per pass, and revocation on close so a panel that has been
opened does not keep every icon it ever drew.

Measured in a browser against a 212-entry catalogue: a screenful of 51 icons
draws in 2.5s where the guest used to spend that on a dozen. The interpreter now
touches no artwork at all — `base64` and `data_url` are gone from the extension.

It also fixes the three rows that could not be fixed before. The 128 KiB cap
existed because base64 grows a file by a third and the result had to fit one
channel message; with the bytes going straight to the panel the ceiling is a
megabyte, and Steam's habit of writing one 604 KB PNG into every size bucket —
`16x16/apps/steam_icon_327030.png` included — is no longer a letter tile. Every
icon that exists on this machine's disk now draws; what is left is entries whose
`Icon=` names something nobody installed.

Co-Authored-By: Claude <noreply@anthropic.com>
The Extensions tab had two tables over one identity: installed above, registry
below. An extension present in both was named twice, and updating one looked
exactly like installing a second — which is the one thing it must not look like,
since replacing a definition keeps its identity and installing a second does not.

So they are one list now, matched by name — the only handle the two sides share,
the digest being precisely what differs when an update is available. A row that is
both shows the digest it is pinned to next to the one on offer, and reads _Update_
or _Current_ accordingly. A transient `ext run` with no name matches nothing and
keeps its own row rather than collapsing into another's.

A registry was also a list of words and hashes, neither of which says what
installing one would do. The manifest now carries each module's
`package.description`, keyed by the bin target's name because that is what the
published object is called — the crate is `blit-ext-systemd`, the module is
`systemd.wasm`. Older registries have no descriptions, and a missing sentence is
not a broken entry.

Co-Authored-By: Claude <noreply@anthropic.com>
The banner above the unit table read "system: 412 units via signals, user: 88
units via signals" — a count that is already on the next line, and a source that
is only worth a reader's attention when it is `poll`, which is the case where the
table lags because there is no gdbus to subscribe with.

So it says "Polling (no gdbus)" then, and nothing at all otherwise. Only `poll`
counts: a scope is `unknown` until its first line lands, and announcing that as
polling would flash the banner open on every panel.

Co-Authored-By: Claude <noreply@anthropic.com>
…asked

A long-lived shell has no command boundary of its own. Agents typed into one
and then dumped scrollback, slept, or matched a regex against the whole grid
— including text that was already on screen.

OSC 133 (and OSC 633) already say where each command starts and ends. The
server turns that into a bounded journal per PTY and addresses output by a
monotonic sequence, so blit terminal journal / output / history --since can
return one command, or only what is new, without pulling the backlog.
wait --pattern now probes that cursor first.

Shell integration stays opt-in: blit does not inject rc files. Hooks are in
docs/shell-integration.md. A terminal whose shell emits nothing keeps an
empty journal.

Co-authored-by: Cursor <cursoragent@cursor.com>
@indent

indent Bot commented Aug 18, 2026

Copy link
Copy Markdown
PR Summary

Adds a per-command terminal journal so an agent can address a long-lived shell PTY by command boundary instead of scraping the whole grid. A shell emitting OSC 133 / OSC 633 semantic-prompt markers lets the server bracket each command into a bounded per-PTY ring of records, and output is addressed by a monotonic sequence (rotated_lines + row) that survives scrollback eviction rather than a grid row that moves under the reader.

  • Wire: FEATURE_TERM_JOURNAL (bit 28) and opcodes 0x500x53 (TERM_JOURNAL/OUTPUT/SINCE/JOURNAL_WAIT); BLIT_TERM_JOURNAL=0 withholds the bit and refuses every request with PERMISSION.
  • Server: total OSC 133/633 state machine, bounded ring (default 256, never-reused indices), sequence-addressed reads via new cursor_seq/oldest_seq/seq_text with a soft max_bytes and TRUNCATED/EVICTED/ALT_SCREEN flags, plus server-side waits resolved on command finish, timeout, or PTY death.
  • CLI: blit terminal journal / output [--wait] / history --since, all with --json; wait --pattern now probes the sequence cursor before subscribing and matches only newly-appended text, falling back to the old grid scan against a server without the feature bit.
  • Docs: RFC in docs/design/term-journal.md, opt-in hooks in docs/shell-integration.md (blit does not inject rc files).

Note: the branch sits on 33 unpublished local-main commits; the journal work is the tip commit c9c6e66 plus a clippy/prettier cleanup cd2f4f0.

Issues

All clear! No issues remaining. 🎉

1 issue already resolved
  • A taller (height-increasing) terminal resize with scrollback present breaks sequence identity: rotated_lines is not decremented, so cursor_seq and every live journal record/output cursor shift by the number of lines pulled back out of history, and seq_text then returns the wrong rows for a command. (fixed by commit f06fb9a)

View session

@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown

🔗 Preview: https://blit-es06e4y68-indent.vercel.app

@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown

Coverage

Crate Lines Functions Regions
alacritty-driver 75.6% (930/1230) 78.7% (74/94) 78.9% (1512/1917)
browser 0.0% (0/825) 0.0% (0/69) 0.0% (0/1404)
cli 39.1% (5731/14659) 45.4% (571/1258) 39.9% (8942/22384)
compositor 35.2% (6040/17178) 49.7% (501/1009) 35.0% (8302/23752)
desktop 77.6% (4164/5366) 70.4% (367/521) 74.0% (5772/7804)
fonts 85.3% (756/886) 89.9% (71/79) 86.4% (1485/1719)
fssync 92.6% (5521/5961) 94.4% (501/531) 92.8% (10189/10981)
gateway 34.0% (669/1966) 40.2% (68/169) 31.4% (1029/3279)
git 87.7% (4642/5295) 90.2% (378/419) 87.5% (7408/8463)
guest 82.2% (2217/2697) 80.0% (252/315) 81.1% (3718/4584)
lsp 77.1% (2687/3483) 79.4% (262/330) 74.9% (4207/5619)
proxy 19.2% (172/898) 20.5% (26/127) 21.0% (293/1392)
remote 91.6% (16910/18462) 94.4% (1189/1260) 89.1% (27485/30846)
sd-notify 73.9% (68/92) 100.0% (6/6) 83.2% (109/131)
server 56.2% (32121/57122) 63.4% (2762/4358) 57.5% (48673/84681)
ssh 32.2% (165/512) 48.2% (27/56) 31.4% (261/830)
upsidedown 31.4% (391/1247) 27.8% (55/198) 34.8% (797/2287)
webrtc-forwarder 8.5% (238/2805) 10.7% (22/205) 6.3% (289/4595)
webserver 64.4% (1250/1941) 67.6% (173/256) 66.6% (2099/3151)
Total 59.4% (84672/142625) 64.9% (7305/11260) 60.3% (132570/219819)

parse_mark uses `?` instead of a nested strip_prefix, the command-length
test initializes the bound in the struct, and prettier gets the three files
the previous fmt pass had left unformatted.

Co-authored-by: Cursor <cursoragent@cursor.com>
Comment thread crates/alacritty-driver/src/lib.rs Outdated
pcarrier and others added 2 commits August 18, 2026 19:06
AddAppSocket was copied from the Linux compositor with std::os::fd::OwnedFd,
which Windows does not have. The non-Linux stub already has its own OwnedFd
placeholder; use that so cargo build -p blit-cli compiles there.

Co-authored-by: Cursor <cursoragent@cursor.com>
A taller viewport runs alacritty grow_lines, which shrinks history and
adds from_history to cursor.line. saturating_sub treated that as zero, so
rotated_lines stayed put and every captured sequence jumped. Follow the
signed history delta instead: shrink still increments, grow decrements,
and the cursor move cancels. Tests cover both directions; the RFC now has
the Resize section the code already cited.

Co-authored-by: Cursor <cursoragent@cursor.com>
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